develop to main (#538) · Posnic/POS@50fa738 · GitHub
Skip to content

Commit 50fa738

Browse files
sridharkalaibalaTyagiquamarsridharposnic
authored
develop to main (#538)
* SDK: add external-sale idempotency helper Closes #399. Links back to app platform parent #34. Adds a connector SDK helper to generate stable idempotency keys and evaluate duplicate imports for external ecommerce order connectors: - stable idempotency key generation decoupled from transient retry counters - deterministic payload hashing with recursive key canonicalization - conflict detection for mutated payloads requiring merchant review - synthetic order fixtures covering first import, duplicate retry, mutated payload, and unrelated orders - unit test suite verifying all states * Fix external sale idempotency key matching (#483) Signed-off-by: Tyagiquamar <mohdquamartyagi@gmail.com> * Preserve proto keys in payload hashing (#483) Signed-off-by: Tyagiquamar <mohdquamartyagi@gmail.com> * Make the channels settings group reachable at all Two bugs, both of which shipped, both invisible to every test in the tree. "Unknown settings group" on the Channels tab. A settings group is declared twice: GROUPS in services/settings-groups.js says which keys it owns, and COLLECTION_OF in repositories/settings.repository.js says which collection holds them. `channels` was in the first and not the second, so the repository refused every read and every write. The tab could never load and could never save, from the day it was added. "itemService.defaultStoreId is not a function" on /online-ordering. item.service exports a CLASS; sale.service next door exports a ready-made object. The online ordering controller required the first the way you require the second, so every method was undefined and the storefront, the public menu and the default-branch lookup were all dead on the deployed API. Neither could be caught by the tests that existed, and that is the more interesting half. The controller tests mock the service, and a mock has whatever methods the test gives it; the group tests either side each mocked the other list. Both failures only exist where the real objects meet. So two checks that use the real objects: settings-group-wiring the two group lists have to agree, in both directions, with channels named explicitly service-shape every service method a controller calls has to exist on the real service, no mocks Found by opening the page on develop, not by running the suite. * Format the new wiring test * Make sending the same order twice safe A waiter taps send, the Wi-Fi drops before the reply arrives, and the app cannot tell "never reached the kitchen" from "reached it and the answer was lost". Those need opposite responses and look identical from the handset. Both apps have sent an idempotencyKey for as long as they have existed and nothing read it. The field was destructured nowhere and stored nowhere, so every retry wrote another ticket and the kitchen cooked it twice - a key that is sent and ignored is worse than no key, because it reads like protection. The online-order writer now reads it, stores it on the sale, and returns the sale that already exists instead of writing a second one. The lookup is scoped by licence, or a key could match another shop's order. Keyless orders are untouched: the field is written only when present and the lookup is skipped without one, so an older handset and every order already in a shop's history behave exactly as before. This is what lets a handset hold an order and send it when the network comes back. Until now the only safe answer to a failed send was to refuse to retry and make somebody check the kitchen screen. * WIP on channel-features: 4e74ca71 Format the new wiring test * Say that this server dedupes orders, so a handset can resend A phone that loses the network mid-order cannot tell "never reached the kitchen" from "reached it and the reply was lost". Those need opposite responses and look identical from the handset. Against a server that returns the order already written, resending is free and the app can hold an order and send it when the network returns. Against one that does not, the same resend is how a table gets two of everything - so the app has to know which it is talking to, and nothing in the response said. features.idempotentOrders is a constant, not a check: the behaviour ships with this code. Absent is the honest answer for every older server, which neither advertises it nor honours the key. A test pins the claim to the code that backs it, because advertising a behaviour the writer does not have is how a handset gets told it may resend into a server that writes a second ticket. * Offer a discount note only once there is a discount to explain The chip sat beside Payment note and Sale note from the moment the sale screen loaded, so a cashier could write a note about a discount nobody had given - and the note then described nothing. On a counter with a customer waiting, a control that cannot apply yet reads as one that is broken. It follows the discount row now, which is the same signal the notes cell already uses for its rowspan, so the chip appears the moment a discount is added and goes again if it is removed. Wiring it into syncNotesCellSpan rather than the add-discount click means every existing call site is covered, including the ones that clear a sale. The wording follows the note itself: a blank one says Add note, an existing one says Edit note. That is the difference between pressing a button to find out what it does and knowing beforehand. It starts hidden in the markup, the way Add charge already does, so it cannot flash into view on first paint before the sync runs. Hidden by inline display rather than a class, because syncActionTooltips reads btn.style.display to decide whether the tooltip wrapper should follow - a class would leave a tooltip hovering over a control that is not there, which this screen has had before. The two new strings go through PosnicPro.i18n.t so a language pack can reach them; _english.json regenerated accordingly. * Say which channels sell an item, and change it in bulk An item is on every channel the shop runs unless it says otherwise. THE DIRECTION IS THE DESIGN. The obvious model gives each item a list of the channels it IS on. With six channels and two hundred lines that is twelve hundred flags before a shop can sell anything, the answer is "all of them" for almost every line, and a channel switched on next month sells NOTHING until somebody walks back through the catalogue. Storing the exceptions instead means a new item sells everywhere, a new channel sells everything, and a shop records only what is different - the cigarettes it will not put on Swiggy. channel_off ['swiggy', 'online'] not sold here channel_hours { online: { from, to } } sold here, but only then Entries are a channel id OR a partner id. "Not on Swiggy" is the request shops actually make, and swiggy is a partner on the marketplace channel rather than a channel of its own - excluding at channel level only would take the item off Zomato too, which nobody asked for. A partner id this code does not recognise simply never matches, so the item stays on sale: a typo that keeps selling is a correction, a typo that silently stops selling is a day of lost orders with no error anywhere to explain them. The hours are a different clock from the serving periods. Breakfast is breakfast on every channel; this is "we stop taking app orders for this at three because it arrives cold". An item can carry both and both must be true. WHERE THE WORK IS DONE. Two places, because there are two moments: the item page one line, while you are editing it anyway the channel page the whole catalogue, filtered by category or by typing part of a name, switched on or off in one go The bulk write touches only what CHANGES. "Sell everything on Swiggy" over four hundred items writes the handful that were off, not four hundred documents - sync replaces whole documents, so every needless write is a chance to lose a field somebody else changed a second earlier. The result says how many actually moved rather than how many were selected. Three things worth knowing: - The storefront and the menu now filter on this, with $ne so absent means included. A shop that never opens the screen loses nothing. - The channel list on the item page comes from the shop's own settings, so a shop that does not use Swiggy is never offered it and a partner added last week appears without a release. - Two i18n traps caught by the tooling: a t() call in a literal runs before the language pack loads, and a key assembled by concatenation is invisible to the translation sweep. Both are spelled out now. * Classify a runtime flag as a grant or a capability The contract test held that every flag in features defaults false, because until now every flag was a grant: something this installation has been given, like an account, which a bare environment has not. idempotentOrders is not that. It says the build returns the order already written rather than writing a second one, which is true wherever this code runs. Defaulting it false would have every server lie about itself, and a handset reading it would hold back behaviour the server supports. So the test names the two kinds rather than looping over them, and a new flag has to be added to one list or the other. That is the point: classifying it is a decision, not a detail. * Put the settings where a shop would look for them Three moves, each because the setting was not where its name said it was. TABS NAMED AFTER WHAT THEY ARE. Account, Images, Payment, Print were named after the old kiosk screen. Three of those are not kiosk settings: they are STOREFRONT settings that the kiosk machine and a customer's phone both read. Checked rather than assumed - /online-ordering/:storeId/device returns exactly one field more than the public reply, and that field is the printer. So the tabs read Storefront, Branding, Payment and Kiosk printer, and the one that really is device-level says whose device it is. SERVING PERIODS MOVED TO RESTAURANT. Breakfast is breakfast wherever the menu is shown - on a QR code, on the kiosk, in the app. They are the kitchen's clock rather than one channel's, and leaving them inside a channel would have meant copying them into the next channel within a month. They still LIVE in the channels settings group, because moving a stored key is a migration for no gain: the screen a thing is edited on and the group it is stored in do not have to agree. That move had a trap in it. The channels tab collected menu_dayparts from markup that is no longer there, so every save of that screen would have sent an empty list and wiped every serving period a shop had set - no error, nothing to explain it. The key is deliberately absent from that payload now, and the group endpoint writes only what it is given. THE KIOSK MACHINE MAKES A NOISE. The alert was wired for the online storefront and stopped there, so an order placed on the shop's own terminal landed in silence - the one channel where a ticket is printing in another room and nobody at the till has any reason to be looking at a screen. A CHIME, never the alarm: the customer is standing at the machine and the ticket prints on its own, so it is information rather than a task. The repeated insistent sound stays for an order WAITING on a person, and using it here would teach staff to ignore the one that matters. * Offer the platforms most shops mean, one tap each Swiggy, Zomato, ONDC, magicpin, OpenCart, WooCommerce and Shopify are buttons on the Channels tab now, beside the partner list. THE IDS ARE THE POINT, not the convenience. A preset writes the exact id the server already knows - the one a sale stores in channel_partner and the one the commission report groups by. A shop that types "Swiggy" one day and "swiggy" the next gets two rows holding half a month each, and neither total is the real one. tests/partner-presets.test.js pins the screen's list against KNOWN_PARTNERS: ids, labels, and which channel each belongs to, so the two copies cannot drift. NO RATE IS GUESSED. What Swiggy charges this shop is what this shop negotiated. A plausible-looking default is exactly the kind of number that gets saved unread and then disagrees with an invoice, so it starts at zero and somebody types it. A platform already in the list is shown as used rather than hidden: a shop looking for Swiggy should find it either way and learn it is already there, instead of adding a second one. Presets are a starting point, not a restriction. A local aggregator nobody has heard of is still added by hand - partners are DATA rather than features precisely so a new one is never a release. The auto-tagger wrapped every one of these brand names in a translation call, which put seven rows in a translator's queue that each have exactly one correct answer, and ran them at LOAD time where no pack exists yet. Brands are exempt from tagging now, with the list beside the rule so the next platform added inherits it. * Transcribe a waiter's spoken order on the till, never on the phone A captain taking an order by voice has to send the audio somewhere. The obvious shortcut is to let the app call a transcription provider itself, and it is the wrong one: that puts a paid API key on every waiter's handset, in a file anybody can read, on devices that get lost and sold. It cannot be rotated without reinstalling every phone, and one leak is billed to the shop until somebody reads the invoice. So the key lives here. POST /api/sales/transcribe takes a clip and returns words. The handset never learns which provider was used. Two settings, deliberately in different groups. voice_provider is a preference, because the NAME of a provider is not a credential and a settings screen has to be able to show which one is chosen. voice_api_key is a secret, so the settings endpoint can only ever answer whether one exists. Adding a provider is one entry in PROVIDERS; openai and google are here. A provider's own error never reaches the caller, because those messages carry the request and sometimes the Authorization header with it. Behind optionalProtect + protectOrKioskKey: a signed-in user or the shop's own equipment. It spends the shop's money per call, so anonymous traffic has no business on it. The tests pin the wiring as much as the behaviour. settings.repository exports the CLASS, and calling resolveGroup on the export gives undefined, which throws, which the caller turns into "could not transcribe" - a feature dead for every shop and indistinguishable from a provider having a bad day. data-sharing.js shipped with that exact bug behind twenty-one green tests. * Drive the bulk channel screen instead of reading it The domain logic has eighteen tests and not one of them can see the failures this screen is actually prone to: a click handler bound to a selector that does not match, a checkbox class the collector never reads, a payload posting the wrong field name, a button that silently does nothing. Every one of those passes node --check, passes eslint, and is invisible until somebody clicks it. Three bugs earlier in this feature's life got through exactly that way. So this builds the markup, loads the real module, lifts the real delegated handlers out of the real file, clicks the real buttons, and asserts on what would have gone over the wire. Taking the handlers from source rather than re-declaring them matters: a handler bound to the wrong selector is the bug being hunted, and rewriting it here would only test the test. It found one thing immediately, and the screen was right rather than the test: a successful bulk change RELOADS the list, which clears the ticks. That is deliberate - the rows now show different states, and leaving them ticked invites a second bulk action nobody meant - so it has its own test now instead of being a side effect somebody later removes as noise. This replaces most of "somebody has to walk the screen", not all of it. It cannot see a control that is invisible, mispositioned or unreadable, and the alarm still has to be heard by a person to know it is loud enough. * docs: link POS deployment decision guide * Find the dish when the customer cannot spell it The menu's search was a substring test, which fails in exactly the way that matters: a hungry person on a phone types "panner", "biriyani", "manchurain", and a menu answering "nothing matches" reads as a restaurant that does not sell it. Every food app in the country tolerates this, so a menu that does not feels broken rather than strict. THE BUDGET IS THE WHOLE DESIGN. Damerau-Levenshtein, so two adjacent letters swapped costs one edit rather than two - "biriyani" is one slip to a person. But the allowance SCALES with word length: nothing forgiven under five characters, one edit to seven, two beyond. A fixed budget of two turns "dal" into "dosa" and every short word matches every other, which is the same uselessness as matching nothing. Matching is per word and every query word has to find something. Somebody who typed "butter naan" meant both words, and matching on one would put Butter Chicken above the naan they plainly asked for. A name hit outranks a category hit outranks a description hit, and anything spelled correctly outranks anything forgiven. FILTERS AND SORT COMPOSE WITH IT. Veg only, available now, and a sort by menu order, most ordered or price. These were one function reading a text box, so turning a filter on silently threw away whatever had been typed; they are three independent narrowings of one list now. Veg only never assumes an unmarked dish is vegetarian - a shop that left the field empty has promised nothing, and guessing on its behalf is the one mistake that filter must not make. A live search overrides the sort box, because somebody who just typed "dosa" is asking a question and answering it in price order buries the dosa. WHAT SELLS, AND WHAT SELLS BESIDE IT. One pass over the last thirty days of this branch's sales gives both: how often a dish appears, and how often two dishes appear on the same bill. Deduplicated per sale, so a table of twelve ordering six biryanis counts once; bills wider than twelve lines are skipped, because a catering order would otherwise decide what the whole menu recommends. Suggestions appear only in the detail sheet, where somebody has already stopped to read one dish, and a dish with no history offers nothing rather than filling the space with three at random. THE ENGINE EXISTS TWICE, ON PURPOSE. The bundle is plain scripts on a phone with no build step, and being fast on a bad connection is why that page exists; giving it a module loader to share ninety lines would cost more than the copy does. tests/menu-search-parity.test.js runs the same queries through both and compares match AND score, because two copies that agree on matching and disagree on ranking still show two different menus. * Give the ordering page the search it never had The digital menu could find a dish and the page people actually order from could not. /order had category scrolling and nothing else, so a customer hunting one line in a catalogue of four hundred scrolled until they gave up - the read-only page was better at finding food than the page where money changes hands. SEARCH SPANS EVERY CATEGORY. Somebody typing "biryani" is asking the restaurant a question, not the Mains tab. A live search leaves the category strip behind and shows one flat list of answers, best first, and the strip comes back the moment the box is cleared. Same engine as the menu, same typo tolerance: "panner" finds the paneer, "biriyani" finds the biryani, and "dal" still refuses to find "dosa" because the edit budget scales with word length. Veg only, and a sort by menu order, most ordered or price. Veg only never assumes an unmarked item is vegetarian - a shop that left the field empty has promised nothing. THREE FIELDS THAT NEVER REACHED THIS BUNDLE. diet, description, prep_minutes and how often a line sells were all in the menu's reply and absent from the storefront's, which is the real reason this page could not filter or sort. The storefront carries them now and the device stores them, so the veg mark also appears on the card rather than only behind a filter. renderProductCards is lifted out of showCategory, because what is being drawn is no longer "the open category" - and two copies of the card markup would mean search results quietly losing a button the category view still had. The matching arithmetic is copied from menu/menu.js, which carries a port of api/src/utils/menu-search.js. Neither bundle has a build step; tests/menu-search-parity.test.js pins the copies to identical answers, including rank, because two copies that agree on matching and disagree on ranking still show two different menus. * Give every dish a picture without asking anybody to upload one A menu card with a picture is better than one without, and a shop with three hundred items will photograph none of them. That is not laziness: it is three hundred photographs, lit, cropped and uploaded, for a list that changes every season. So the real choice was never "photo or no photo". It was "something or nothing", and it has been nothing. An emoji is the something. Already on every phone, in colour, at any size, in both themes, with no download, no licence and no storage. One short string on the item, rendered as text. AND NOBODY HAS TO CHOOSE IT EITHER, which is the whole point. A shop that will not upload three hundred photographs will not pick three hundred emoji. So the NAME is read: Chicken Biryani is a rice dish, Masala Dosa is a dosa, Filter Coffee is a coffee, Lifebuoy Soap is soap. An ordinary menu comes out decorated with nobody opening the item form, and the form is only where the handful a shop disagrees with get fixed. That is the shape the sale grid's tile already uses - derived from the name, visible, changeable any time - except that a hashed colour means nothing and a picture of a dosa means dosa. The render order extends the existing one: a photograph beats the shop's own icon, which beats the guess, which beats nothing. Nothing is an honest answer and is what "Item 4" gets; a grey placeholder box is a promise of a picture that never arrives. WHICH WORD WINS, AND WHY IT IS NOT LONGEST-MATCH. A name carries the dish (biryani, dosa, pizza), the thing (paneer, chicken, prawn) and the method (tikka, tandoori, fry), and they are not equally useful to somebody scanning a menu. Read in that order, Chicken Biryani is a biryani rather than a chicken, Paneer Tikka is paneer rather than a skewer, and Chicken 65 - which names no dish at all - is chicken. Longest-match got both of the first two wrong. Matching is on WORD BOUNDARIES, so "chapati" is not a chai and "scone" is not a cone. A wrong picture on a menu card is worse than a blank one, so the table stays narrow and answers nothing for a name that says nothing. "sweet" came out of the sweets list for the same reason: it is an adjective before it is a dish, and it was turning Sweet Lassi, Sweet Corn and Sweet Lime all into a boiled sweet. ONE IMPLEMENTATION. The server resolves it, for the menu page, the ordering page, the shop's own terminal and the captain app alike, so every screen draws the same picture for the same dish and a build that never heard of this gets it anyway. The item form asks the server rather than shipping the table twice: a second copy can drift, and the drift shows up as a shopkeeper being shown one picture while their customers are shown another, which nobody reports because nobody sees both screens at once. The stored field refuses letters and digits, so it cannot quietly become a second name, and is capped by CHARACTER rather than by UTF-16 unit - every emoji worth having is a surrogate pair, and slicing one in half leaves a broken glyph on every card that dish appears on. * Give the captain handsets their two endpoints back `POST /items/accessQr` and `POST /sales/qrOrder` were deleted with the rest of the old online-ordering shapes, on the grounds that nobody was using the channel. That was true of the channel and not of the apps. Signed captain builds are published and being installed on phones, and a phone cannot be updated from here: without these, a waiter can log in and then load no menu and place no order. The kiosk route came back a commit later for exactly this reason, one device along. This is the same fact about a different handset. TWO THINGS CHANGED WHILE THEY WERE AWAY, AND BOTH ARE KEPT. They are no longer anonymous. They never should have been. accessQr took a branch's raw database id - which appears in every authenticated response and is no secret - from anybody at all and answered with that branch's catalogue; qrOrder wrote a real sale the same way. Both now need a signed-in user or the installation's kiosk key, which every captain handset already satisfies: it sends the token it logged in with to every other route it calls. And accessQr asks for the TABLESIDE channel rather than the customer one. A line a shop will not put in front of a stranger's phone can be perfectly fine for a waiter standing at the table, and until now those were the same list. Underneath, both are the new implementation. accessQr is the storefront query wearing the old field names; qrOrder is createOnlineOrder, so the idempotency key a queued handset replays is honoured here too and a retry cannot double a ticket. There is still one storefront and one order path rather than two that drift. The branch lookup gains a second, separate parameter rather than learning to accept either kind of value in the first. `storeId` is the public door and still resolves only by store address; `branchId` is the staff door and a route may pass it only once it has established that the caller works for the shop. Keeping them apart makes the guard a property of the route, so it cannot be lost by a store address happening to look like an id. GET /online-ordering/:storeId/device and POST /online-ordering/:storeId/orders remain what new work is built against. * Do not let a suspended shop halt the deploy The ring smoke proved a reload by taking the FIRST host in tenants.map and polling it until it answered 200. A suspended shop is answered 403 by nginx without the request ever reaching the app, so it can never go 200: the gate waits its full 120 seconds and halts the rollout, and every later ring keeps serving the old build. Twenty-one shops are suspended today, and which one happens to sit first in that file is an accident of provisioning order. Nothing was wrong with the release on the day that ordering changes. The same applies to a shop that is simply broken or half provisioned. So the candidates now exclude suspended shops and there are five of them rather than one, and the poll is bounded by a wall-clock deadline instead of a round count, because trying several hosts per round would otherwise multiply the wait it promises. An empty candidate list is an error that says so, rather than 120 seconds of polling nothing. Also gitignore database snapshots. A 1.1GB raw copy of the production database - every shop, every customer - was sitting untracked in this working tree, and the only thing keeping it out of a repository was that nobody ran "git add -A" while it was there. The redundant extracted copy is deleted; the archive it came from is now ignored. grep -P became grep -E in the same lines. The pattern has no PCRE-only syntax and -E is available in places -P is not. * Actually ship the customer bundles /menu and /order have answered 404 on every environment since the day the feature shipped. Not a routing bug: the directories were never deployed. api/app.js mounts them from path.join(__dirname, '..', 'menu') and '../order', each behind fs.existsSync. Production rsynced api/ alone; develop rsynced api/, frontend/public/ and languages/. Neither ever copied menu/ or order/, so the guard did exactly what it was written to do and skipped the mount - no error, no warning, nothing in a log. Every customer URL fell through to the 404 handler, and a 404 reads as a routing problem rather than a deployment one, which is why it survived review, a release, and every test in this repository. Found by curling the live site, not by the suite: https://azure.posnic.io/order 404 https://demo.posnic.io/menu 404 https://develop.posnic.io/menu 404 Three things, because one of them would only fix today: the deploys both workflows rsync menu/ and order/ now a log line a missing bundle says so at boot instead of going quiet a test tests/bundle-deployment.test.js reads the *_BUNDLE constants out of app.js and demands every deploy ship each one, in both directions The test is the part that matters. The lesson is not "remember the rsync" - it is that a directory the code serves and the deploy does not ship is a pair nobody can hold in their head, and it has to be checked by something that runs on every pull request. * Show every photo of a dish, not just the first Shops have been uploading sets per item for years - the item form has taken them all along, and item.multi_image has held them - and the menu showed exactly one. The rest were taken, stored, backed up, and never seen by a customer. The detail sheet carries a strip you push sideways now, with dots underneath. A strip rather than a carousel with arrows: a thumb already knows how to shove a row of images, and a control that has to be discovered is one most people never touch. Scroll snapping does the work a carousel script would, so there is no library and no frame-by-frame position maths. The CARD still shows one image. A gallery belongs where somebody has stopped to look, not in a list being scanned - and the owner's own read was that most people never open the detail at all, which makes the strip a reward for the ones who do rather than weight everybody carries. Only the first photo loads eagerly. The rest are off-screen until the strip is pushed, and a phone on a bad connection should not pay for five images of a dish nobody opened. Each carries "name, photo 2 of 4" so the strip is navigable without sight. THREE FALLBACKS, IN ORDER, because a menu must never show an empty grey box: photos where they exist, the single cover image for a reply that predates this field, and the generated dish icon for the shops - most of them - that upload nothing. That last one landed from another branch the same afternoon; the two meet here, with a test pinning that a photo beats an icon rather than sitting beside it. * Correct an advisory note that said no fix existed The extract-zip acceptance argued, among other things, that no fixed version was published. That was true when it was written and is not now: @puppeteer/browsers 3.x dropped extract-zip for modern-tar, and it ships with puppeteer 25. The acceptance still holds on reachability - the archive is a Chromium build fetched from Google over HTTPS and is never attacker-supplied - but an exception that rests partly on a claim that has gone stale is one nobody can re-audit. What actually blocks the upgrade today is that whatsapp-web.js 1.34.7 pins puppeteer to exactly 24.38.0, so taking the fix means running WhatsApp on a puppeteer major it was never tested against. Noted so the next person sees the real trade and knows when it gets cheap: whenever whatsapp-web.js moves to puppeteer 25 itself. * Run the production deploy when a bundle changes Shipping the directories was only half of it. deploy-api.yml is filtered to `paths: ["api/**"]`, so a commit touching only menu/ or order/ would pass CI, merge into main, and never reach a shop - the workflow would not even start. That is a quieter failure than the 404 that started this. At least a 404 is visible; a workflow that never runs leaves nothing in Actions to notice. This branch is exactly that case: it changes menu/ and one line of api/, and without the filter fix the menu half would have deployed only by the accident of the api/ line travelling with it. develop needs no equivalent - that workflow has no paths filter and fires on every push. tests/bundle-deployment.test.js now checks the filter as well as the rsync, from the same list of bundles it reads out of app.js. * Let a deploy change prove itself (#517) deploy-frontend.yml and deploy-frontend-cdn.yml both list themselves in their paths filter. deploy-api.yml did not, so a change to HOW the API deploys sat unused until some unrelated api/ commit happened to carry it out - the one commit that most needs proving is the one that never ran. The bundle rsync added earlier today is exactly that shape: it would have deployed only because an api/ line travelled beside it. The test learned something too. It understood `paths: ["a", "b"]` and not the dashed list, so it failed the moment this file was reformatted - and a check that breaks on formatting teaches people it is noise rather than that the deploy is wrong. It reads either spelling now. * The plumbing an AI feature goes through, and nothing that uses it yet (#516) This is deliberately not a feature. It is the one place a model gets asked anything, so there is one place the key lives, one place that decides whether a shop has this switched on, and one place that can be made to stop. Built now, before any feature, because the rules below are the part that has to be right and they are the part that gets skipped when a feature is the exciting bit. THREE RULES, ENFORCED HERE SO NO FEATURE HAS TO REMEMBER THEM. Never in the sale path. A till sells when the internet is down; that is the whole product. Nothing here may sit between a cashier and a completed sale, and every failure must be survivable by doing the job the old way. Nothing is applied without a person. A model is not deterministic - the same question answers differently twice - which is intolerable for anything touching money or stock. So this returns TEXT for somebody to look at, and the writing is done by the same reviewed, tested paths that a person typing it in would use. Off by default, and "off" means honestly off. `available()` says no for a shop that chose a provider and never pasted a key, because the setting alone is not the feature: a screen that believes it is puts a button in front of a shopkeeper and fails when they press it. It never throws either, so a screen can always ask. WHERE THE KEY LIVES. Same as the voice pair and for the same reason: a key in a page or a handset is a key on every device that ever loads it, cannot be rotated without reinstalling all of them, and is billed to the shop until somebody reads the invoice. ai_provider and ai_model are preferences, because the name of a provider is not a credential and a screen has to show which one is in force. ai_api_key is a secret, so the settings endpoint can only ever answer whether one exists. CAPS, because a bug must not become an invoice. Six pictures, six megabytes each, twenty-four thousand characters in and four thousand tokens out, ninety seconds. The image cap is measured DECODED, which is what a provider bills and what the timeout is spent on; base64 is a third larger than the bytes it carries, so checking the string would let a file through that is really over the cap. Three providers - anthropic, openai, google - each one entry in PROVIDERS, each given the question and the key and returning text. Nothing else about the shape of a request reaches the caller, so a feature can never come to depend on which provider a shop happens to use. A shop may name its own model: accounts differ in which they are allowed, and one on a tier the default is not on would otherwise be locked out by a 404 it cannot act on. A provider's own error never reaches the caller. Those messages carry the request and sometimes the key with it. jsonFrom() is here because models wrap JSON in prose and in code fences however firmly they are asked not to, and a feature that crashes on that works in testing and fails on a Tuesday. It answers null for anything it cannot read, and every caller has to survive that. * Merge the storage keys into .env instead of writing over it (#518) The production deploy's "Write storage env" step ended in cat > ~/apps/tenants/app/api/.env which replaces the whole file with the five S3 keys above it. Every instance keeps MONGODB_URI, JWT_SECRET, SESSION_SECRET, ENCRYPTION_KEY, KIOSK_API_KEY and around a hundred more in that same file, and not one of them is in this repository to restore afterwards. Every shop would have come back up with no database URI. It has never fired, and that is the part worth naming. The step's own guard exits early because the S3 secrets are not set here, printing S3 secrets not set in this repo; skipping env write - uploads will keep falling back to local disk on every production deploy. That reads like a note about image uploads, so nobody read the branch underneath it. The first person to switch uploads on by setting AWS_S3_BUCKET would have taken the estate down with a green deploy. A step that is harmless only while it is disabled is a fuse, not a safeguard. Now it drops the keys it is setting, keeps every other line, appends its own and moves the result into place in one step, leaving one .bak. tests/deploy-env-merge.test.js pins it three ways: no workflow may truncate a .env, the merge parts must still be there, and the step's own text is run against a fake instance with ssh replaced by a local shell, asserting that unrelated keys survive, that the key it does own is updated rather than duplicated, and that no temp file is left behind. Reverting the workflow to the old line fails three of the four. * Let a shop actually turn voice ordering on, and fix the setting it turns (#519) There was nowhere to configure this. voice_provider and voice_api_key existed as settings keys and nothing on any screen wrote them, so the feature could be switched on by editing the database and by no other means. That is the gap this closes, and closing it turned up a worse one. ONE SETTING, TWO READERS, AND THEY DID NOT SPEAK THE SAME LANGUAGE. voice_provider holds what the SHOPKEEPER chose, in the words a shopkeeper would use: off, the phone itself, or a company they have an account with. The till read that and knew which vendor to call. The handset read the SAME FIELD expecting its own vocabulary - off, device, server - so a shop that chose `openai` had a handset that recognised neither `off` nor `server`, fell through to its own recogniser and never called the till at all. The shop had configured a paid provider, would have been billed for nothing, got device recognition, and nothing anywhere said so. So the handset is told a DERIVED value, and utils/voice-settings.js is the one place that derivation lives. A handset learns where the audio goes and nothing else. It is never told which vendor, because a handset told the vendor is a handset that will eventually be asked to hold the key for it, and telling one phone tells every phone in the building. The test asserts the vendor name does not appear in what a handset receives. The till now also refuses `server` as a saved choice. It is the handset's word, never a shop's, and seeing it stored means something wrote the wrong vocabulary into the setting - which would bill a shop through a vendor nobody picked. THE SCREEN. Integrations, beside Analytics, which is the same shape: a hint, a choice, a credential and a Save. One dropdown, because a shopkeeper is choosing a thing they can name rather than an architecture. The key row appears only for a provider that needs one - a control that cannot affect anything should not ask for a decision. The key goes through the secrets group and is write-only: read back as "configured", never as a value. An empty key on save means LEAVE THE SAVED ONE ALONE, or the first person to change the language would blank the shop's credential. voice_language is new and is not decoration. A recogniser told the wrong locale mishears NUMBERS before it mishears anything else, and a number is half of every order. Shaped rather than listed, because the tags a recogniser accepts differ by platform and by version and a list here would refuse a language some handset supports perfectly well. Anything not tag-shaped is refused outright, because it reaches a native API. Both defaults are chosen so a shop that never opens this screen still gets something that works: the phone's own recogniser, in Indian English, for nothing. Switching it off is the deliberate act. The settings ride along with the menu, which is the one call every handset already makes on every start, so a change is in force the next time a waiter opens the app. * Give every channel its own settings page (#521) The Features page was split into one card per channel and shipped. The settings behind those cards were not, and the code said so: All five point at the one channels page for now. The Features page is split; the settings pages behind it are not, yet. So every card opened the same combined screen, still headed "Sales Channels" - our reporting word, not a shopkeeper's. From the console that is indistinguishable from a deploy that never landed. Four panes now, each with its own sidebar entry gated on its own feature switch: Online Ordering storefront, branding, payment, new orders Kiosk Machine the printer, and a line saying the rest is shared Delivery Partners the aggregators and what they keep Webshop a shop the owner runs themselves Venues and delivery charges went to Restaurant, beside the serving periods. A hotel is reachable from the QR code, the phone and every aggregator, and a delivery charge belongs to how the food travels rather than to which app the order came through; both would have been copied into the next channel within a month. Two things the split could have broken, and did not: The "ways this shop takes orders" checkboxes went, because the Features cards choose channels now. collect() read them into sales_channels_enabled, so every save would then have posted an empty list and cleared the value the item channel picker reads. The key is left unsent, the way menu_dayparts already is. Every channel screen still saves the whole group. The panes are hidden rather than removed and collect() reads the DOM by class, so a save from any of them writes every channel's rows wherever they sit. The products screen is one copy that moves into whichever channel is open, with that channel preselected. Four copies would have meant four sets of the same ids. tests/channel-panes-split.test.js pins all of it, including the generic rule that no feature card may point at a section that does not exist - openSection falls back to Core Settings for an unknown key, which is why the old code aimed all five at one page rather than at pages it wanted. Run against the pre-split files, three of the four channels share a page and none has a pane of its own. * Give the captain app a page, and a way to pair a handset (#524) It was the one channel without its own settings. Its card pointed at Restaurant, on the grounds that the app runs on the tables Restaurant manages. That reads fine and it left the one thing a shop actually has to do with a handset unreachable. Pairing a phone is a screen the API serves at /pair. It is deliberately a plain page outside the console bundle so it works on a till that is mid-setup with no internet, and nothing in the console has ever linked to it. The only way to find it was to be told it existed. So: Captain App, beside the other four, gated on module_captain_enable. Two tabs. Handsets explains what to install, opens the pairing screen, and says plainly that the code carries an address rather than a password. What it sells is the same shared products screen the other channels borrow, with the tableside channel preselected - which is new capability, not just a new page: a shop could not take an item off the captain app from here before. The two settings the app uses that are not only its own stay where they belong, with links rather than copies: tables and serving periods on Restaurant, voice ordering on Integrations beside the key it needs. The pairing link is built from API_URL, never a relative path. The packaged desktop build serves this console from file://, where "/pair" resolves to the filesystem root and the link would silently do nothing - the same trap dashboard.js documents for its runtime-info fetch. tests/channel-panes-split.test.js covers five channels now, and a new test ties the three ends of the pairing link together: the console has the link, the API still mounts /pair, and the handler builds the URL from API_URL rather than a path that dies in the desktop build. * Put voice ordering where somebody would look for it (#522) It went under Settings > Integrations, beside webhooks and API tokens. That was filing it by how it is BUILT - it can hold a third-party key, like the Analytics card beside it - rather than by what it IS. What it is, is a captain app feature. A restaurant owner who wants their waiters to speak orders goes to where they switched the captain app on. Nobody has ever gone looking for that under Integrations, and the fact that the implementation involves somebody else's API is our problem, not a shopkeeper's filing system. So it lives in the Captain App card on the Features tab, in the module-subs block that already exists for exactly this - a setting that belongs to a module and sits inside it. The card dims itself and disengages its sub-controls when the module is off, so these stop asking for a decision that could not take effect. Nothing about the settings themselves changed: the same one dropdown, the same write-only key, the same derivation for the handset. Only where a person finds them. The English map is regenerated with its own tool rather than hand edited, which is what it is for. * Document signed release verification (#520) * Fix what the split broke, and show a shop its own addresses (#525) * Fix what the split broke, and show a shop its own addresses Walking the new pages turned up four things, one of them able to lose data. THE SERIOUS ONE. PosnicPro.salesChannels.load() was bound to '#channels-tab-line', the tab id of the combined page the split deleted. Nothing called it any more, so Delivery Partners opened with no partners, Restaurant with no venues and no delivery charges, and the default branch and approval boxes empty. collect() reads those same rows back out of the DOM, so the next Save would have written the empty screen back and erased every partner, venue and charge the shop had. Two more handlers had died the same way: the one that fills the channel and category pickers on "What it sells", which is why that tab did nothing at all, and the one that draws the Swiggy/Zomato one-tap buttons, which is why Delivery Partners offered only "Add another". The item page offered channels from sales_channels_enabled - the checkbox list whose screen the split removed. With no markup writing it the value is usually absent, and absent meant "offer all eight", including the ones the shop had switched off. It reads the feature switches now, and a partner only appears while the feature owning its kind is on. Razorpay's gate was inverted, and had been: a SUCCESSFUL save of the gateway key ran prop('disabled', true) while a FAILED one ran prop('disabled', false). Storing a working key locked the option and a rejected key unlocked it. The greyed-out switch also said nothing about why, so it now says the key goes under Core Settings, Payment Gateway. And two things that were only ever findable by knowing the shape of a URL: a shop now sees both its addresses beside the store id, /order and /menu, with copy and open. They are two pages rather than two modes, so stopping orders leaves the menu standing. The Branding pictures stopped calling themselves the kiosk's. One shop has one look; the QR menu, the ordering page and the kiosk all draw the same four images, and both that tab and Payment now say so. tests/channel-panes-split.test.js gains a rule that would have caught the whole class: no handler may be bound to a tab id that is not in the markup. dead-selectors.js cannot see it - it matches $('#id') and these are $(document).on('click', '#id', fn) - and tab ids are always static markup, so the rule has no false positives. It found the second and third dead bindings after the first was fixed by hand. * Point the captain app's voice link at where voice ordering now lives #522 moved voice ordering from Integrations to the Features page, one commit after the Captain App page shipped a link to Integrations. The link still worked - Integrations is a real page - so it took somebody somewhere reasonable and wrong. The new check catches the cheaper half of this: a settings link to a page that does not exist at all. It cannot catch a link to a real page that no longer holds what it promises, and the comment says so rather than implying more than it does. * Regenerate the English map for the changed voice link label * Tell the recogniser what this shop sells, before it guesses (#526) The hard part of voice ordering was never "which dish did they mean". That is a lookup against a few hundred known strings, the handset already does it locally and for nothing, and it is good at it. The hard part is acoustic: getting the sounds of "Chicken Biryani" out of a loud dining room and an accent the model was not mostly trained on. Every serious provider takes domain hints for exactly that, and every one of them takes them FREE. A model told that "biryani", "uthappam" and "rasmalai" are words that exist in this room stops reaching for the ordinary English that sounds like them. It is the cheapest accuracy in the feature. Cheaper than changing provider, and much cheaper than widening the spelling tolerance - which buys accuracy by putting dishes nobody ordered on the bill. Whisper takes them as a prompt, Google as phrase hints. The Google boost is deliberately mild: enough to prefer a real dish over the English word that sounds like it, not enough to hear a dish in a sentence that had none. Read once in the service rather than inside each provider, so every provider gets the same list and none has to know where it came from. WHAT LEAVES THE BUILDING. Dish names, and nothing else. Not prices, not stock, not what sold yesterday. This list rides with every clip, so the rule has to be one somebody can hold in their head: if it is not a name printed on the shop's own menu, it does not go. The projection is the cheapest place to make that true and the hardest to undo by accident, and a test asserts it. AN IMPROVEMENT, NOT A DEPENDENCY. A shop whose menu could not be read still takes orders by voice, slightly less accurately, rather than not at all. Every failure path answers with an empty list. Capped here rather than at the provider. Whisper silently drops a prompt past its own token limit, so a shop with two thousand items would send a truncated list and never know; the character cap also guarantees the cut never lands mid-name, because half a dish name teaches the model a word the shop does not sell. Read once and held for five minutes. A menu changes a few times a season; a database round trip on every spoken order would be paid for by a waiter standing at a table. The branch id is validated before a connection is opened, because "that is not a branch" needs no database to answer and a warning logged for it reads like a fault. * Make "taking orders right now" say one thing and mean it (#528) Five buttons sat here at all times: three pause lengths, a Resume, and a separate red Stop underneath that did exactly what "Rest of today" did. Resume was offered to a shop that was already accepting, which is a button for undoing something that has not happened. Now the state is a badge - green accepting, red paused until a time - and only the actions that apply to it are shown. Pausing while running, resuming while stopped. The one that mattered: a pause is stored with the rest of the form, and the old Stop button announced "Orders stopped for today" in a SUCCESS toast having stored nothing. A kitchen under water reads that as done, walks away, and the orders keep arriving. Every button that changes the pause now leaves a marker beside it that stays until the form is saved, and the save clears it. Pausing is still a time and never a flag, which is the rule the whole control is built on: a switch with no end is one somebody flips in a Friday rush and finds still off on Tuesday. The hours help text now hides with the grid it explains, instead of explaining a grid nobody can see. Two tests pin the state machine and the unsaved marker. Writing them found two of my own mistakes - a regex that could not span the bracket in until.toISOString(), so it matched nothing and passed. * A Features card is a switch. Write that down and enforce it (#527) Voice ordering was put in the Captain App's card on the Features list. The card grew a dropdown, a language field, a Save button and a paragraph of help, ended up twice the height of its neighbours, and broke the grid it sits in. The owner caught it from a screenshot, because nothing failed. That list is a row of on/off switches. A shopkeeper scans it to see what is enabled, and that is the only question it answers. Somebody looking for "is Captain on" should not have to read past a form to find out. Voice ordering now has its own tab on the Captain App page, which already existed and is where it always belonged. Nothing about the settings changed - same dropdown, same write-only key, same derivation for the handset. Only where a person finds them. THE RULE IS WRITTEN DOWN, because this was the second wrong home in an afternoon and the first two were both arrived at by reasoning rather than by carelessness. AGENTS.md gains "Where a setting goes": the Features list holds ON and OFF, every setting belongs on the module's own page, a module with no page needs a page rather than an exception, and a setting is never filed by how it is BUILT. A voice provider is not an "integration" because it holds an API key, any more than SMTP is. AND ENFORCED, because a rule nobody can breach by accident is worth more than a rule everybody agrees with. The test slices each module card by nesting depth and fails naming the card and what got into it. The list's own chrome - the branch picker and the search box - is deliberately exempt: that is navigation for the list, not a setting of anything in it. Verified to fail by putting the mistake back. * A spending cap, a meter, a data fence, and the first AI feature (#523) * AI: a spending cap, a meter, a data fence, and the first feature Builds on the plumbing from #516 rather than beside it. That file was already the one place a model call goes through; this adds the three things it did not have and the first thing that uses it. WHY A CAP AND A METER ARE NOT OPTIONAL HERE. Posnic charges nothing for AI: the shop chooses a provider, saves its own key and pays that provider directly, which is what lets this work on the free and self-hosted app where we could never bill anybody. It also means the money a loop in our code would burn is the shopkeeper's, arriving on their card, with our name on the software that spent it. So the cap is checked BEFORE the call, because checked afterwards it is a report of the damage rather than a brake on it, and the meter records what each FEATURE cost per month, because "what did descriptions cost me" is the question a shop actually asks. Both live in ask(), so every feature built later inherits them without remembering to. The figures are ours, computed from the token counts the providers already reported and were dropping on the floor, priced at list. They will not match an invoice to the paisa and the settings screen should say the provider's own dashboard is the authority. A model missing from the price table costs the most expensive rate rather than nothing, so a provider renaming one cannot silently uncap a shop. THE FENCE. Item names are typed by staff and, through the online ordering page, by the public. The same product name field had a stored XSS fixed this month; it is now a prompt-injection surface, and "ignore the above" is a cheaper thing to try than a script tag. Shop content is wrapped and the model told the wrapper holds data, and a payload carrying the closing marker cannot end the fence early. That is not a guarantee on its own - no prompt is - which is why the rule already at the top of ai.service.js matters more: nothing writes to a shop's data, so the worst an injected name achieves is a bad suggestion somebody then declines. THE FIRST FEATURE is the description nobody was ever going to type, which the comparator evidence puts first: four to six hours a week at Shopify. It may describe only what the shop's own fields state, and may not call anything organic, natural, homemade or sugar-free unless that exact word is already there, because those are regulated claims and the shopkeeper is the one who answers for them. It writes in the shop's language, fills the form, and saves nothing. Supplier names, prices and phone numbers are not sent. Sixteen tests, each mutation-checked. README endpoint count and both English maps regenerated for the two new routes. * Regenerate the API docs and format to the repo style Two new endpoints mean docs/API.md and docs/openapi.json are stale, and the CI job exists precisely so an endpoint cannot land undocumented. 625 endpoints, 40 groups. Prettier over the files this branch touched. * Keep the database off the critical path, and the response shape intact Two real problems with how the cap and meter were wired, both caught by the ai.service tests that already existed. THE DATABASE WAS IN FRONT OF EVERY CALL. ask() made two extra round trips before talking to a provider: one to read the cap, one to read the month's spend. That is latency on a screen somebody is standing at, a new way for an AI feature to fail when Mongo is slow, and it hung the existing tests, which stub fetch and have no database at all. A test that sat for five seconds was telling me something true about production. The cap now comes from the preferences read settingsFor was already making, so there is no second trip. The month's spend is read only when a cap actually exists, so a shop that set none never touches the collection. And the meter is written without being awaited: a shopkeeper waiting for a description should not also wait for our bookkeeping, and a failed write under-counts a month by one call rather than losing an answer they have already paid for. THE RESPONSE SHAPE WAS NOT MINE TO WIDEN. I had added cost_minor to what ask() returns, which broke a test named "the happy path returns words and nothing else". That test is a deliberate contract: a caller gets words, not the key, not which provider answered, not what it cost. Widening it for a number the frontend never reads would have been the first crack in it. The cost lives where it belongs, in a row per feature per month that a settings screen can read. One expectation in the existing suite was widened rather than worked around: settingsFor now also reports the cap, and an unreadable settings row reports it as null, the same way it already reports no provider and no key. capFor is deleted. Nothing calls it now that the cap rides along with the settings, and a helper nobody calls is a helper that rots. * Click the online ordering screen, and fix what that found (#529) * Click the online ordering screen instead of reading it Every bug the owner found walking these pages passed node --check, passed eslint, and passed a suite that reads the source as text. A handler bound to a tab id that had been deleted. A gate that disabled the thing it should have enabled. A button that announced success having saved nothing. Reading source cannot see any of those. So this builds the markup, loads the real module out of the real file, lifts the real delegated handlers, clicks the real buttons and asserts on what a shopkeeper would see. Thirteen tests over the two controls that are new: the shop's two addresses, and taking orders right now. Proved rather than assumed: reverting the unsaved marker and rebuilding the address from a relative path - the two regressions this is most meant to stop - fail four of the thirteen. It replaces most of "somebody needs to walk the screen", not all of it. It cannot see a control that is invisible, mispositioned or unreadable, which is why the owner's walkthrough still found what it found. * Draw the shop's addresses when its store id arrives The load path sets the box with $("#kioskstore_id").val(store_id), and .val() fires no event - so the 'input change' handler that keeps the two addresses current while somebody types never runs for it. Clicking through the sidebar happens to work: the click handler fires after the settings have loaded. Opening #/settings/onlineordering directly does not. The pane shows before the data arrives, the box is empty at that moment, and nothing runs again afterwards - so a shop that saved its store id months ago would open its own page and see no addresses at all. Found by reading the load path before the owner tested it, not by the new tests, which drive the screen with the value already in place. * Shop hostnames come from data, and a CLA the paid edition needs (#530) * Ask for a CLA, because the DCO cannot carry a paid edition The DCO we already require certifies provenance "under the project licence (AGPL-3.0-only)". That is a certification bound to one licence, not a grant, so it gives us no right to distribute a contribution on any other terms. BUSINESS_MODEL.md has assumed since the open-source transition that we kept "the ability to dual-license"; only the DCO half of that was ever built. It matters now because a branded, signed, supported build sold to a business is the thing that pays for work on the open one, and eight people outside the company have already merged under the DCO alone. So: an Individual Contributor Licence Agreement adapted from the Apache ICLA v2.0, which adds the sublicense right and nothing else. Contributors keep their copyright, stay free to publish their work anywhere, and their contribution remains available to everyone under AGPL-3.0 whatever we do commercially. A licence, not an assignment, and the document says so in the second paragraph rather than burying it. Signatures live on a branch of this repository, so no third-party service holds the record. The text is a draft and is marked as one. It needs counsel before the bot is switched on, and the eight existing contributors need asking separately. Signed-off-by: Sridhar Bala <sridhar@posnic.com> * A shop's hostnames come from data, not from concatenation `${t.subdomain}.posnic.io` was true while there was one domain to serve shops on. It stops being true the moment a shop answers on a second: a reseller's customer is at cusxyz.xbill.in, and a reseller who buys their own domain moves every one of their customers onto shopname.theirdomain.com in an afternoon. The danger in changing this is not that new names fail, which anybody would notice on the first request, but that old ones quietly stop working or that a shop becomes reachable at a name nobody meant. Both are silent, and the second shows one business another's data. So SHOP_BASE_DOMAINS defaults to posnic.io and the whole estate keeps exactly the names it has today. This is inert until somebody sets the variable, which is the only way a change this load-bearing is safe to deploy ahead of the DNS and certificates that will use it. hostsFor() takes three sources in order of authority: an explicit `hosts` array on the tenant, `<subdomain>.<base>` for each configured base domain, and the customer's own webDomain. It lowercases, strips a port and deduplicates, because resolve() does the same before it looks a host up and anything stored differently is simply never found. A tenant with no name at all gets an empty list and is not served, rather than served under a derived guess. It lives in its own module rather than inside shard.js so it can be tested without booting the application, and so the provisioner can eventually read the same rule instead of growing its own copy in each of its eight generators. public-url.js reads the same variable. A shop served at cusxyz.xbill.in would otherwise be told its own address is untrusted and send no link at all, which is the dead-link failure that file exists to prevent. The suffix match still refuses xbill.in.evil.example. 493 api util tests pass, the 13 existing shard tests still pass, and the 11 public-url tests still pass. Signed-off-by: Sridhar Bala <sridhar@posnic.com> * Format the new test to Prettier's taste CI's lint step flagged it. Only the new file: api/shard.js fails the same check at origin/main and is left alone, because running --write over a file Prettier has never formatted rewrites the whole thing and buries a small change in a thousand-line diff. Si…
1 parent e4e6d70 commit 50fa738

131 files changed

Lines changed: 17483 additions & 3029 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/CLA.md‎

Lines changed: 130 additions & 0 deletions

‎.github/CONTRIBUTING.md‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,25 @@ Sign each commit with `git commit -s`, which adds:
171171
Signed-off-by: Your Name <you@example.com>
172172
```
173173

174-
By signing off you certify you have the right to submit the code under the
175-
project licence (AGPL-3.0-only).
174+
By signing off you certify that the work is yours to submit and that you have
175+
the right to contribute it on the terms below.
176+
177+
## Sign the CLA, once
178+
179+
Alongside the DCO we ask for a one-line signature on the
180+
[Contributor Licence Agreement](CLA.md). A bot asks for it on your first pull
181+
request and remembers it afterwards.
182+
183+
**You keep the copyright in your work.** It is a licence, not an assignment, and
184+
you stay free to use and publish your own contribution anywhere else. What it
185+
adds beyond the DCO is the right to *sublicense*: Posnic publishes this project
186+
under AGPL-3.0 and intends to keep doing so, and the same right lets us ship a
187+
branded, signed and supported build commercially. That paid edition is what pays
188+
for the work on the open one. Without the sublicense right we could not offer it
189+
at all.
190+
191+
Your contribution stays available to everyone under AGPL-3.0 regardless. Nothing
192+
in the agreement lets us take that away.
176193

177194
## What makes a great bug report
178195

‎.github/workflows/cla.yml‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Contributor Licence Agreement check.
2+
#
3+
# Why this exists: the DCO we already require certifies provenance under
4+
# AGPL-3.0-only. It grants no right to distribute a contribution under any other
5+
# terms, so it cannot support a paid edition. The CLA adds the sublicense right
6+
# and nothing else; contributors keep their copyright.
7+
#
8+
# Signatures are stored in this repository on a branch of their own, so there is
9+
# no third-party service holding the record and no external account to lose.
10+
name: CLA
11+
12+
on:
13+
issue_comment:
14+
types: [created]
15+
pull_request_target:
16+
types: [opened, synchronize, reopened]
17+
18+
permissions:
19+
actions: write
20+
contents: write
21+
pull-requests: write
22+
statuses: write
23+
24+
jobs:
25+
cla:
26+
runs-on: ubuntu-latest
27+
# Only act on pull requests, and on comments that belong to one.
28+
if: >
29+
github.event.issue.pull_request ||
30+
github.event_name == 'pull_request_target'
31+
steps:
32+
- name: Check or record the signature
33+
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
34+
env:
35+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
36+
with:
37+
path-to-signatures: signatures/v1/cla.json
38+
path-to-document: https://github.com/Posnic/POS/blob/develop/.github/CLA.md
39+
branch: cla-signatures
40+
allowlist: dependabot[bot],github-actions[bot],sridharkalaibala,sridharposnic
41+
custom-notsigned-prcomment: >
42+
Thank you for this. Before it can be merged we need a one-line
43+
signature on our
44+
[Contributor Licence Agreement](https://github.com/Posnic/POS/blob/develop/.github/CLA.md).
45+
You keep the copyright in your work; the agreement lets Posnic ship
46+
your contribution in the paid edition alongside the AGPL release.
47+
To sign, reply to this pull request with exactly:
48+
custom-pr-sign-comment: 'I have read the CLA document and I hereby sign the CLA'
49+
custom-allsigned-prcomment: 'All contributors have signed the CLA. Thank you.'
50+
lock-pullrequest-aftermerge: false

‎.github/workflows/deploy-api.yml‎

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,18 @@ name: Deploy api (Lightsail)
66
on:
77
push:
88
branches: [main]
9-
paths: ['api/**']
9+
paths:
10+
- "api/**"
11+
# The API serves /menu and /order from these, so a change to either is
12+
# a change to what customers see. Without them a menu-only commit
13+
# passes CI, merges, and the workflow never even starts.
14+
- "menu/**"
15+
- "order/**"
16+
# And the workflow itself, the way deploy-frontend.yml already does.
17+
# A change to HOW the API deploys otherwise sits unused until some
18+
# unrelated api/ commit happens to carry it out - so the one commit
19+
# that most needs proving is the one that never runs.
20+
- ".github/workflows/deploy-api.yml"
1021
workflow_dispatch:
1122

1223
# Least privilege for GITHUB_TOKEN.
@@ -40,6 +51,26 @@ jobs:
4051
--exclude .wwebjs_auth --exclude .wwebjs_cache \
4152
-e "ssh -i ~/.ssh/deploy.pem" \
4253
api/ "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:~/apps/tenants/app/api/"
54+
55+
# The customer-facing bundles, which the API serves and this workflow
56+
# never shipped.
57+
#
58+
# api/app.js mounts them from `path.join(__dirname, '..', 'menu')` and
59+
# '../order', guarded by fs.existsSync - so a missing directory is not an
60+
# error, it is SILENCE. Every shop's /menu and /order answered 404 from
61+
# the day the feature shipped, and the 404 reads as a routing bug rather
62+
# than a deployment one, which is why it survived so long.
63+
#
64+
# tests/bundle-deployment.test.js pins this to what app.js actually
65+
# serves, in both directions.
66+
- name: Sync the customer bundles
67+
run: |
68+
rsync -az --delete \
69+
-e "ssh -i ~/.ssh/deploy.pem" \
70+
menu/ "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:~/apps/tenants/app/menu/"
71+
rsync -az --delete \
72+
-e "ssh -i ~/.ssh/deploy.pem" \
73+
order/ "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:~/apps/tenants/app/order/"
4374
# The processes read api/.env at boot (dotenv), and rsync above
4475
# deliberately never touches it. The S3 credentials were put in GitHub
4576
# secrets, which the server itself never reads - this is the step that
@@ -74,8 +105,45 @@ jobs:
74105
# BREVO_API_KEY secret is set, and harmless while empty.
75106
[ -n "$BREVO_API_KEY" ] && echo "BREVO_API_KEY=$BREVO_API_KEY"
76107
[ -n "$EMAIL_FROM" ] && echo "EMAIL_FROM=$EMAIL_FROM"
108+
# MERGED INTO THE EXISTING .env, NEVER WRITTEN OVER IT.
109+
#
110+
# This was `cat > ~/apps/tenants/app/api/.env`, which replaces the
111+
# whole file with the handful of keys above. The instance keeps
112+
# MONGODB_URI, JWT_SECRET, SESSION_SECRET, ENCRYPTION_KEY and around
113+
# a hundred more in that same file, and not one of them is in this
114+
# repo to put back afterwards.
115+
#
116+
# It has never fired, but only because the S3 secrets are unset and
117+
# the guard above exits first - so the step reports a reassuring
118+
# warning about uploads and nobody reads what the enabled path does.
119+
# The day somebody sets AWS_S3_BUCKET to turn uploads on, that
120+
# deploy would truncate the estate's configuration to five keys and
121+
# every shop would come back with no database URI. A step that is
122+
# harmless only while it is switched off is a fuse, not a safeguard.
123+
#
124+
# So: drop the keys we are setting, keep every other line, append
125+
# ours, move the result into place in one step. One .bak is left for
126+
# the deploy that gets it wrong anyway.
77127
} | ssh -i ~/.ssh/deploy.pem "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
78-
'umask 077; cat > ~/apps/tenants/app/api/.env && echo "storage env written ($(grep -c = ~/apps/tenants/app/api/.env) keys)"'
128+
'set -eu
129+
umask 077
130+
env=$HOME/apps/tenants/app/api/.env
131+
dir=$(dirname "$env")
132+
incoming=$(mktemp "$dir/.env.incoming.XXXXXX")
133+
merged=$(mktemp "$dir/.env.merged.XXXXXX")
134+
cat > "$incoming"
135+
if [ -f "$env" ]; then
136+
cp -p "$env" "$env.bak"
137+
cp "$env" "$merged"
138+
for k in $(sed -n "s/^\([A-Za-z_][A-Za-z0-9_]*\)=.*/\1/p" "$incoming"); do
139+
sed -i "/^$k=/d" "$merged"
140+
done
141+
fi
142+
cat "$incoming" >> "$merged"
143+
mv "$merged" "$env"
144+
chmod 600 "$env"
145+
rm -f "$incoming"
146+
echo "storage env merged: $(grep -c = "$env") keys in .env"'
79147
80148
# Ring-ordered reload with a smoke gate between rings (U3.2).
81149
#

‎.github/workflows/deploy-develop.yml‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ jobs:
4141

4242
- uses: actions/setup-node@v7
4343
with:
44-
node-version: '22'
44+
node-version: "22"
4545

4646
# Built here rather than on the box: a 2 GB machine running mongod and
4747
# the API should not also be running a gulp build, and building in CI
@@ -81,12 +81,55 @@ jobs:
8181
--exclude node_modules --exclude .env --exclude uploads \
8282
-e "ssh -i ~/.ssh/develop.pem" \
8383
api/ "${{ secrets.DEVELOP_USER }}@${{ secrets.DEVELOP_HOST }}:~/posnic-develop/api/"
84+
# HASHED BUNDLES ARE PROTECTED FROM --delete.
85+
#
86+
# A tab that is already open still asks for script/dashboard.<old>.js
87+
# by name. Plain --delete removes it the moment the next build lands,
88+
# the page 404s on its own script, and the person looking at it says
89+
# the site is down - which is exactly what happened here after four
90+
# deploys inside thirteen minutes.
91+
#
92+
# deploy-frontend.yml learned this on a live estate ("mobile view add
93+
# category shown and page broken. crashed" was an open tab meeting a
94+
# deploy that had pruned its stylesheet). develop never did, and
95+
# develop is where people sit with a tab open all day while builds
96+
# land on top of them. Same protection, same retention below.
8497
rsync -az --delete \
98+
--filter='protect public/script/*.js' \
99+
--filter='protect public/style/*.css' \
85100
-e "ssh -i ~/.ssh/develop.pem" \
86101
frontend/public/ "${{ secrets.DEVELOP_USER }}@${{ secrets.DEVELOP_HOST }}:~/posnic-develop/frontend/public/"
87102
rsync -az \
88103
-e "ssh -i ~/.ssh/develop.pem" \
89104
languages/ "${{ secrets.DEVELOP_USER }}@${{ secrets.DEVELOP_HOST }}:~/posnic-develop/languages/"
105+
# Retention: for every hashed bundle family (name.<hash8>.ext), keep
106+
# the newest 3 and remove the rest. An open tab survives at least two
107+
# more deploys; the instance never grows without bound. Same shape as
108+
# deploy-frontend.yml, which is where this was worked out.
109+
ssh -i ~/.ssh/develop.pem "${{ secrets.DEVELOP_USER }}@${{ secrets.DEVELOP_HOST }}" 'bash -s' <<'REMOTE'
110+
set -u
111+
for d in ~/posnic-develop/frontend/public/script ~/posnic-develop/frontend/public/style; do
112+
[ -d "$d" ] || continue
113+
cd "$d"
114+
ls -1 2>/dev/null | grep -E '^[A-Za-z0-9_-]+\.[0-9a-f]{8}\.(js|css)$' \
115+
| sed -E 's/\.[0-9a-f]{8}\.(js|css)$/.\1/' | sort -u \
116+
| while read -r fam; do
117+
base="${fam%.*}"; ext="${fam##*.}"
118+
ls -1t "$base".????????."$ext" 2>/dev/null | tail -n +4 \
119+
| while read -r old; do rm -f -- "$old" && echo "pruned $d/$old"; done
120+
done
121+
done
122+
REMOTE
123+
# The customer-facing bundles. api/app.js serves these from '../menu'
124+
# and '../order' behind an fs.existsSync guard, so leaving them out
125+
# was not an error - it was a silent 404 on every /menu and /order
126+
# URL, which is exactly the surface anybody comes to develop to test.
127+
rsync -az --delete \
128+
-e "ssh -i ~/.ssh/develop.pem" \
129+
menu/ "${{ secrets.DEVELOP_USER }}@${{ secrets.DEVELOP_HOST }}:~/posnic-develop/menu/"
130+
rsync -az --delete \
131+
-e "ssh -i ~/.ssh/develop.pem" \
132+
order/ "${{ secrets.DEVELOP_USER }}@${{ secrets.DEVELOP_HOST }}:~/posnic-develop/order/"
90133
91134
- name: Install and restart
92135
run: |

‎.gitignore‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,13 @@ src/mongodb/
107107
# file on its way to a person and back; the translations belong in
108108
# frontend/languages/<code>.json once merged, not in a half-filled sheet.
109109
*-to-translate.json
110+
111+
# Database snapshots pulled down for comparison or recovery work. These are
112+
# raw copies of a production database - every shop, every customer - and the
113+
# only thing standing between one and a public repository is that nobody ran
114+
# "git add -A" while a 1.1GB Atlas snapshot happened to be sitting here. Name
115+
# them by their cluster and timestamp, as the Atlas download already does.
116+
Main-[0-9]*T[0-9]*Z/
117+
Main-[0-9]*T[0-9]*Z.tgz
118+
*.mongodump/
119+
dump/

‎.prettierignore‎

Lines changed: 6 additions & 0 deletions

0 commit comments

Comments
 (0)