@aglyn/plugins-commerce 1.0.0-beta.201 → 1.0.0-beta.203

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/console/gift-cards-card.component.js +34 -2
  3. package/src/lib/components/console/gift-cards-card.component.js.map +1 -1
  4. package/src/lib/model/commerce-gift-cards.d.ts +30 -0
  5. package/src/lib/model/commerce-gift-cards.js +39 -1
  6. package/src/lib/model/commerce-gift-cards.js.map +1 -1
  7. package/src/lib/server/billing-webhook.js +180 -106
  8. package/src/lib/server/billing-webhook.js.map +1 -1
  9. package/src/lib/server/cart-checkout.js +46 -8
  10. package/src/lib/server/cart-checkout.js.map +1 -1
  11. package/src/lib/server/checkout.js +23 -5
  12. package/src/lib/server/checkout.js.map +1 -1
  13. package/src/lib/server/download.js +12 -1
  14. package/src/lib/server/download.js.map +1 -1
  15. package/src/lib/server/draft-order.js +5 -2
  16. package/src/lib/server/draft-order.js.map +1 -1
  17. package/src/lib/server/feed.js +12 -2
  18. package/src/lib/server/feed.js.map +1 -1
  19. package/src/lib/server/gift-card-risk.d.ts +112 -0
  20. package/src/lib/server/gift-card-risk.js +220 -0
  21. package/src/lib/server/gift-card-risk.js.map +1 -0
  22. package/src/lib/server/gift-cards.js +37 -2
  23. package/src/lib/server/gift-cards.js.map +1 -1
  24. package/src/lib/server/paid-media-delivery.d.ts +26 -4
  25. package/src/lib/server/paid-media-delivery.js +36 -9
  26. package/src/lib/server/paid-media-delivery.js.map +1 -1
  27. package/src/lib/server/recurring-charges.d.ts +44 -0
  28. package/src/lib/server/recurring-charges.js +65 -0
  29. package/src/lib/server/recurring-charges.js.map +1 -0
  30. package/src/lib/server/refund.js +24 -0
  31. package/src/lib/server/refund.js.map +1 -1
  32. package/src/lib/server/reserve.js +14 -5
  33. package/src/lib/server/reserve.js.map +1 -1
  34. package/src/lib/server/stream.js +12 -1
  35. package/src/lib/server/stream.js.map +1 -1
  36. package/src/lib/server/subscription-portal.js +13 -5
  37. package/src/lib/server/subscription-portal.js.map +1 -1
  38. package/src/lib/server/supplier-outbox.js +22 -7
  39. package/src/lib/server/supplier-outbox.js.map +1 -1
  40. package/src/lib/server.js +15 -3
  41. package/src/lib/server.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/reserve.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { AttemptClaim, PluginApiHandler } from '@aglyn/aglyn/server'\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport { claimAttempt } from '@aglyn/aglyn/server'\nimport { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport { connectLinkageIsReady } from '@aglyn/tenant-data-admin/server/stripe-account-mode'\nimport { checkoutSessionCardAuthenticationParams } from '@aglyn/tenant-data-admin/server/stripe-card-authentication'\n\n/**\n * Reserve a stay (AGL-310): server-side quote + availability check,\n * pending reservation doc, then a Stripe session for the deposit (or\n * full amount) on the merchant's account. Webhook completion confirms\n * the reservation; abandoned pending holds expire after 30 minutes and\n * stop blocking the calendar.\n */\nexport const reserveHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n if (!process.env.STRIPE_SECRET_KEY) {\n return res.status(501).json({ error: 'Payments are not configured.' })\n }\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const resourceId = String(body.resourceId ?? '')\n const checkInDayMs = CommerceModel.toDayMs(Number(body.checkInDayMs ?? 0))\n const checkOutDayMs = CommerceModel.toDayMs(Number(body.checkOutDayMs ?? 0))\n const guestName = String(body.guestName ?? '').trim().slice(0, 120)\n const guestEmail = String(body.guestEmail ?? '').trim().toLowerCase().slice(0, 120)\n // One reservation attempt, minted by the widget (AGL-1697). Node lowercases\n // incoming headers, but read both spellings — the plugin API request type\n // makes no promise about casing.\n const idempotencyKey = String(\n req.headers['idempotency-key'] ?? req.headers['Idempotency-Key'] ?? '',\n ).trim().slice(0, 200)\n if (!hostId || !resourceId || !checkInDayMs || !checkOutDayMs) {\n return res.status(400).json({ error: 'Missing reservation details' })\n }\n\n let claim: AttemptClaim | null = null\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n // Owner + payment readiness, HOISTED above the claim (AGL-1697): both are\n // deterministic refusals a merchant fixes out of band, and neither should\n // burn the guest's attempt key.\n const ownerOrg = await getOrgForHost(hostId)\n // Plan gate, re-asked per request exactly as the storefront checkout\n // asks it (AGL-1873, the AGL-481 pattern): a lapsed org's storefront\n // must not keep taking reservation deposits — at the free plan's 0%\n // transaction fee — through the one shopper-facing commerce door that\n // skipped the entitlement. Deterministic and merchant-side, so it sits\n // here above the claim with the readiness check.\n if (!Aglyn.checkEntitlement(ownerOrg?.org as any, 'commerce')) {\n return res.status(403).json({ error: 'Reservations are not enabled' })\n }\n const ownerId = ownerOrg?.org?.ownerUid\n const ownerProfile = ownerId\n ? await firestore.collection('profiles').doc(String(ownerId)).get()\n : null\n const accountId = ownerProfile?.get('stripeAccountId')\n if (\n !connectLinkageIsReady(\n {\n accountId,\n chargesEnabled: ownerProfile?.get('stripeChargesEnabled'),\n accountLivemode: ownerProfile?.get('stripeAccountLivemode'),\n },\n { subject: `reserve host ${hostId}` },\n )\n ) {\n return res.status(409).json({ error: 'Payments are not set up yet' })\n }\n\n // The claim sits ABOVE the availability read, and that placement is the\n // fix for this handler's particular lie (AGL-1697): a retried attempt used\n // to collide with its OWN 30-minute pending hold and be told \"Those dates\n // just sold out\" — about dates the guest was holding — where the correct\n // answer was the original session URL. Replaying here means the retry\n // never reaches the availability check at all. Every refusal below the\n // claim releases it, because each is deterministic and retryable.\n const claimed = await claimAttempt(firestore, {\n kind: 'commerce-reserve',\n scopeId: hostId,\n orgId: String(ownerOrg?.org?.id ?? ''),\n key: idempotencyKey,\n busyMessage: 'This reservation is already being processed',\n })\n if ('replay' in claimed) {\n return res.status(claimed.replay.status).json(claimed.replay.body)\n }\n claim = claimed.claim\n\n // ONLY STAYS THAT COULD STILL OVERLAP, NEAREST FIRST (AGL-2159).\n //\n // This was `.where('resourceId','==',resourceId).limit(500)` with no\n // ordering, so Firestore returned 500 documents in `__name__` order — an\n // arbitrary slice of the resource's ENTIRE booking history. Every stay the\n // resource has ever had competed for those 500 slots on equal terms with\n // the ones that matter, so a cottage that had taken 500 bookings began\n // silently dropping LIVE reservations out of its own availability check\n // and confirming a second guest into an occupied room. Nothing about that\n // failure is visible: `isRangeAvailable` is handed a short list and\n // answers, correctly, that the range is free.\n //\n // `checkOutDayMs > checkInDayMs` is the one inequality that removes the\n // whole of the past — a stay that ended before the requested arrival\n // cannot overlap it — and ordering by the same field puts the stays\n // nearest the requested window at the front of the limit rather than\n // whichever ones happen to sort early by id. Needs the composite index\n // `reservations (resourceId ASC, checkOutDayMs ASC)`, added to\n // `cloud/firebase-firestore.indexes.json` and pinned by\n // `reserve-availability-indexes.spec.ts` — an equality plus an `orderBy`\n // on a second field is exactly the shape that reads as free and is not\n // (AGL-1793), and here a missing index throws rather than under-reporting.\n //\n // RESIDUAL, stated: the cap can still bite when a single resource holds\n // more than 500 stays that all end between the requested arrival and the\n // end of a genuinely overlapping one. That is a far narrower window than\n // \"500 bookings in this resource's lifetime\", but it is not zero, and the\n // sweep that would keep expired `pending` holds from filling those slots\n // is a scheduled job this change does not add (see the commit).\n const availabilityQuery = hostRef\n .collection('reservations')\n .where('resourceId', '==', resourceId)\n .where('checkOutDayMs', '>', checkInDayMs)\n .orderBy('checkOutDayMs')\n .limit(500)\n const [resourceSnapshot, reservationsSnapshot, storeSnapshot] =\n await Promise.all([\n hostRef.collection('resources').doc(resourceId).get(),\n availabilityQuery.get(),\n // The merchant's own lodging rate (AGL-1969). Read in the SAME round\n // trip as the availability query rather than as a fourth sequential\n // await — this path already runs three reads before it can answer, and\n // a tax lookup is not a reason to make a guest wait for a fourth.\n hostRef.collection('settings').doc('store').get(),\n ])\n const resource = resourceSnapshot.data() as CommerceModel.HostResource | undefined\n if (!resource) {\n await claim.release()\n return res.status(404).json({ error: 'Unknown resource' })\n }\n\n // Pending holds block for 30 minutes only, so abandoned checkouts\n // release the dates. The lapse itself lives in\n // `reservationHoldsDates`, which `isRangeAvailable` applies — this reads\n // the fields that rule needs and leaves the rule to the model, so the\n // storefront's date-picker cannot answer a different question from the\n // door that takes the money.\n //\n // Extracted so the pre-read below and the re-read inside the booking\n // transaction cannot drift apart (AGL-2450). Two copies of a filter that\n // decides whether a room is free is exactly the shape where one of them\n // quietly stops matching the other.\n const readLive = (snapshot: {\n docs: any[]\n }): CommerceModel.HostReservation[] =>\n snapshot.docs.map((docSnapshot) => ({\n checkInDayMs: Number(docSnapshot.get('checkInDayMs')),\n checkOutDayMs: Number(docSnapshot.get('checkOutDayMs')),\n status: String(\n docSnapshot.get('status'),\n ) as CommerceModel.ReservationStatus,\n createdAtMs: Number(docSnapshot.get('createdAtMs') ?? 0),\n })) as CommerceModel.HostReservation[]\n const now = Date.now()\n const live = readLive(reservationsSnapshot)\n if (\n !CommerceModel.isRangeAvailable(\n resource,\n live,\n checkInDayMs,\n checkOutDayMs,\n now,\n )\n ) {\n // A GENUINE sold-out (a keyed retry replayed before reaching here). The\n // guest picks other dates or the blocking stay is cancelled; either way\n // the key must survive the refusal.\n await claim.release()\n return res.status(409).json({ error: 'Those dates just sold out' })\n }\n const quote = CommerceModel.computeReservationQuote(\n resource,\n checkInDayMs,\n checkOutDayMs,\n )\n if (quote.problem) {\n await claim.release()\n return res.status(400).json({ error: quote.problem })\n }\n\n const chargeCents = quote.depositCents || quote.totalCents\n\n // NO DISCOUNT IS APPLIED HERE, AND THAT IS AN OPEN QUESTION RATHER THAN AN\n // OVERSIGHT (AGL-305).\n //\n // `hosts/{hostId}/discounts` reaches the cart, buy-now, the draft-order\n // payment link and the register. This door was deliberately left out,\n // because unlike those four it does not take a payment for goods — it takes\n // a DEPOSIT against a stay that is settled later, and \"apply 10% off\" has\n // three different meanings against that shape:\n //\n // 1. Discount the STAY. `quote.totalCents` falls, the deposit falls with\n // it because it is a percentage of the subtotal, and the balance falls\n // too. This is the only reading that is actually a discount on the\n // booking — and it reprices a merchant's lodging inventory from a\n // storefront promo code, which is a larger claim than any of the four\n // goods doors makes.\n // 2. Discount the DEPOSIT only. Strictly worse than doing nothing: the\n // balance is computed downstream as `totalCents - paidCents`, so every\n // cent taken off at booking is billed straight back at check-out. The\n // guest would be shown a discount and then charged for it, which is\n // the silent-reversal shape this codebase refuses elsewhere.\n // 3. Discount the BALANCE. Nothing visible at booking; the guest finds\n // out at the register. A promotion nobody can see when they buy is not\n // a promotion.\n //\n // What makes this a product decision and not an implementation one is that\n // the balance is not collected by any door this repo can teach. It is read\n // off the console's reservations card as `totalCents - paidCents` and\n // collected BY A HUMAN at the till, so option 1 is the only one that\n // survives the hand-off — and it survives it by changing the stay's stored\n // price, which is the merchant's revenue basis and what their analytics,\n // their folio and their register all read.\n //\n // The tax makes it sharper still. `lodgingTax` below is an occupancy rate,\n // not a sales rate, and its basis here is already a STATED LIMITATION — it\n // is charged on what is collected (the deposit) rather than on the stay.\n // Layering a discount on top asks whether occupancy tax is owed on the\n // discounted room rate or the gross one, which differs by jurisdiction, is\n // the merchant's own regime to answer, and is not a question this handler\n // can decide from a promo code.\n //\n // So: refusing to guess. A shopper who types a code here is not told it\n // worked — this path never reads one, so there is nothing to fall through\n // silently, which is the failure mode that mattered. When the owner picks\n // a reading, option 1 is the one the machinery already supports: reduce\n // `quote.subtotalCents` before the deposit is derived from it, and the\n // deposit, the balance, the fee and the tax all follow without further\n // change.\n\n // LODGING TAX: the MERCHANT'S OWN RATE, off unless they set one\n // (AGL-1969, answering the decision AGL-1953 recorded here).\n //\n // What this path used to do, and why it was only ever a holding position:\n // it computed no tax in either store mode, on two stated grounds. The\n // first is still true and is the reason for the shape below — a stay is\n // not goods. The AGL-285 zone editor configures a SALES rate, lodging is\n // an occupancy/hotel regime with its own rates, its own registration and\n // its own return, and reading `rates[]` for a night would be confidently\n // wrong rather than merely absent. Stripe Tax has the mirror problem: it\n // needs a lodging tax code this handler does not send, so\n // `automatic_tax` here would compute a goods rate on a room. Neither of\n // those changes, and neither is used.\n //\n // What DID change is that the merchant now has a field of their own to\n // answer in (`tax.lodging`), so \"Aglyn does not know the right number\"\n // stops being a reason to collect nothing. They type the rate, we compute\n // it, charge it, record it and stamp the regime; the settings card states\n // plainly that determining what is owed and to whom is theirs. Aglyn\n // takes no tax position here — this is the manual LODGING rate, which\n // never touches Aglyn's registrations.\n //\n // ⚠️ This comment used to say \"Terms §10.3 ... untouched by this\". §10.3\n // CHANGED on 2026-08-24 (AGL-1956): Aglyn accepts marketplace-facilitator\n // status and new §10.7 makes storefront sales tax Aglyn's where it has a\n // collection obligation. Lodging tax is still the merchant's, so this\n // handler is unaffected — but §10.3 is no longer the authority for that.\n //\n // DEFAULT OFF, and that is load-bearing: `resolveFlatTaxCents` answers\n // zero for an absent, zero, negative or out-of-range rate, so no existing\n // reservation's charge moves because this shipped.\n //\n // THE BASIS IS WHAT IS CHARGED, AND THAT IS A STATED LIMITATION RATHER\n // THAN AN ANSWER. `chargeCents` is `depositCents || totalCents`, so on a\n // deposit-taking resource the tax collected here is the rate on the\n // DEPOSIT, not on the stay. Whether a given jurisdiction wants occupancy\n // tax on the full stay at booking, on the deposit only, or on redemption\n // at check-out is a tax question, it differs by jurisdiction, and this\n // code deliberately does NOT decide it — the amount the platform actually\n // moves is the only basis available to it, the settings card says so in\n // the merchant's own view, and the residual is on AGL-1969.\n const taxSettings = ((storeSnapshot.data() as any)?.tax ??\n {}) as CommerceModel.TaxSettings\n const lodgingTax = CommerceModel.resolveFlatTaxCents(\n taxSettings.lodging,\n chargeCents,\n 'Lodging tax',\n )\n\n // Platform take PLUS Stripe's card cost at cost (AGL-2152). This is a\n // destination charge, so Stripe debits its percentage plus 30¢ from the\n // PLATFORM's balance; with the old take-only figure every tier carrying a\n // 0% service rate paid Aglyn nothing and cost it that fee on each\n // reservation.\n //\n // THE TWO BASES ARE NOW DIFFERENT NUMBERS (AGL-1969), and the split is\n // AGL-2317's rule: the platform take is computed on the ITEMS — the stay\n // — while Stripe's cost is passed through on the WHOLE charge, because\n // that is what Stripe actually debits. Taking a percentage of the lodging\n // tax would be taking a cut of the state's money, which no storefront\n // path does. The previous single-argument form is what made this a real\n // risk: it read as \"the fee base is the charge\" and would have silently\n // started taxing the tax the moment a tax line appeared.\n const feeCents = Aglyn.resolveTransactionFeeCents(\n ownerOrg?.org as any,\n 'service',\n chargeCents,\n chargeCents + lodgingTax.taxCents,\n )\n\n // THE HOLD IS TAKEN IN A TRANSACTION (AGL-2450).\n //\n // The check above is a fast refusal on a stale read, and on its own that is\n // all it ever was: the availability query ran, the quote and the fee were\n // computed, and only then did the hold land — so two guests requesting the\n // same dates both read a reservation set that lacked the other's booking,\n // both passed, and both got a `pending` row and a real payment. Nothing\n // downstream caught it. The webhook's transaction guards double-CONFIRMATION\n // of one booking against Stripe redelivery; it never re-asks availability,\n // so both holds confirmed cleanly into two guests in one room.\n //\n // `claimAttempt` does not help either, and it is worth being explicit about\n // why: it is keyed on the guest's own `Idempotency-Key`, so it dedupes\n // retries of ONE attempt. Two different guests carry two different keys and\n // never contend.\n //\n // Re-asking inside the transaction that writes is the whole guard — the\n // same shape as `decrementVariantStock` (AGL-2320), and easier to justify\n // here: the conflicting write lands BEFORE Stripe is contacted, so this\n // door can legitimately refuse rather than take the money and withhold the\n // room.\n const reservationRef = hostRef.collection('reservations').doc()\n const booked = await firestore.runTransaction(async (transaction: any) => {\n const fresh = await transaction.get(availabilityQuery)\n // Re-timed, not reusing `now`: a transaction that retries under\n // contention may run appreciably later, and the 30-minute pending window\n // has to be measured from when this attempt actually commits.\n if (\n !CommerceModel.isRangeAvailable(\n resource,\n readLive(fresh),\n checkInDayMs,\n checkOutDayMs,\n Date.now(),\n )\n ) {\n return false\n }\n // `create()`, not `set()`: the ref is freshly minted, so an id collision\n // is a bug rather than an overwrite to absorb silently.\n transaction.create(reservationRef, {\n resourceId,\n status: 'pending',\n checkInDayMs,\n checkOutDayMs,\n guestName: guestName || null,\n guestEmail: guestEmail || null,\n nights: quote.nights,\n totalCents: quote.totalCents,\n depositCents: quote.depositCents,\n paidCents: 0,\n createdAtMs: now,\n } satisfies CommerceModel.HostReservation)\n return true\n })\n if (!booked) {\n // Lost the race for these dates. Same refusal as the pre-read's, and the\n // key survives it for the same reason: the guest picks other dates or the\n // blocking stay is cancelled.\n await claim.release()\n return res.status(409).json({ error: 'Those dates just sold out' })\n }\n\n const referer = String(req.headers.referer ?? '')\n const origin = `https://${req.headers.host}`\n const backUrl = referer.startsWith('http') ? referer : origin\n const separator = backUrl.includes('?') ? '&' : '?'\n const params = new URLSearchParams({\n mode: 'payment',\n // 3-D Secure, from the one seam every card payment shares (AGL-3360).\n ...checkoutSessionCardAuthenticationParams('payment'),\n 'line_items[0][quantity]': '1',\n 'line_items[0][price_data][currency]': 'usd',\n 'line_items[0][price_data][unit_amount]': String(chargeCents),\n 'line_items[0][price_data][product_data][name]': `${resource.name} — ${\n quote.nights\n } night${quote.nights === 1 ? '' : 's'}${\n quote.depositCents && quote.depositCents < quote.totalCents\n ? ' (deposit)'\n : ''\n }`.slice(0, 120),\n // The merchant's lodging tax as an ORDINARY product line Stripe is\n // never told is tax — the AGL-1711 construction every other manual rate\n // rides. That is what makes the derived `taxMode` read `manual`: the\n // figure is the merchant's own, computed from their own rate, and never\n // against Aglyn's registrations the way `automatic_tax` would be\n // (AGL-1904). Absent entirely when no rate is set, so a default store's\n // session carries no tax parameter of any kind.\n ...(lodgingTax.taxCents > 0\n ? {\n 'line_items[1][quantity]': '1',\n 'line_items[1][price_data][currency]': 'usd',\n 'line_items[1][price_data][unit_amount]': String(\n lodgingTax.taxCents,\n ),\n 'line_items[1][price_data][product_data][name]': lodgingTax.label,\n }\n : {}),\n ...(feeCents > 0\n ? { 'payment_intent_data[application_fee_amount]': String(feeCents) }\n : {}),\n 'payment_intent_data[transfer_data][destination]': String(accountId),\n ...(guestEmail ? { customer_email: guestEmail } : {}),\n success_url: `${backUrl}${separator}reserved=1`,\n cancel_url: `${backUrl}${separator}reserved=0`,\n 'metadata[type]': 'commerce-reservation',\n 'metadata[hostId]': hostId,\n 'metadata[reservationId]': reservationRef.id,\n 'metadata[feeCents]': String(feeCents),\n // The only witness to the tax (AGL-1969). By the construction above\n // Stripe does not know the second line is tax, so it reports\n // `total_details.amount_tax: 0` — the session's own metadata is what\n // `storefront-tax-record.ts` and the confirmation branch read to record\n // the figure and derive the regime, exactly as `checkout.ts` stamps it\n // for a buy-now sale. Absent when there is no tax, never a reassuring\n // `'0'`: absent and zero are different facts on a back-book question.\n ...(lodgingTax.taxCents > 0\n ? {\n 'metadata[taxCents]': String(lodgingTax.taxCents),\n 'metadata[taxPct]': String(lodgingTax.pct),\n }\n : {}),\n })\n const response = await fetch(\n 'https://api.stripe.com/v1/checkout/sessions',\n {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n 'Content-Type': 'application/x-www-form-urlencoded',\n // The half that costs real money (AGL-1697). Stripe replays the\n // existing session for a repeated key instead of opening a second\n // one, covering the window where the claim is written but the\n // response never arrives.\n ...(claim.stripeKey ? { 'Idempotency-Key': claim.stripeKey } : {}),\n },\n body: params.toString(),\n },\n )\n const session = (await response.json()) as { url?: string; error?: any }\n if (!response.ok || !session.url) {\n console.error('Stripe reservation error', session.error)\n await reservationRef.delete().catch(() => undefined)\n // The hold is rolled back, so the key must come back too — the guest\n // presses the same button and the retry re-derives the same digest,\n // which Stripe replays if the session did get created.\n await claim.release()\n return res.status(502).json({ error: 'Payment setup failed' })\n }\n const payload = { url: session.url }\n await claim.record(200, payload)\n return res.status(200).json(payload)\n } catch (error) {\n console.error(error)\n // Release on the way out so a transient failure does not strand the key\n // (AGL-1691's rule): the hold may or may not have landed, and it expires\n // in 30 minutes either way.\n await claim?.release()\n return res.status(500).json({ error: 'Reservation failed' })\n }\n}\n"],"names":["Aglyn","CommerceModel","claimAttempt","firebaseAdmin","getOrgForHost","connectLinkageIsReady","checkoutSessionCardAuthenticationParams","reserveHandler","req","res","body","method","status","json","error","process","env","STRIPE_SECRET_KEY","JSON","parse","hostId","String","resourceId","checkInDayMs","toDayMs","Number","checkOutDayMs","guestName","trim","slice","guestEmail","toLowerCase","idempotencyKey","headers","claim","ownerOrg","storeSnapshot","firestore","app","hostRef","collection","doc","checkEntitlement","org","ownerId","ownerUid","ownerProfile","get","accountId","chargesEnabled","accountLivemode","subject","claimed","kind","scopeId","orgId","id","key","busyMessage","replay","availabilityQuery","where","orderBy","limit","resourceSnapshot","reservationsSnapshot","Promise","all","resource","data","release","readLive","snapshot","docs","map","docSnapshot","createdAtMs","now","Date","live","isRangeAvailable","quote","computeReservationQuote","problem","chargeCents","depositCents","totalCents","taxSettings","tax","lodgingTax","resolveFlatTaxCents","lodging","feeCents","resolveTransactionFeeCents","taxCents","reservationRef","booked","runTransaction","transaction","fresh","create","nights","paidCents","referer","origin","host","backUrl","startsWith","separator","includes","params","URLSearchParams","mode","name","label","customer_email","success_url","cancel_url","pct","response","fetch","Authorization","stripeKey","toString","session","ok","url","console","delete","catch","undefined","payload","record"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/reserve.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { readSiteReturnHost, siteReturnUrl } from '@aglyn/aglyn/app-utils/site-return-url'\nimport type { AttemptClaim, PluginApiHandler } from '@aglyn/aglyn/server'\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport { claimAttempt } from '@aglyn/aglyn/server'\nimport { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport { connectLinkageIsReady } from '@aglyn/tenant-data-admin/server/stripe-account-mode'\nimport { checkoutSessionCardAuthenticationParams } from '@aglyn/tenant-data-admin/server/stripe-card-authentication'\n\n/**\n * Reserve a stay (AGL-310): server-side quote + availability check,\n * pending reservation doc, then a Stripe session for the deposit (or\n * full amount) on the merchant's account. Webhook completion confirms\n * the reservation; abandoned pending holds expire after 30 minutes and\n * stop blocking the calendar.\n */\nexport const reserveHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n if (!process.env.STRIPE_SECRET_KEY) {\n return res.status(501).json({ error: 'Payments are not configured.' })\n }\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const resourceId = String(body.resourceId ?? '')\n const checkInDayMs = CommerceModel.toDayMs(Number(body.checkInDayMs ?? 0))\n const checkOutDayMs = CommerceModel.toDayMs(Number(body.checkOutDayMs ?? 0))\n const guestName = String(body.guestName ?? '').trim().slice(0, 120)\n const guestEmail = String(body.guestEmail ?? '').trim().toLowerCase().slice(0, 120)\n // One reservation attempt, minted by the widget (AGL-1697). Node lowercases\n // incoming headers, but read both spellings — the plugin API request type\n // makes no promise about casing.\n const idempotencyKey = String(\n req.headers['idempotency-key'] ?? req.headers['Idempotency-Key'] ?? '',\n ).trim().slice(0, 200)\n if (!hostId || !resourceId || !checkInDayMs || !checkOutDayMs) {\n return res.status(400).json({ error: 'Missing reservation details' })\n }\n\n let claim: AttemptClaim | null = null\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n // Owner + payment readiness, HOISTED above the claim (AGL-1697): both are\n // deterministic refusals a merchant fixes out of band, and neither should\n // burn the guest's attempt key.\n const ownerOrg = await getOrgForHost(hostId)\n // Plan gate, re-asked per request exactly as the storefront checkout\n // asks it (AGL-1873, the AGL-481 pattern): a lapsed org's storefront\n // must not keep taking reservation deposits — at the free plan's 0%\n // transaction fee — through the one shopper-facing commerce door that\n // skipped the entitlement. Deterministic and merchant-side, so it sits\n // here above the claim with the readiness check.\n if (!Aglyn.checkEntitlement(ownerOrg?.org as any, 'commerce')) {\n return res.status(403).json({ error: 'Reservations are not enabled' })\n }\n const ownerId = ownerOrg?.org?.ownerUid\n const ownerProfile = ownerId\n ? await firestore.collection('profiles').doc(String(ownerId)).get()\n : null\n const accountId = ownerProfile?.get('stripeAccountId')\n if (\n !connectLinkageIsReady(\n {\n accountId,\n chargesEnabled: ownerProfile?.get('stripeChargesEnabled'),\n accountLivemode: ownerProfile?.get('stripeAccountLivemode'),\n },\n { subject: `reserve host ${hostId}` },\n )\n ) {\n return res.status(409).json({ error: 'Payments are not set up yet' })\n }\n\n // The claim sits ABOVE the availability read, and that placement is the\n // fix for this handler's particular lie (AGL-1697): a retried attempt used\n // to collide with its OWN 30-minute pending hold and be told \"Those dates\n // just sold out\" — about dates the guest was holding — where the correct\n // answer was the original session URL. Replaying here means the retry\n // never reaches the availability check at all. Every refusal below the\n // claim releases it, because each is deterministic and retryable.\n const claimed = await claimAttempt(firestore, {\n kind: 'commerce-reserve',\n scopeId: hostId,\n orgId: String(ownerOrg?.org?.id ?? ''),\n key: idempotencyKey,\n busyMessage: 'This reservation is already being processed',\n })\n if ('replay' in claimed) {\n return res.status(claimed.replay.status).json(claimed.replay.body)\n }\n claim = claimed.claim\n\n // ONLY STAYS THAT COULD STILL OVERLAP, NEAREST FIRST (AGL-2159).\n //\n // This was `.where('resourceId','==',resourceId).limit(500)` with no\n // ordering, so Firestore returned 500 documents in `__name__` order — an\n // arbitrary slice of the resource's ENTIRE booking history. Every stay the\n // resource has ever had competed for those 500 slots on equal terms with\n // the ones that matter, so a cottage that had taken 500 bookings began\n // silently dropping LIVE reservations out of its own availability check\n // and confirming a second guest into an occupied room. Nothing about that\n // failure is visible: `isRangeAvailable` is handed a short list and\n // answers, correctly, that the range is free.\n //\n // `checkOutDayMs > checkInDayMs` is the one inequality that removes the\n // whole of the past — a stay that ended before the requested arrival\n // cannot overlap it — and ordering by the same field puts the stays\n // nearest the requested window at the front of the limit rather than\n // whichever ones happen to sort early by id. Needs the composite index\n // `reservations (resourceId ASC, checkOutDayMs ASC)`, added to\n // `cloud/firebase-firestore.indexes.json` and pinned by\n // `reserve-availability-indexes.spec.ts` — an equality plus an `orderBy`\n // on a second field is exactly the shape that reads as free and is not\n // (AGL-1793), and here a missing index throws rather than under-reporting.\n //\n // RESIDUAL, stated: the cap can still bite when a single resource holds\n // more than 500 stays that all end between the requested arrival and the\n // end of a genuinely overlapping one. That is a far narrower window than\n // \"500 bookings in this resource's lifetime\", but it is not zero, and the\n // sweep that would keep expired `pending` holds from filling those slots\n // is a scheduled job this change does not add (see the commit).\n const availabilityQuery = hostRef\n .collection('reservations')\n .where('resourceId', '==', resourceId)\n .where('checkOutDayMs', '>', checkInDayMs)\n .orderBy('checkOutDayMs')\n .limit(500)\n const [resourceSnapshot, reservationsSnapshot, storeSnapshot] =\n await Promise.all([\n hostRef.collection('resources').doc(resourceId).get(),\n availabilityQuery.get(),\n // The merchant's own lodging rate (AGL-1969). Read in the SAME round\n // trip as the availability query rather than as a fourth sequential\n // await — this path already runs three reads before it can answer, and\n // a tax lookup is not a reason to make a guest wait for a fourth.\n hostRef.collection('settings').doc('store').get(),\n ])\n const resource = resourceSnapshot.data() as CommerceModel.HostResource | undefined\n if (!resource) {\n await claim.release()\n return res.status(404).json({ error: 'Unknown resource' })\n }\n\n // Pending holds block for 30 minutes only, so abandoned checkouts\n // release the dates. The lapse itself lives in\n // `reservationHoldsDates`, which `isRangeAvailable` applies — this reads\n // the fields that rule needs and leaves the rule to the model, so the\n // storefront's date-picker cannot answer a different question from the\n // door that takes the money.\n //\n // Extracted so the pre-read below and the re-read inside the booking\n // transaction cannot drift apart (AGL-2450). Two copies of a filter that\n // decides whether a room is free is exactly the shape where one of them\n // quietly stops matching the other.\n const readLive = (snapshot: {\n docs: any[]\n }): CommerceModel.HostReservation[] =>\n snapshot.docs.map((docSnapshot) => ({\n checkInDayMs: Number(docSnapshot.get('checkInDayMs')),\n checkOutDayMs: Number(docSnapshot.get('checkOutDayMs')),\n status: String(\n docSnapshot.get('status'),\n ) as CommerceModel.ReservationStatus,\n createdAtMs: Number(docSnapshot.get('createdAtMs') ?? 0),\n })) as CommerceModel.HostReservation[]\n const now = Date.now()\n const live = readLive(reservationsSnapshot)\n if (\n !CommerceModel.isRangeAvailable(\n resource,\n live,\n checkInDayMs,\n checkOutDayMs,\n now,\n )\n ) {\n // A GENUINE sold-out (a keyed retry replayed before reaching here). The\n // guest picks other dates or the blocking stay is cancelled; either way\n // the key must survive the refusal.\n await claim.release()\n return res.status(409).json({ error: 'Those dates just sold out' })\n }\n const quote = CommerceModel.computeReservationQuote(\n resource,\n checkInDayMs,\n checkOutDayMs,\n )\n if (quote.problem) {\n await claim.release()\n return res.status(400).json({ error: quote.problem })\n }\n\n const chargeCents = quote.depositCents || quote.totalCents\n\n // NO DISCOUNT IS APPLIED HERE, AND THAT IS AN OPEN QUESTION RATHER THAN AN\n // OVERSIGHT (AGL-305).\n //\n // `hosts/{hostId}/discounts` reaches the cart, buy-now, the draft-order\n // payment link and the register. This door was deliberately left out,\n // because unlike those four it does not take a payment for goods — it takes\n // a DEPOSIT against a stay that is settled later, and \"apply 10% off\" has\n // three different meanings against that shape:\n //\n // 1. Discount the STAY. `quote.totalCents` falls, the deposit falls with\n // it because it is a percentage of the subtotal, and the balance falls\n // too. This is the only reading that is actually a discount on the\n // booking — and it reprices a merchant's lodging inventory from a\n // storefront promo code, which is a larger claim than any of the four\n // goods doors makes.\n // 2. Discount the DEPOSIT only. Strictly worse than doing nothing: the\n // balance is computed downstream as `totalCents - paidCents`, so every\n // cent taken off at booking is billed straight back at check-out. The\n // guest would be shown a discount and then charged for it, which is\n // the silent-reversal shape this codebase refuses elsewhere.\n // 3. Discount the BALANCE. Nothing visible at booking; the guest finds\n // out at the register. A promotion nobody can see when they buy is not\n // a promotion.\n //\n // What makes this a product decision and not an implementation one is that\n // the balance is not collected by any door this repo can teach. It is read\n // off the console's reservations card as `totalCents - paidCents` and\n // collected BY A HUMAN at the till, so option 1 is the only one that\n // survives the hand-off — and it survives it by changing the stay's stored\n // price, which is the merchant's revenue basis and what their analytics,\n // their folio and their register all read.\n //\n // The tax makes it sharper still. `lodgingTax` below is an occupancy rate,\n // not a sales rate, and its basis here is already a STATED LIMITATION — it\n // is charged on what is collected (the deposit) rather than on the stay.\n // Layering a discount on top asks whether occupancy tax is owed on the\n // discounted room rate or the gross one, which differs by jurisdiction, is\n // the merchant's own regime to answer, and is not a question this handler\n // can decide from a promo code.\n //\n // So: refusing to guess. A shopper who types a code here is not told it\n // worked — this path never reads one, so there is nothing to fall through\n // silently, which is the failure mode that mattered. When the owner picks\n // a reading, option 1 is the one the machinery already supports: reduce\n // `quote.subtotalCents` before the deposit is derived from it, and the\n // deposit, the balance, the fee and the tax all follow without further\n // change.\n\n // LODGING TAX: the MERCHANT'S OWN RATE, off unless they set one\n // (AGL-1969, answering the decision AGL-1953 recorded here).\n //\n // What this path used to do, and why it was only ever a holding position:\n // it computed no tax in either store mode, on two stated grounds. The\n // first is still true and is the reason for the shape below — a stay is\n // not goods. The AGL-285 zone editor configures a SALES rate, lodging is\n // an occupancy/hotel regime with its own rates, its own registration and\n // its own return, and reading `rates[]` for a night would be confidently\n // wrong rather than merely absent. Stripe Tax has the mirror problem: it\n // needs a lodging tax code this handler does not send, so\n // `automatic_tax` here would compute a goods rate on a room. Neither of\n // those changes, and neither is used.\n //\n // What DID change is that the merchant now has a field of their own to\n // answer in (`tax.lodging`), so \"Aglyn does not know the right number\"\n // stops being a reason to collect nothing. They type the rate, we compute\n // it, charge it, record it and stamp the regime; the settings card states\n // plainly that determining what is owed and to whom is theirs. Aglyn\n // takes no tax position here — this is the manual LODGING rate, which\n // never touches Aglyn's registrations.\n //\n // ⚠️ This comment used to say \"Terms §10.3 ... untouched by this\". §10.3\n // CHANGED on 2026-08-24 (AGL-1956): Aglyn accepts marketplace-facilitator\n // status and new §10.7 makes storefront sales tax Aglyn's where it has a\n // collection obligation. Lodging tax is still the merchant's, so this\n // handler is unaffected — but §10.3 is no longer the authority for that.\n //\n // DEFAULT OFF, and that is load-bearing: `resolveFlatTaxCents` answers\n // zero for an absent, zero, negative or out-of-range rate, so no existing\n // reservation's charge moves because this shipped.\n //\n // THE BASIS IS WHAT IS CHARGED, AND THAT IS A STATED LIMITATION RATHER\n // THAN AN ANSWER. `chargeCents` is `depositCents || totalCents`, so on a\n // deposit-taking resource the tax collected here is the rate on the\n // DEPOSIT, not on the stay. Whether a given jurisdiction wants occupancy\n // tax on the full stay at booking, on the deposit only, or on redemption\n // at check-out is a tax question, it differs by jurisdiction, and this\n // code deliberately does NOT decide it — the amount the platform actually\n // moves is the only basis available to it, the settings card says so in\n // the merchant's own view, and the residual is on AGL-1969.\n const taxSettings = ((storeSnapshot.data() as any)?.tax ??\n {}) as CommerceModel.TaxSettings\n const lodgingTax = CommerceModel.resolveFlatTaxCents(\n taxSettings.lodging,\n chargeCents,\n 'Lodging tax',\n )\n\n // Platform take PLUS Stripe's card cost at cost (AGL-2152). This is a\n // destination charge, so Stripe debits its percentage plus 30¢ from the\n // PLATFORM's balance; with the old take-only figure every tier carrying a\n // 0% service rate paid Aglyn nothing and cost it that fee on each\n // reservation.\n //\n // THE TWO BASES ARE NOW DIFFERENT NUMBERS (AGL-1969), and the split is\n // AGL-2317's rule: the platform take is computed on the ITEMS — the stay\n // — while Stripe's cost is passed through on the WHOLE charge, because\n // that is what Stripe actually debits. Taking a percentage of the lodging\n // tax would be taking a cut of the state's money, which no storefront\n // path does. The previous single-argument form is what made this a real\n // risk: it read as \"the fee base is the charge\" and would have silently\n // started taxing the tax the moment a tax line appeared.\n const feeCents = Aglyn.resolveTransactionFeeCents(\n ownerOrg?.org as any,\n 'service',\n chargeCents,\n chargeCents + lodgingTax.taxCents,\n )\n\n // THE HOLD IS TAKEN IN A TRANSACTION (AGL-2450).\n //\n // The check above is a fast refusal on a stale read, and on its own that is\n // all it ever was: the availability query ran, the quote and the fee were\n // computed, and only then did the hold land — so two guests requesting the\n // same dates both read a reservation set that lacked the other's booking,\n // both passed, and both got a `pending` row and a real payment. Nothing\n // downstream caught it. The webhook's transaction guards double-CONFIRMATION\n // of one booking against Stripe redelivery; it never re-asks availability,\n // so both holds confirmed cleanly into two guests in one room.\n //\n // `claimAttempt` does not help either, and it is worth being explicit about\n // why: it is keyed on the guest's own `Idempotency-Key`, so it dedupes\n // retries of ONE attempt. Two different guests carry two different keys and\n // never contend.\n //\n // Re-asking inside the transaction that writes is the whole guard — the\n // same shape as `decrementVariantStock` (AGL-2320), and easier to justify\n // here: the conflicting write lands BEFORE Stripe is contacted, so this\n // door can legitimately refuse rather than take the money and withhold the\n // room.\n const reservationRef = hostRef.collection('reservations').doc()\n const booked = await firestore.runTransaction(async (transaction: any) => {\n const fresh = await transaction.get(availabilityQuery)\n // Re-timed, not reusing `now`: a transaction that retries under\n // contention may run appreciably later, and the 30-minute pending window\n // has to be measured from when this attempt actually commits.\n if (\n !CommerceModel.isRangeAvailable(\n resource,\n readLive(fresh),\n checkInDayMs,\n checkOutDayMs,\n Date.now(),\n )\n ) {\n return false\n }\n // `create()`, not `set()`: the ref is freshly minted, so an id collision\n // is a bug rather than an overwrite to absorb silently.\n transaction.create(reservationRef, {\n resourceId,\n status: 'pending',\n checkInDayMs,\n checkOutDayMs,\n guestName: guestName || null,\n guestEmail: guestEmail || null,\n nights: quote.nights,\n totalCents: quote.totalCents,\n depositCents: quote.depositCents,\n paidCents: 0,\n createdAtMs: now,\n } satisfies CommerceModel.HostReservation)\n return true\n })\n if (!booked) {\n // Lost the race for these dates. Same refusal as the pre-read's, and the\n // key survives it for the same reason: the guest picks other dates or the\n // blocking stay is cancelled.\n await claim.release()\n return res.status(409).json({ error: 'Those dates just sold out' })\n }\n\n // The site's own page, never a header's say-so (AGL-3363): a caller\n // writes `Referer` and `Host`, and this URL is where Stripe sends the\n // payer — and what the receipt's download links are built from.\n const backUrl = siteReturnUrl({\n candidates: [String(req.headers.referer ?? ''), `https://${req.headers.host}`],\n site: await readSiteReturnHost(hostRef),\n requestHost: String(req.headers.host ?? ''),\n })\n const separator = backUrl.includes('?') ? '&' : '?'\n const params = new URLSearchParams({\n mode: 'payment',\n // 3-D Secure, from the one seam every card payment shares (AGL-3360).\n ...checkoutSessionCardAuthenticationParams('payment'),\n 'line_items[0][quantity]': '1',\n 'line_items[0][price_data][currency]': 'usd',\n 'line_items[0][price_data][unit_amount]': String(chargeCents),\n 'line_items[0][price_data][product_data][name]': `${resource.name} — ${\n quote.nights\n } night${quote.nights === 1 ? '' : 's'}${\n quote.depositCents && quote.depositCents < quote.totalCents\n ? ' (deposit)'\n : ''\n }`.slice(0, 120),\n // The merchant's lodging tax as an ORDINARY product line Stripe is\n // never told is tax — the AGL-1711 construction every other manual rate\n // rides. That is what makes the derived `taxMode` read `manual`: the\n // figure is the merchant's own, computed from their own rate, and never\n // against Aglyn's registrations the way `automatic_tax` would be\n // (AGL-1904). Absent entirely when no rate is set, so a default store's\n // session carries no tax parameter of any kind.\n ...(lodgingTax.taxCents > 0\n ? {\n 'line_items[1][quantity]': '1',\n 'line_items[1][price_data][currency]': 'usd',\n 'line_items[1][price_data][unit_amount]': String(\n lodgingTax.taxCents,\n ),\n 'line_items[1][price_data][product_data][name]': lodgingTax.label,\n }\n : {}),\n ...(feeCents > 0\n ? { 'payment_intent_data[application_fee_amount]': String(feeCents) }\n : {}),\n 'payment_intent_data[transfer_data][destination]': String(accountId),\n ...(guestEmail ? { customer_email: guestEmail } : {}),\n success_url: `${backUrl}${separator}reserved=1`,\n cancel_url: `${backUrl}${separator}reserved=0`,\n 'metadata[type]': 'commerce-reservation',\n 'metadata[hostId]': hostId,\n 'metadata[reservationId]': reservationRef.id,\n 'metadata[feeCents]': String(feeCents),\n // The only witness to the tax (AGL-1969). By the construction above\n // Stripe does not know the second line is tax, so it reports\n // `total_details.amount_tax: 0` — the session's own metadata is what\n // `storefront-tax-record.ts` and the confirmation branch read to record\n // the figure and derive the regime, exactly as `checkout.ts` stamps it\n // for a buy-now sale. Absent when there is no tax, never a reassuring\n // `'0'`: absent and zero are different facts on a back-book question.\n ...(lodgingTax.taxCents > 0\n ? {\n 'metadata[taxCents]': String(lodgingTax.taxCents),\n 'metadata[taxPct]': String(lodgingTax.pct),\n }\n : {}),\n })\n const response = await fetch(\n 'https://api.stripe.com/v1/checkout/sessions',\n {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n 'Content-Type': 'application/x-www-form-urlencoded',\n // The half that costs real money (AGL-1697). Stripe replays the\n // existing session for a repeated key instead of opening a second\n // one, covering the window where the claim is written but the\n // response never arrives.\n ...(claim.stripeKey ? { 'Idempotency-Key': claim.stripeKey } : {}),\n },\n body: params.toString(),\n },\n )\n const session = (await response.json()) as { url?: string; error?: any }\n if (!response.ok || !session.url) {\n console.error('Stripe reservation error', session.error)\n await reservationRef.delete().catch(() => undefined)\n // The hold is rolled back, so the key must come back too — the guest\n // presses the same button and the retry re-derives the same digest,\n // which Stripe replays if the session did get created.\n await claim.release()\n return res.status(502).json({ error: 'Payment setup failed' })\n }\n const payload = { url: session.url }\n await claim.record(200, payload)\n return res.status(200).json(payload)\n } catch (error) {\n console.error(error)\n // Release on the way out so a transient failure does not strand the key\n // (AGL-1691's rule): the hold may or may not have landed, and it expires\n // in 30 minutes either way.\n await claim?.release()\n return res.status(500).json({ error: 'Reservation failed' })\n }\n}\n"],"names":["readSiteReturnHost","siteReturnUrl","Aglyn","CommerceModel","claimAttempt","firebaseAdmin","getOrgForHost","connectLinkageIsReady","checkoutSessionCardAuthenticationParams","reserveHandler","req","res","body","method","status","json","error","process","env","STRIPE_SECRET_KEY","JSON","parse","hostId","String","resourceId","checkInDayMs","toDayMs","Number","checkOutDayMs","guestName","trim","slice","guestEmail","toLowerCase","idempotencyKey","headers","claim","ownerOrg","storeSnapshot","firestore","app","hostRef","collection","doc","checkEntitlement","org","ownerId","ownerUid","ownerProfile","get","accountId","chargesEnabled","accountLivemode","subject","claimed","kind","scopeId","orgId","id","key","busyMessage","replay","availabilityQuery","where","orderBy","limit","resourceSnapshot","reservationsSnapshot","Promise","all","resource","data","release","readLive","snapshot","docs","map","docSnapshot","createdAtMs","now","Date","live","isRangeAvailable","quote","computeReservationQuote","problem","chargeCents","depositCents","totalCents","taxSettings","tax","lodgingTax","resolveFlatTaxCents","lodging","feeCents","resolveTransactionFeeCents","taxCents","reservationRef","booked","runTransaction","transaction","fresh","create","nights","paidCents","backUrl","candidates","referer","host","site","requestHost","separator","includes","params","URLSearchParams","mode","name","label","customer_email","success_url","cancel_url","pct","response","fetch","Authorization","stripeKey","toString","session","ok","url","console","delete","catch","undefined","payload","record"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,kBAAkB,EAAEC,aAAa,QAAQ,yCAAwC;AAE1F,YAAYC,WAAW,sBAAqB;AAC5C,YAAYC,mBAAmB,oBAAU;AACzC,SAASC,YAAY,QAAQ,sBAAqB;AAClD,SAASC,aAAa,EAAEC,aAAa,QAAQ,2BAA0B;AACvE,SAASC,qBAAqB,QAAQ,sDAAqD;AAC3F,SAASC,uCAAuC,QAAQ,6DAA4D;AAEpH;;;;;;CAMC,GACD,OAAO,MAAMC,iBAAmC,OAAOC,KAAKC;QAQDD,WACnCE,cACIA,kBACwBA,oBACCA,qBAC1BA,iBACCA,kBAKxBF,MAAAA;IAlBF,IAAIA,IAAIG,MAAM,KAAK,QAAQ;QACzB,OAAOF,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;IAC5D;IACA,IAAI,CAACC,QAAQC,GAAG,CAACC,iBAAiB,EAAE;QAClC,OAAOR,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA+B;IACtE;IACA,MAAMJ,OACJ,OAAOF,IAAIE,IAAI,KAAK,WAAWQ,KAAKC,KAAK,CAACX,IAAIE,IAAI,KAAKF,YAAAA,IAAIE,IAAI,YAARF,YAAY,CAAC;IACtE,MAAMY,SAASC,QAAOX,eAAAA,KAAKU,MAAM,YAAXV,eAAe;IACrC,MAAMY,aAAaD,QAAOX,mBAAAA,KAAKY,UAAU,YAAfZ,mBAAmB;IAC7C,MAAMa,eAAetB,cAAcuB,OAAO,CAACC,QAAOf,qBAAAA,KAAKa,YAAY,YAAjBb,qBAAqB;IACvE,MAAMgB,gBAAgBzB,cAAcuB,OAAO,CAACC,QAAOf,sBAAAA,KAAKgB,aAAa,YAAlBhB,sBAAsB;IACzE,MAAMiB,YAAYN,QAAOX,kBAAAA,KAAKiB,SAAS,YAAdjB,kBAAkB,IAAIkB,IAAI,GAAGC,KAAK,CAAC,GAAG;IAC/D,MAAMC,aAAaT,QAAOX,mBAAAA,KAAKoB,UAAU,YAAfpB,mBAAmB,IAAIkB,IAAI,GAAGG,WAAW,GAAGF,KAAK,CAAC,GAAG;IAC/E,4EAA4E;IAC5E,0EAA0E;IAC1E,iCAAiC;IACjC,MAAMG,iBAAiBX,QACrBb,QAAAA,8BAAAA,IAAIyB,OAAO,CAAC,kBAAkB,YAA9BzB,8BAAkCA,IAAIyB,OAAO,CAAC,kBAAkB,YAAhEzB,OAAoE,IACpEoB,IAAI,GAAGC,KAAK,CAAC,GAAG;IAClB,IAAI,CAACT,UAAU,CAACE,cAAc,CAACC,gBAAgB,CAACG,eAAe;QAC7D,OAAOjB,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA8B;IACrE;IAEA,IAAIoB,QAA6B;IACjC,IAAI;0BAoVoB1B,sBAEAA;YAtUN2B,eA4BAA,gBAwMMC;QAnPtB,MAAMC,YAAYlC,cAAcmC,GAAG,GAAGD,SAAS;QAC/C,MAAME,UAAUF,UAAUG,UAAU,CAAC,SAASC,GAAG,CAACrB;QAClD,0EAA0E;QAC1E,0EAA0E;QAC1E,gCAAgC;QAChC,MAAMe,WAAW,MAAM/B,cAAcgB;QACrC,qEAAqE;QACrE,qEAAqE;QACrE,oEAAoE;QACpE,sEAAsE;QACtE,uEAAuE;QACvE,iDAAiD;QACjD,IAAI,CAACpB,MAAM0C,gBAAgB,CAACP,4BAAAA,SAAUQ,GAAG,EAAS,aAAa;YAC7D,OAAOlC,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAA+B;QACtE;QACA,MAAM8B,UAAUT,6BAAAA,gBAAAA,SAAUQ,GAAG,qBAAbR,cAAeU,QAAQ;QACvC,MAAMC,eAAeF,UACjB,MAAMP,UAAUG,UAAU,CAAC,YAAYC,GAAG,CAACpB,OAAOuB,UAAUG,GAAG,KAC/D;QACJ,MAAMC,YAAYF,gCAAAA,aAAcC,GAAG,CAAC;QACpC,IACE,CAAC1C,sBACC;YACE2C;YACAC,cAAc,EAAEH,gCAAAA,aAAcC,GAAG,CAAC;YAClCG,eAAe,EAAEJ,gCAAAA,aAAcC,GAAG,CAAC;QACrC,GACA;YAAEI,SAAS,CAAC,aAAa,EAAE/B,QAAQ;QAAC,IAEtC;YACA,OAAOX,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAA8B;QACrE;QAEA,wEAAwE;QACxE,2EAA2E;QAC3E,0EAA0E;QAC1E,yEAAyE;QACzE,sEAAsE;QACtE,uEAAuE;QACvE,kEAAkE;QAClE,MAAMsC,UAAU,MAAMlD,aAAamC,WAAW;YAC5CgB,MAAM;YACNC,SAASlC;YACTmC,OAAOlC,gBAAOc,6BAAAA,iBAAAA,SAAUQ,GAAG,qBAAbR,eAAeqB,EAAE,oBAAI;YACnCC,KAAKzB;YACL0B,aAAa;QACf;QACA,IAAI,YAAYN,SAAS;YACvB,OAAO3C,IAAIG,MAAM,CAACwC,QAAQO,MAAM,CAAC/C,MAAM,EAAEC,IAAI,CAACuC,QAAQO,MAAM,CAACjD,IAAI;QACnE;QACAwB,QAAQkB,QAAQlB,KAAK;QAErB,iEAAiE;QACjE,EAAE;QACF,qEAAqE;QACrE,yEAAyE;QACzE,2EAA2E;QAC3E,yEAAyE;QACzE,uEAAuE;QACvE,wEAAwE;QACxE,0EAA0E;QAC1E,oEAAoE;QACpE,8CAA8C;QAC9C,EAAE;QACF,wEAAwE;QACxE,qEAAqE;QACrE,oEAAoE;QACpE,qEAAqE;QACrE,uEAAuE;QACvE,+DAA+D;QAC/D,wDAAwD;QACxD,yEAAyE;QACzE,uEAAuE;QACvE,2EAA2E;QAC3E,EAAE;QACF,wEAAwE;QACxE,yEAAyE;QACzE,yEAAyE;QACzE,0EAA0E;QAC1E,yEAAyE;QACzE,gEAAgE;QAChE,MAAM0B,oBAAoBrB,QACvBC,UAAU,CAAC,gBACXqB,KAAK,CAAC,cAAc,MAAMvC,YAC1BuC,KAAK,CAAC,iBAAiB,KAAKtC,cAC5BuC,OAAO,CAAC,iBACRC,KAAK,CAAC;QACT,MAAM,CAACC,kBAAkBC,sBAAsB7B,cAAc,GAC3D,MAAM8B,QAAQC,GAAG,CAAC;YAChB5B,QAAQC,UAAU,CAAC,aAAaC,GAAG,CAACnB,YAAYyB,GAAG;YACnDa,kBAAkBb,GAAG;YACrB,qEAAqE;YACrE,oEAAoE;YACpE,uEAAuE;YACvE,kEAAkE;YAClER,QAAQC,UAAU,CAAC,YAAYC,GAAG,CAAC,SAASM,GAAG;SAChD;QACH,MAAMqB,WAAWJ,iBAAiBK,IAAI;QACtC,IAAI,CAACD,UAAU;YACb,MAAMlC,MAAMoC,OAAO;YACnB,OAAO7D,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAmB;QAC1D;QAEA,kEAAkE;QAClE,+CAA+C;QAC/C,yEAAyE;QACzE,sEAAsE;QACtE,uEAAuE;QACvE,6BAA6B;QAC7B,EAAE;QACF,qEAAqE;QACrE,yEAAyE;QACzE,wEAAwE;QACxE,oCAAoC;QACpC,MAAMyD,WAAW,CAACC,WAGhBA,SAASC,IAAI,CAACC,GAAG,CAAC,CAACC;oBAMGA;uBANc;oBAClCpD,cAAcE,OAAOkD,YAAY5B,GAAG,CAAC;oBACrCrB,eAAeD,OAAOkD,YAAY5B,GAAG,CAAC;oBACtCnC,QAAQS,OACNsD,YAAY5B,GAAG,CAAC;oBAElB6B,aAAanD,QAAOkD,mBAAAA,YAAY5B,GAAG,CAAC,0BAAhB4B,mBAAkC;gBACxD;;QACF,MAAME,MAAMC,KAAKD,GAAG;QACpB,MAAME,OAAOR,SAASN;QACtB,IACE,CAAChE,cAAc+E,gBAAgB,CAC7BZ,UACAW,MACAxD,cACAG,eACAmD,MAEF;YACA,wEAAwE;YACxE,wEAAwE;YACxE,oCAAoC;YACpC,MAAM3C,MAAMoC,OAAO;YACnB,OAAO7D,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAA4B;QACnE;QACA,MAAMmE,QAAQhF,cAAciF,uBAAuB,CACjDd,UACA7C,cACAG;QAEF,IAAIuD,MAAME,OAAO,EAAE;YACjB,MAAMjD,MAAMoC,OAAO;YACnB,OAAO7D,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAOmE,MAAME,OAAO;YAAC;QACrD;QAEA,MAAMC,cAAcH,MAAMI,YAAY,IAAIJ,MAAMK,UAAU;QAE1D,2EAA2E;QAC3E,uBAAuB;QACvB,EAAE;QACF,wEAAwE;QACxE,sEAAsE;QACtE,4EAA4E;QAC5E,0EAA0E;QAC1E,+CAA+C;QAC/C,EAAE;QACF,2EAA2E;QAC3E,4EAA4E;QAC5E,wEAAwE;QACxE,uEAAuE;QACvE,2EAA2E;QAC3E,0BAA0B;QAC1B,yEAAyE;QACzE,4EAA4E;QAC5E,2EAA2E;QAC3E,yEAAyE;QACzE,kEAAkE;QAClE,yEAAyE;QACzE,4EAA4E;QAC5E,oBAAoB;QACpB,EAAE;QACF,2EAA2E;QAC3E,2EAA2E;QAC3E,sEAAsE;QACtE,qEAAqE;QACrE,2EAA2E;QAC3E,yEAAyE;QACzE,2CAA2C;QAC3C,EAAE;QACF,2EAA2E;QAC3E,2EAA2E;QAC3E,yEAAyE;QACzE,uEAAuE;QACvE,2EAA2E;QAC3E,0EAA0E;QAC1E,gCAAgC;QAChC,EAAE;QACF,wEAAwE;QACxE,0EAA0E;QAC1E,0EAA0E;QAC1E,wEAAwE;QACxE,uEAAuE;QACvE,uEAAuE;QACvE,UAAU;QAEV,gEAAgE;QAChE,6DAA6D;QAC7D,EAAE;QACF,0EAA0E;QAC1E,sEAAsE;QACtE,wEAAwE;QACxE,yEAAyE;QACzE,yEAAyE;QACzE,yEAAyE;QACzE,yEAAyE;QACzE,0DAA0D;QAC1D,wEAAwE;QACxE,sCAAsC;QACtC,EAAE;QACF,uEAAuE;QACvE,uEAAuE;QACvE,0EAA0E;QAC1E,0EAA0E;QAC1E,qEAAqE;QACrE,sEAAsE;QACtE,uCAAuC;QACvC,EAAE;QACF,yEAAyE;QACzE,0EAA0E;QAC1E,yEAAyE;QACzE,sEAAsE;QACtE,yEAAyE;QACzE,EAAE;QACF,uEAAuE;QACvE,0EAA0E;QAC1E,mDAAmD;QACnD,EAAE;QACF,uEAAuE;QACvE,yEAAyE;QACzE,oEAAoE;QACpE,yEAAyE;QACzE,yEAAyE;QACzE,uEAAuE;QACvE,0EAA0E;QAC1E,wEAAwE;QACxE,4DAA4D;QAC5D,MAAMC,wBAAgBnD,sBAAAA,cAAciC,IAAI,uBAAnB,AAACjC,oBAA8BoD,GAAG,oBACrD,CAAC;QACH,MAAMC,aAAaxF,cAAcyF,mBAAmB,CAClDH,YAAYI,OAAO,EACnBP,aACA;QAGF,sEAAsE;QACtE,wEAAwE;QACxE,0EAA0E;QAC1E,kEAAkE;QAClE,eAAe;QACf,EAAE;QACF,uEAAuE;QACvE,yEAAyE;QACzE,uEAAuE;QACvE,0EAA0E;QAC1E,sEAAsE;QACtE,wEAAwE;QACxE,wEAAwE;QACxE,yDAAyD;QACzD,MAAMQ,WAAW5F,MAAM6F,0BAA0B,CAC/C1D,4BAAAA,SAAUQ,GAAG,EACb,WACAyC,aACAA,cAAcK,WAAWK,QAAQ;QAGnC,iDAAiD;QACjD,EAAE;QACF,4EAA4E;QAC5E,0EAA0E;QAC1E,2EAA2E;QAC3E,0EAA0E;QAC1E,wEAAwE;QACxE,6EAA6E;QAC7E,2EAA2E;QAC3E,+DAA+D;QAC/D,EAAE;QACF,4EAA4E;QAC5E,uEAAuE;QACvE,4EAA4E;QAC5E,iBAAiB;QACjB,EAAE;QACF,wEAAwE;QACxE,0EAA0E;QAC1E,wEAAwE;QACxE,2EAA2E;QAC3E,QAAQ;QACR,MAAMC,iBAAiBxD,QAAQC,UAAU,CAAC,gBAAgBC,GAAG;QAC7D,MAAMuD,SAAS,MAAM3D,UAAU4D,cAAc,CAAC,OAAOC;YACnD,MAAMC,QAAQ,MAAMD,YAAYnD,GAAG,CAACa;YACpC,gEAAgE;YAChE,yEAAyE;YACzE,8DAA8D;YAC9D,IACE,CAAC3D,cAAc+E,gBAAgB,CAC7BZ,UACAG,SAAS4B,QACT5E,cACAG,eACAoD,KAAKD,GAAG,KAEV;gBACA,OAAO;YACT;YACA,yEAAyE;YACzE,wDAAwD;YACxDqB,YAAYE,MAAM,CAACL,gBAAgB;gBACjCzE;gBACAV,QAAQ;gBACRW;gBACAG;gBACAC,WAAWA,aAAa;gBACxBG,YAAYA,cAAc;gBAC1BuE,QAAQpB,MAAMoB,MAAM;gBACpBf,YAAYL,MAAMK,UAAU;gBAC5BD,cAAcJ,MAAMI,YAAY;gBAChCiB,WAAW;gBACX1B,aAAaC;YACf;YACA,OAAO;QACT;QACA,IAAI,CAACmB,QAAQ;YACX,yEAAyE;YACzE,0EAA0E;YAC1E,8BAA8B;YAC9B,MAAM9D,MAAMoC,OAAO;YACnB,OAAO7D,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAA4B;QACnE;QAEA,oEAAoE;QACpE,sEAAsE;QACtE,gEAAgE;QAChE,MAAMyF,UAAUxG,cAAc;YAC5ByG,YAAY;gBAACnF,QAAOb,uBAAAA,IAAIyB,OAAO,CAACwE,OAAO,YAAnBjG,uBAAuB;gBAAK,CAAC,QAAQ,EAAEA,IAAIyB,OAAO,CAACyE,IAAI,EAAE;aAAC;YAC9EC,MAAM,MAAM7G,mBAAmByC;YAC/BqE,aAAavF,QAAOb,oBAAAA,IAAIyB,OAAO,CAACyE,IAAI,YAAhBlG,oBAAoB;QAC1C;QACA,MAAMqG,YAAYN,QAAQO,QAAQ,CAAC,OAAO,MAAM;QAChD,MAAMC,SAAS,IAAIC,gBAAgB;YACjCC,MAAM;WAEH3G,wCAAwC;YAC3C,2BAA2B;YAC3B,uCAAuC;YACvC,0CAA0Ce,OAAO+D;YACjD,iDAAiD,GAAGhB,SAAS8C,IAAI,CAAC,GAAG,EACnEjC,MAAMoB,MAAM,CACb,MAAM,EAAEpB,MAAMoB,MAAM,KAAK,IAAI,KAAK,MACjCpB,MAAMI,YAAY,IAAIJ,MAAMI,YAAY,GAAGJ,MAAMK,UAAU,GACvD,eACA,IACJ,CAACzD,KAAK,CAAC,GAAG;WAQR4D,WAAWK,QAAQ,GAAG,IACtB;YACE,2BAA2B;YAC3B,uCAAuC;YACvC,0CAA0CzE,OACxCoE,WAAWK,QAAQ;YAErB,iDAAiDL,WAAW0B,KAAK;QACnE,IACA,CAAC,GACDvB,WAAW,IACX;YAAE,+CAA+CvE,OAAOuE;QAAU,IAClE,CAAC;YACL,mDAAmDvE,OAAO2B;WACtDlB,aAAa;YAAEsF,gBAAgBtF;QAAW,IAAI,CAAC;YACnDuF,aAAa,GAAGd,UAAUM,UAAU,UAAU,CAAC;YAC/CS,YAAY,GAAGf,UAAUM,UAAU,UAAU,CAAC;YAC9C,kBAAkB;YAClB,oBAAoBzF;YACpB,2BAA2B2E,eAAevC,EAAE;YAC5C,sBAAsBnC,OAAOuE;WAQzBH,WAAWK,QAAQ,GAAG,IACtB;YACE,sBAAsBzE,OAAOoE,WAAWK,QAAQ;YAChD,oBAAoBzE,OAAOoE,WAAW8B,GAAG;QAC3C,IACA,CAAC;QAEP,MAAMC,WAAW,MAAMC,MACrB,+CACA;YACE9G,QAAQ;YACRsB,SAAS;gBACPyF,eAAe,CAAC,OAAO,EAAE3G,QAAQC,GAAG,CAACC,iBAAiB,EAAE;gBACxD,gBAAgB;eAKZiB,MAAMyF,SAAS,GAAG;gBAAE,mBAAmBzF,MAAMyF,SAAS;YAAC,IAAI,CAAC;YAElEjH,MAAMqG,OAAOa,QAAQ;QACvB;QAEF,MAAMC,UAAW,MAAML,SAAS3G,IAAI;QACpC,IAAI,CAAC2G,SAASM,EAAE,IAAI,CAACD,QAAQE,GAAG,EAAE;YAChCC,QAAQlH,KAAK,CAAC,4BAA4B+G,QAAQ/G,KAAK;YACvD,MAAMiF,eAAekC,MAAM,GAAGC,KAAK,CAAC,IAAMC;YAC1C,qEAAqE;YACrE,oEAAoE;YACpE,uDAAuD;YACvD,MAAMjG,MAAMoC,OAAO;YACnB,OAAO7D,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAuB;QAC9D;QACA,MAAMsH,UAAU;YAAEL,KAAKF,QAAQE,GAAG;QAAC;QACnC,MAAM7F,MAAMmG,MAAM,CAAC,KAAKD;QACxB,OAAO3H,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAACuH;IAC9B,EAAE,OAAOtH,OAAO;QACdkH,QAAQlH,KAAK,CAACA;QACd,wEAAwE;QACxE,yEAAyE;QACzE,4BAA4B;QAC5B,OAAMoB,yBAAAA,MAAOoC,OAAO;QACpB,OAAO7D,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;IAC5D;AACF,EAAC"}
@@ -13,7 +13,8 @@
13
13
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
- */ import { MEDIA_CDN_RENDITION_AUTO, MEDIA_CDN_RENDITION_PARAM } from "@aglyn/aglyn/server";
16
+ */ import { flagLookalikeDomain } from "@aglyn/tenant-data-admin/server/hosted-page-review";
17
+ import { MEDIA_CDN_RENDITION_AUTO, MEDIA_CDN_RENDITION_PARAM } from "@aglyn/aglyn/server";
17
18
  import * as CommerceModel from "../model/index.js";
18
19
  import { firebaseAdmin, GATED_VIDEO_SESSION_TTL_MS } from "@aglyn/tenant-data-admin";
19
20
  import { createPaidMediaDeliveryIo, resolvePaidMediaDelivery } from "./paid-media-delivery.js";
@@ -167,6 +168,16 @@ const TTL_MS = 15 * 60 * 1000;
167
168
  video,
168
169
  refusal: delivery.refusal
169
170
  }));
171
+ // A lookalike hotlink is filed for staff and the merchant (AGL-3363).
172
+ if (delivery.refusal === 'lookalike' && delivery.host) {
173
+ await flagLookalikeDomain({
174
+ kind: 'link',
175
+ hostId,
176
+ orgId: null,
177
+ domain: delivery.host,
178
+ where: "a product's video link"
179
+ });
180
+ }
170
181
  return delivery.refusal === 'not-private' ? res.status(409).send('This video is not ready to play yet') : res.status(404).send('No video');
171
182
  }
172
183
  /*
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/stream.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n MEDIA_CDN_RENDITION_AUTO,\n MEDIA_CDN_RENDITION_PARAM,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport {\n firebaseAdmin,\n GATED_VIDEO_SESSION_TTL_MS,\n} from '@aglyn/tenant-data-admin'\nimport {\n createPaidMediaDeliveryIo,\n resolvePaidMediaDelivery,\n} from './paid-media-delivery'\nimport { createHmac, timingSafeEqual } from 'crypto'\nimport { requireActiveMember } from './membership'\nimport { checkMemberEntitlement } from './gate'\nimport { tokenSigningSecret } from './download'\n\nconst TTL_MS = 15 * 60 * 1000\n\n/**\n * Signed with the dedicated, fail-closed `TOKEN_SIGNING_SECRET` (AGL-509).\n * AGL-509 converted the download and supplier tokens but missed this call\n * site (AGL-689): the old `STRIPE_SECRET_KEY ?? 'aglyn'` key left the whole\n * payload — `stream:${hostId}:${productId}:${video}:${exp}`, all public\n * identifiers — forgeable on any deploy without the Stripe key, and the\n * short TTL bought nothing because the forger chooses `exp`.\n */\nfunction sign(hostId: string, productId: string, video: number, exp: number) {\n return createHmac('sha256', tokenSigningSecret())\n .update(`stream:${hostId}:${productId}:${video}:${exp}`)\n .digest('hex')\n .slice(0, 32)\n}\n\n/**\n * Constant-time signature compare (AGL-512, closed here by AGL-1881).\n *\n * The presented signature was compared with `!==`, which short-circuits on\n * the first differing byte — the exact defect AGL-512 closed in `download.ts`\n * and `supplier-update.ts` and missed here, one directory over. It matters\n * more on this route than it reads: the signature is a truncated HMAC over\n * values the caller already knows (`hostId`, `productId`, `video`, `exp`), so\n * a byte-at-a-time oracle recovers a token for a video the caller was never\n * entitled to, and the 15-minute TTL does not bound the attempt rate.\n *\n * Length is checked first because `timingSafeEqual` THROWS on a length\n * mismatch rather than returning false, and the expected length here is a\n * public constant (32 hex chars) — leaking it costs nothing.\n */\nfunction signatureMatches(presented: string, expected: string): boolean {\n const a = Buffer.from(String(presented ?? ''), 'utf8')\n const b = Buffer.from(String(expected ?? ''), 'utf8')\n if (a.length !== b.length) return false\n return timingSafeEqual(new Uint8Array(a), new Uint8Array(b))\n}\n\n/**\n * Gated video streaming (AGL-315). POST (member session) checks the\n * entitlement and mints a short-TTL signed URL; GET with a valid signature\n * redirects to a media URL that itself expires (AGL-2814). The mint step is\n * the server-enforced gate, a shared stream link dies within 15 minutes, and\n * the media URL behind it dies with the viewing session.\n */\nexport const streamHandler: PluginApiHandler = async (req, res) => {\n const hostId = String(\n (req.method === 'POST' ? req.body?.hostId : req.query.hostId) ?? '',\n )\n const productId = String(\n (req.method === 'POST' ? req.body?.productId : req.query.productId) ?? '',\n )\n const video = Math.max(\n 0,\n Number(\n (req.method === 'POST' ? req.body?.video : req.query.video) ?? 0,\n ),\n )\n if (!hostId || !productId) {\n return res.status(400).json({ error: 'Missing hostId or productId' })\n }\n\n try {\n if (req.method === 'POST') {\n // Suspension gate (AGL-550): suspended members (AGL-546) cannot\n // mint stream URLs — 403'd with the session cookie cleared.\n const auth = await requireActiveMember(req, res, hostId, 'Sign in first')\n if (!auth) return\n const email = String(auth.member.get('email') ?? '')\n const entitled =\n email && (await checkMemberEntitlement(hostId, email, productId))\n if (!entitled) {\n return res.status(403).json({ error: 'Not entitled' })\n }\n const exp = Date.now() + TTL_MS\n const url =\n `/api/commerce/stream?hostId=${encodeURIComponent(hostId)}` +\n `&productId=${encodeURIComponent(productId)}&video=${video}` +\n `&exp=${exp}&sig=${sign(hostId, productId, video, exp)}`\n res.setHeader('Cache-Control', 'private, no-store')\n return res.status(200).json({ url, expiresAtMs: exp })\n }\n\n // GET: signature + expiry gate, then redirect to the media.\n const exp = Number(req.query.exp ?? 0)\n const sig = String(req.query.sig ?? '')\n if (\n !exp ||\n exp < Date.now() ||\n !signatureMatches(sig, sign(hostId, productId, video, exp))\n ) {\n return res.status(403).send('Link expired — reload the page')\n }\n const app = firebaseAdmin.app()\n const productSnapshot = await app\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('products')\n .doc(productId)\n .get()\n const product = CommerceModel.liftLegacyProduct(\n (productSnapshot.data() as any) ?? {},\n )\n const file = product.gatedVideos?.[video]\n if (!file?.url) return res.status(404).send('No video')\n /**\n * The redirect lands on a link that expires (AGL-2814).\n *\n * Everything above only guards the 302. What the 302 points at is what\n * the buyer, and anyone who copies the `Location` out of the buyer's\n * network panel, keeps. So `resolvePaidMediaDelivery` hands back one of\n * three things and never a permanent URL:\n *\n * - a CDN URL signed for {@link GATED_VIDEO_SESSION_TTL_MS}, when the video\n * is a PRIVATE asset in this site's library or its org's. The CDN refuses\n * a private asset on every path without an unexpired signature.\n * - a V4 signed Storage read with the same lifetime, for an object in those\n * libraries that no media document owns. A raw download URL's token lives\n * as long as the object, so it is never the redirect.\n * - an author-typed hotlink, unchanged, because nothing of ours runs there.\n *\n * A video whose asset is still PUBLIC is refused rather than delivered:\n * its URL already works for anyone, so redirecting a buyer to it is the\n * leak. The 409 is what the gated player reports to the viewer, and the\n * log line is what tells the operator which product needs its video made\n * private.\n *\n * `?r=auto` rides inside the signed URL (AGL-2766). The signature covers\n * `(scope, mediaId, exp)` and not the parameter, so it asks the CDN for the\n * best delivery copy of the one asset the signature names and can reach no\n * other. Nothing the caller sends reaches this `Location`: the GET half\n * reads `hostId`, `productId`, `video`, `exp` and `sig`, and builds the\n * target from the product document.\n *\n * With a delivery provider configured and its release flag on for the\n * owning org, a video with a current copy there is redirected straight to\n * the provider's short-lived URL instead (AGL-2824), for the same session\n * and the same rendition the CDN would pick. `Accept` is the one request\n * header that reaches that choice, and only to pick among the asset's own\n * encodings.\n */\n const delivery = await resolvePaidMediaDelivery({\n stored: file.url,\n hostId,\n ttlMs: GATED_VIDEO_SESSION_TTL_MS,\n cdnParams: [[MEDIA_CDN_RENDITION_PARAM, MEDIA_CDN_RENDITION_AUTO]],\n delivery: { accept: req.headers?.['accept'] },\n io: createPaidMediaDeliveryIo({\n firestore: app.firestore(),\n bucket: app\n .storage()\n .bucket(process.env['NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET']),\n }),\n })\n // Per-member and time-boxed, whichever way it goes: no cache may keep the\n // redirect past the session it opens, nor a refusal past the fix.\n res.setHeader('Cache-Control', 'private, no-store')\n if (delivery.ok === false) {\n console.error(\n '[commerce/stream] gated video not delivered',\n JSON.stringify({ hostId, productId, video, refusal: delivery.refusal }),\n )\n return delivery.refusal === 'not-private'\n ? res.status(409).send('This video is not ready to play yet')\n : res.status(404).send('No video')\n }\n /*\n * `Vary: Accept` only when this response chose the rendition (AGL-2824).\n *\n * A signed CDN URL is the same for every client: the `Location` is a\n * function of the product document and the clock, not of the request's\n * `Accept`, and the negotiation happens on the SECOND response, where\n * `serveMediaCdn` declares `Vary` (AGL-2766). A delivery provider's URL is\n * minted after the rendition is picked, so there the `Accept` shaped this\n * `Location`, and saying so is correct even under `no-store`.\n */\n if (delivery.via === 'delivery') res.setHeader('Vary', 'Accept')\n return res.redirect(302, delivery.location)\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Stream unavailable' })\n }\n}\n"],"names":["MEDIA_CDN_RENDITION_AUTO","MEDIA_CDN_RENDITION_PARAM","CommerceModel","firebaseAdmin","GATED_VIDEO_SESSION_TTL_MS","createPaidMediaDeliveryIo","resolvePaidMediaDelivery","createHmac","timingSafeEqual","requireActiveMember","checkMemberEntitlement","tokenSigningSecret","TTL_MS","sign","hostId","productId","video","exp","update","digest","slice","signatureMatches","presented","expected","a","Buffer","from","String","b","length","Uint8Array","streamHandler","req","res","method","body","query","Math","max","Number","status","json","error","productSnapshot","product","auth","email","member","get","entitled","Date","now","url","encodeURIComponent","setHeader","expiresAtMs","sig","send","app","firestore","collection","doc","liftLegacyProduct","data","file","gatedVideos","delivery","stored","ttlMs","cdnParams","accept","headers","io","bucket","storage","process","env","ok","console","JSON","stringify","refusal","via","redirect","location"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/stream.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { flagLookalikeDomain } from '@aglyn/tenant-data-admin/server/hosted-page-review'\nimport {\n MEDIA_CDN_RENDITION_AUTO,\n MEDIA_CDN_RENDITION_PARAM,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport {\n firebaseAdmin,\n GATED_VIDEO_SESSION_TTL_MS,\n} from '@aglyn/tenant-data-admin'\nimport {\n createPaidMediaDeliveryIo,\n resolvePaidMediaDelivery,\n} from './paid-media-delivery'\nimport { createHmac, timingSafeEqual } from 'crypto'\nimport { requireActiveMember } from './membership'\nimport { checkMemberEntitlement } from './gate'\nimport { tokenSigningSecret } from './download'\n\nconst TTL_MS = 15 * 60 * 1000\n\n/**\n * Signed with the dedicated, fail-closed `TOKEN_SIGNING_SECRET` (AGL-509).\n * AGL-509 converted the download and supplier tokens but missed this call\n * site (AGL-689): the old `STRIPE_SECRET_KEY ?? 'aglyn'` key left the whole\n * payload — `stream:${hostId}:${productId}:${video}:${exp}`, all public\n * identifiers — forgeable on any deploy without the Stripe key, and the\n * short TTL bought nothing because the forger chooses `exp`.\n */\nfunction sign(hostId: string, productId: string, video: number, exp: number) {\n return createHmac('sha256', tokenSigningSecret())\n .update(`stream:${hostId}:${productId}:${video}:${exp}`)\n .digest('hex')\n .slice(0, 32)\n}\n\n/**\n * Constant-time signature compare (AGL-512, closed here by AGL-1881).\n *\n * The presented signature was compared with `!==`, which short-circuits on\n * the first differing byte — the exact defect AGL-512 closed in `download.ts`\n * and `supplier-update.ts` and missed here, one directory over. It matters\n * more on this route than it reads: the signature is a truncated HMAC over\n * values the caller already knows (`hostId`, `productId`, `video`, `exp`), so\n * a byte-at-a-time oracle recovers a token for a video the caller was never\n * entitled to, and the 15-minute TTL does not bound the attempt rate.\n *\n * Length is checked first because `timingSafeEqual` THROWS on a length\n * mismatch rather than returning false, and the expected length here is a\n * public constant (32 hex chars) — leaking it costs nothing.\n */\nfunction signatureMatches(presented: string, expected: string): boolean {\n const a = Buffer.from(String(presented ?? ''), 'utf8')\n const b = Buffer.from(String(expected ?? ''), 'utf8')\n if (a.length !== b.length) return false\n return timingSafeEqual(new Uint8Array(a), new Uint8Array(b))\n}\n\n/**\n * Gated video streaming (AGL-315). POST (member session) checks the\n * entitlement and mints a short-TTL signed URL; GET with a valid signature\n * redirects to a media URL that itself expires (AGL-2814). The mint step is\n * the server-enforced gate, a shared stream link dies within 15 minutes, and\n * the media URL behind it dies with the viewing session.\n */\nexport const streamHandler: PluginApiHandler = async (req, res) => {\n const hostId = String(\n (req.method === 'POST' ? req.body?.hostId : req.query.hostId) ?? '',\n )\n const productId = String(\n (req.method === 'POST' ? req.body?.productId : req.query.productId) ?? '',\n )\n const video = Math.max(\n 0,\n Number(\n (req.method === 'POST' ? req.body?.video : req.query.video) ?? 0,\n ),\n )\n if (!hostId || !productId) {\n return res.status(400).json({ error: 'Missing hostId or productId' })\n }\n\n try {\n if (req.method === 'POST') {\n // Suspension gate (AGL-550): suspended members (AGL-546) cannot\n // mint stream URLs — 403'd with the session cookie cleared.\n const auth = await requireActiveMember(req, res, hostId, 'Sign in first')\n if (!auth) return\n const email = String(auth.member.get('email') ?? '')\n const entitled =\n email && (await checkMemberEntitlement(hostId, email, productId))\n if (!entitled) {\n return res.status(403).json({ error: 'Not entitled' })\n }\n const exp = Date.now() + TTL_MS\n const url =\n `/api/commerce/stream?hostId=${encodeURIComponent(hostId)}` +\n `&productId=${encodeURIComponent(productId)}&video=${video}` +\n `&exp=${exp}&sig=${sign(hostId, productId, video, exp)}`\n res.setHeader('Cache-Control', 'private, no-store')\n return res.status(200).json({ url, expiresAtMs: exp })\n }\n\n // GET: signature + expiry gate, then redirect to the media.\n const exp = Number(req.query.exp ?? 0)\n const sig = String(req.query.sig ?? '')\n if (\n !exp ||\n exp < Date.now() ||\n !signatureMatches(sig, sign(hostId, productId, video, exp))\n ) {\n return res.status(403).send('Link expired — reload the page')\n }\n const app = firebaseAdmin.app()\n const productSnapshot = await app\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('products')\n .doc(productId)\n .get()\n const product = CommerceModel.liftLegacyProduct(\n (productSnapshot.data() as any) ?? {},\n )\n const file = product.gatedVideos?.[video]\n if (!file?.url) return res.status(404).send('No video')\n /**\n * The redirect lands on a link that expires (AGL-2814).\n *\n * Everything above only guards the 302. What the 302 points at is what\n * the buyer, and anyone who copies the `Location` out of the buyer's\n * network panel, keeps. So `resolvePaidMediaDelivery` hands back one of\n * three things and never a permanent URL:\n *\n * - a CDN URL signed for {@link GATED_VIDEO_SESSION_TTL_MS}, when the video\n * is a PRIVATE asset in this site's library or its org's. The CDN refuses\n * a private asset on every path without an unexpired signature.\n * - a V4 signed Storage read with the same lifetime, for an object in those\n * libraries that no media document owns. A raw download URL's token lives\n * as long as the object, so it is never the redirect.\n * - an author-typed hotlink, unchanged, because nothing of ours runs there.\n *\n * A video whose asset is still PUBLIC is refused rather than delivered:\n * its URL already works for anyone, so redirecting a buyer to it is the\n * leak. The 409 is what the gated player reports to the viewer, and the\n * log line is what tells the operator which product needs its video made\n * private.\n *\n * `?r=auto` rides inside the signed URL (AGL-2766). The signature covers\n * `(scope, mediaId, exp)` and not the parameter, so it asks the CDN for the\n * best delivery copy of the one asset the signature names and can reach no\n * other. Nothing the caller sends reaches this `Location`: the GET half\n * reads `hostId`, `productId`, `video`, `exp` and `sig`, and builds the\n * target from the product document.\n *\n * With a delivery provider configured and its release flag on for the\n * owning org, a video with a current copy there is redirected straight to\n * the provider's short-lived URL instead (AGL-2824), for the same session\n * and the same rendition the CDN would pick. `Accept` is the one request\n * header that reaches that choice, and only to pick among the asset's own\n * encodings.\n */\n const delivery = await resolvePaidMediaDelivery({\n stored: file.url,\n hostId,\n ttlMs: GATED_VIDEO_SESSION_TTL_MS,\n cdnParams: [[MEDIA_CDN_RENDITION_PARAM, MEDIA_CDN_RENDITION_AUTO]],\n delivery: { accept: req.headers?.['accept'] },\n io: createPaidMediaDeliveryIo({\n firestore: app.firestore(),\n bucket: app\n .storage()\n .bucket(process.env['NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET']),\n }),\n })\n // Per-member and time-boxed, whichever way it goes: no cache may keep the\n // redirect past the session it opens, nor a refusal past the fix.\n res.setHeader('Cache-Control', 'private, no-store')\n if (delivery.ok === false) {\n console.error(\n '[commerce/stream] gated video not delivered',\n JSON.stringify({ hostId, productId, video, refusal: delivery.refusal }),\n )\n // A lookalike hotlink is filed for staff and the merchant (AGL-3363).\n if (delivery.refusal === 'lookalike' && delivery.host) {\n await flagLookalikeDomain({\n kind: 'link',\n hostId,\n orgId: null,\n domain: delivery.host,\n where: \"a product's video link\",\n })\n }\n return delivery.refusal === 'not-private'\n ? res.status(409).send('This video is not ready to play yet')\n : res.status(404).send('No video')\n }\n /*\n * `Vary: Accept` only when this response chose the rendition (AGL-2824).\n *\n * A signed CDN URL is the same for every client: the `Location` is a\n * function of the product document and the clock, not of the request's\n * `Accept`, and the negotiation happens on the SECOND response, where\n * `serveMediaCdn` declares `Vary` (AGL-2766). A delivery provider's URL is\n * minted after the rendition is picked, so there the `Accept` shaped this\n * `Location`, and saying so is correct even under `no-store`.\n */\n if (delivery.via === 'delivery') res.setHeader('Vary', 'Accept')\n return res.redirect(302, delivery.location)\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Stream unavailable' })\n }\n}\n"],"names":["flagLookalikeDomain","MEDIA_CDN_RENDITION_AUTO","MEDIA_CDN_RENDITION_PARAM","CommerceModel","firebaseAdmin","GATED_VIDEO_SESSION_TTL_MS","createPaidMediaDeliveryIo","resolvePaidMediaDelivery","createHmac","timingSafeEqual","requireActiveMember","checkMemberEntitlement","tokenSigningSecret","TTL_MS","sign","hostId","productId","video","exp","update","digest","slice","signatureMatches","presented","expected","a","Buffer","from","String","b","length","Uint8Array","streamHandler","req","res","method","body","query","Math","max","Number","status","json","error","productSnapshot","product","auth","email","member","get","entitled","Date","now","url","encodeURIComponent","setHeader","expiresAtMs","sig","send","app","firestore","collection","doc","liftLegacyProduct","data","file","gatedVideos","delivery","stored","ttlMs","cdnParams","accept","headers","io","bucket","storage","process","env","ok","console","JSON","stringify","refusal","host","kind","orgId","domain","where","via","redirect","location"],"mappings":"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"}
@@ -13,7 +13,8 @@
13
13
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
- */ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
16
+ */ import { readSiteReturnHost, siteReturnUrl } from "@aglyn/aglyn/app-utils/site-return-url";
17
+ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
17
18
  import { requireActiveMember } from "./membership.js";
18
19
  /**
19
20
  * Subscriber self-service (AGL-303): opens the Stripe Billing Portal for
@@ -38,7 +39,7 @@ import { requireActiveMember } from "./membership.js";
38
39
  error: 'Missing hostId'
39
40
  });
40
41
  try {
41
- var _auth_member_get, _req_headers_referer;
42
+ var _auth_member_get, _req_headers_referer, _req_headers_host;
42
43
  var _subscriptions_docs_;
43
44
  // Suspension gate (AGL-550): suspended members (AGL-546) cannot
44
45
  // open the billing portal — 403'd with the session cookie cleared.
@@ -57,8 +58,15 @@ import { requireActiveMember } from "./membership.js";
57
58
  error: 'No subscription found'
58
59
  });
59
60
  }
60
- const referer = String((_req_headers_referer = req.headers.referer) != null ? _req_headers_referer : '');
61
- const origin = `https://${req.headers.host}`;
61
+ // The site's own page, never a header's say-so (AGL-3363).
62
+ const returnUrl = siteReturnUrl({
63
+ candidates: [
64
+ String((_req_headers_referer = req.headers.referer) != null ? _req_headers_referer : ''),
65
+ `https://${req.headers.host}`
66
+ ],
67
+ site: await readSiteReturnHost(hostRef),
68
+ requestHost: String((_req_headers_host = req.headers.host) != null ? _req_headers_host : '')
69
+ });
62
70
  const response = await fetch('https://api.stripe.com/v1/billing_portal/sessions', {
63
71
  method: 'POST',
64
72
  headers: {
@@ -67,7 +75,7 @@ import { requireActiveMember } from "./membership.js";
67
75
  },
68
76
  body: new URLSearchParams({
69
77
  customer: String(customerId),
70
- return_url: referer.startsWith('http') ? referer : origin
78
+ return_url: returnUrl
71
79
  }).toString()
72
80
  });
73
81
  const session = await response.json();
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/subscription-portal.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { requireActiveMember } from './membership'\n\n/**\n * Subscriber self-service (AGL-303): opens the Stripe Billing Portal for\n * the signed-in member's storefront subscription (cancel, switch\n * payment method). Member-gated; the portal customer is matched by the\n * member's email through the recorded subscription doc.\n */\nexport const subscriptionPortalHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n if (!process.env.STRIPE_SECRET_KEY) {\n return res.status(501).json({ error: 'Billing is not configured.' })\n }\n const hostId = String(req.body?.hostId ?? '')\n if (!hostId) return res.status(400).json({ error: 'Missing hostId' })\n\n try {\n // Suspension gate (AGL-550): suspended members (AGL-546) cannot\n // open the billing portal — 403'd with the session cookie cleared.\n const auth = await requireActiveMember(req, res, hostId)\n if (!auth) return\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const email = String(auth.member.get('email') ?? '')\n if (!email) return res.status(401).json({ error: 'Not signed in' })\n const subscriptions = await hostRef\n .collection('subscriptions')\n .where('customerEmail', '==', email)\n .limit(1)\n .get()\n const customerId = subscriptions.docs[0]?.get('stripeCustomerId')\n if (!customerId) {\n return res.status(404).json({ error: 'No subscription found' })\n }\n const referer = String(req.headers.referer ?? '')\n const origin = `https://${req.headers.host}`\n const response = await fetch(\n 'https://api.stripe.com/v1/billing_portal/sessions',\n {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n 'Content-Type': 'application/x-www-form-urlencoded',\n },\n body: new URLSearchParams({\n customer: String(customerId),\n return_url: referer.startsWith('http') ? referer : origin,\n }).toString(),\n },\n )\n const session = await response.json()\n if (!response.ok || !session.url) {\n console.error('Portal error', session?.error)\n return res.status(502).json({ error: 'Portal unavailable' })\n }\n return res.status(200).json({ url: session.url })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Portal unavailable' })\n }\n}\n"],"names":["firebaseAdmin","requireActiveMember","subscriptionPortalHandler","req","res","method","status","json","error","process","env","STRIPE_SECRET_KEY","hostId","String","body","auth","subscriptions","firestore","app","hostRef","collection","doc","email","member","get","where","limit","customerId","docs","referer","headers","origin","host","response","fetch","Authorization","URLSearchParams","customer","return_url","startsWith","toString","session","ok","url","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAGD,SAASA,aAAa,QAAQ,2BAA0B;AACxD,SAASC,mBAAmB,QAAQ,kBAAc;AAElD;;;;;CAKC,GACD,OAAO,MAAMC,4BAA8C,OAAOC,KAAKC;;QAO/CD;IANtB,IAAIA,IAAIE,MAAM,KAAK,QAAQ;QACzB,OAAOD,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;IAC5D;IACA,IAAI,CAACC,QAAQC,GAAG,CAACC,iBAAiB,EAAE;QAClC,OAAOP,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA6B;IACpE;IACA,MAAMI,SAASC,gBAAOV,YAAAA,IAAIW,IAAI,qBAARX,UAAUS,MAAM,mBAAI;IAC1C,IAAI,CAACA,QAAQ,OAAOR,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;QAAEC,OAAO;IAAiB;IAEnE,IAAI;YAOmBO,kBAWEZ;YAJJa;QAbnB,gEAAgE;QAChE,mEAAmE;QACnE,MAAMD,OAAO,MAAMd,oBAAoBE,KAAKC,KAAKQ;QACjD,IAAI,CAACG,MAAM;QACX,MAAME,YAAYjB,cAAckB,GAAG,GAAGD,SAAS;QAC/C,MAAME,UAAUF,UAAUG,UAAU,CAAC,SAASC,GAAG,CAACT;QAClD,MAAMU,QAAQT,QAAOE,mBAAAA,KAAKQ,MAAM,CAACC,GAAG,CAAC,oBAAhBT,mBAA4B;QACjD,IAAI,CAACO,OAAO,OAAOlB,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAgB;QACjE,MAAMQ,gBAAgB,MAAMG,QACzBC,UAAU,CAAC,iBACXK,KAAK,CAAC,iBAAiB,MAAMH,OAC7BI,KAAK,CAAC,GACNF,GAAG;QACN,MAAMG,cAAaX,uBAAAA,cAAcY,IAAI,CAAC,EAAE,qBAArBZ,qBAAuBQ,GAAG,CAAC;QAC9C,IAAI,CAACG,YAAY;YACf,OAAOvB,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAwB;QAC/D;QACA,MAAMqB,UAAUhB,QAAOV,uBAAAA,IAAI2B,OAAO,CAACD,OAAO,YAAnB1B,uBAAuB;QAC9C,MAAM4B,SAAS,CAAC,QAAQ,EAAE5B,IAAI2B,OAAO,CAACE,IAAI,EAAE;QAC5C,MAAMC,WAAW,MAAMC,MACrB,qDACA;YACE7B,QAAQ;YACRyB,SAAS;gBACPK,eAAe,CAAC,OAAO,EAAE1B,QAAQC,GAAG,CAACC,iBAAiB,EAAE;gBACxD,gBAAgB;YAClB;YACAG,MAAM,IAAIsB,gBAAgB;gBACxBC,UAAUxB,OAAOc;gBACjBW,YAAYT,QAAQU,UAAU,CAAC,UAAUV,UAAUE;YACrD,GAAGS,QAAQ;QACb;QAEF,MAAMC,UAAU,MAAMR,SAAS1B,IAAI;QACnC,IAAI,CAAC0B,SAASS,EAAE,IAAI,CAACD,QAAQE,GAAG,EAAE;YAChCC,QAAQpC,KAAK,CAAC,gBAAgBiC,2BAAAA,QAASjC,KAAK;YAC5C,OAAOJ,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAqB;QAC5D;QACA,OAAOJ,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEoC,KAAKF,QAAQE,GAAG;QAAC;IACjD,EAAE,OAAOnC,OAAO;QACdoC,QAAQpC,KAAK,CAACA;QACd,OAAOJ,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;IAC5D;AACF,EAAC"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/subscription-portal.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { readSiteReturnHost, siteReturnUrl } from '@aglyn/aglyn/app-utils/site-return-url'\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { requireActiveMember } from './membership'\n\n/**\n * Subscriber self-service (AGL-303): opens the Stripe Billing Portal for\n * the signed-in member's storefront subscription (cancel, switch\n * payment method). Member-gated; the portal customer is matched by the\n * member's email through the recorded subscription doc.\n */\nexport const subscriptionPortalHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n if (!process.env.STRIPE_SECRET_KEY) {\n return res.status(501).json({ error: 'Billing is not configured.' })\n }\n const hostId = String(req.body?.hostId ?? '')\n if (!hostId) return res.status(400).json({ error: 'Missing hostId' })\n\n try {\n // Suspension gate (AGL-550): suspended members (AGL-546) cannot\n // open the billing portal — 403'd with the session cookie cleared.\n const auth = await requireActiveMember(req, res, hostId)\n if (!auth) return\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const email = String(auth.member.get('email') ?? '')\n if (!email) return res.status(401).json({ error: 'Not signed in' })\n const subscriptions = await hostRef\n .collection('subscriptions')\n .where('customerEmail', '==', email)\n .limit(1)\n .get()\n const customerId = subscriptions.docs[0]?.get('stripeCustomerId')\n if (!customerId) {\n return res.status(404).json({ error: 'No subscription found' })\n }\n // The site's own page, never a header's say-so (AGL-3363).\n const returnUrl = siteReturnUrl({\n candidates: [String(req.headers.referer ?? ''), `https://${req.headers.host}`],\n site: await readSiteReturnHost(hostRef),\n requestHost: String(req.headers.host ?? ''),\n })\n const response = await fetch(\n 'https://api.stripe.com/v1/billing_portal/sessions',\n {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n 'Content-Type': 'application/x-www-form-urlencoded',\n },\n body: new URLSearchParams({\n customer: String(customerId),\n return_url: returnUrl,\n }).toString(),\n },\n )\n const session = await response.json()\n if (!response.ok || !session.url) {\n console.error('Portal error', session?.error)\n return res.status(502).json({ error: 'Portal unavailable' })\n }\n return res.status(200).json({ url: session.url })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Portal unavailable' })\n }\n}\n"],"names":["readSiteReturnHost","siteReturnUrl","firebaseAdmin","requireActiveMember","subscriptionPortalHandler","req","res","method","status","json","error","process","env","STRIPE_SECRET_KEY","hostId","String","body","auth","subscriptions","firestore","app","hostRef","collection","doc","email","member","get","where","limit","customerId","docs","returnUrl","candidates","headers","referer","host","site","requestHost","response","fetch","Authorization","URLSearchParams","customer","return_url","toString","session","ok","url","console"],"mappings":"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"}
@@ -14,7 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
- import { firebaseAdmin, notifyHostManagers } from "@aglyn/tenant-data-admin";
17
+ import { describeConfiguredUrlRefusal, fetchConfiguredPublicUrl, firebaseAdmin, flagLookalikeDomain, notifyHostManagers } from "@aglyn/tenant-data-admin";
18
18
  import { isDocumentId } from "@aglyn/tenant-data-admin/server/document-id";
19
19
  import { createHmac } from "crypto";
20
20
  /**
@@ -230,8 +230,11 @@ import { createHmac } from "crypto";
230
230
  const body = String((_data_body = data.body) != null ? _data_body : '');
231
231
  let status;
232
232
  try {
233
- var _ref;
234
- const response = await fetch(url, {
233
+ // A merchant-typed address, called from the platform's servers: https
234
+ // only, public addresses only (pinned), never a lookalike, never a
235
+ // followed redirect (AGL-3363). A bare `fetch` here reached any address a
236
+ // merchant typed, the cloud metadata endpoint included.
237
+ const called = await fetchConfiguredPublicUrl(url, {
235
238
  method: 'POST',
236
239
  headers: _extends({
237
240
  'Content-Type': 'application/json'
@@ -241,13 +244,25 @@ import { createHmac } from "crypto";
241
244
  body,
242
245
  signal: AbortSignal.timeout(SUPPLIER_DELIVERY_TIMEOUT_MS)
243
246
  });
244
- status = Number((_ref = response == null ? void 0 : response.status) != null ? _ref : 0);
245
- if (!(response == null ? void 0 : response.ok)) {
247
+ if (called.ok === false) {
248
+ if (called.refusal === 'lookalike' && called.host) {
249
+ await flagLookalikeDomain({
250
+ kind: 'link',
251
+ hostId,
252
+ orgId: null,
253
+ domain: called.host,
254
+ where: 'a supplier order webhook'
255
+ });
256
+ }
257
+ return recordFailure(firestore, snapshot, data, attempts, now, `the supplier's webhook address was not called: ${describeConfiguredUrlRefusal(called.refusal)}`, undefined);
258
+ }
259
+ status = called.status;
260
+ if (!(status >= 200 && status < 300)) {
246
261
  return recordFailure(firestore, snapshot, data, attempts, now, `supplier answered HTTP ${status}`, status);
247
262
  }
248
263
  } catch (error) {
249
- var _ref1;
250
- return recordFailure(firestore, snapshot, data, attempts, now, String((_ref1 = error == null ? void 0 : error.message) != null ? _ref1 : error), undefined);
264
+ var _ref;
265
+ return recordFailure(firestore, snapshot, data, attempts, now, String((_ref = error == null ? void 0 : error.message) != null ? _ref : error), undefined);
251
266
  }
252
267
  // Delivered rows are DELETED rather than stamped — see the module note: the
253
268
  // body holds the buyer's email and name, and a top-level document is outside
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/supplier-outbox.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { type PluginJobHostGate } from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n notifyHostManagers,\n} from '@aglyn/tenant-data-admin'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport { createHmac } from 'crypto'\n\n/**\n * The durable outbox for the dropship supplier notification (AGL-2473).\n *\n * ## What was wrong\n *\n * `billing-webhook.ts` routed a paid dropship order inside\n * `void (async () => { … })()`, and the last thing that block did was\n * `await fetch(supplier.webhookUrl, …)` — an endpoint the MERCHANT typed into\n * a console field, on a host Aglyn neither runs nor monitors. Vercel freezes\n * the container the instant the response is written, so a supplier that is\n * slow, rate-limiting, or simply down is a supplier that is never told. The\n * order is paid, the buyer is thanked, the merchant sees \"Sent to supplier X\"\n * on the timeline, and NOTHING ANYWHERE records that the POST is still owed —\n * so no later pass can find it and no report can list it. It surfaces when the\n * customer asks where their parcel is.\n *\n * ## Why not just `await` it\n *\n * AGL-2161 fixed the other four fire-and-forget sites in that file and\n * deliberately left this one, because awaiting it is worse rather than better:\n * a supplier taking 30s pushes the handler past Stripe's delivery window,\n * Stripe redelivers, and the redelivery re-enters a fan-out whose only\n * idempotency is one `created` flag upstream. A silently dropped notification\n * would have been traded for a DUPLICATED order. `refund.ts` awaits its own\n * sibling and says so in a comment — correctly, because that sibling only\n * touches our own Firestore.\n *\n * So the line this module draws is not \"fast vs slow\" but **ours vs theirs**.\n * Everything the routing block does to Aglyn's own storage — the callback\n * token, the timeline entry, the supplier email through our own provider — is\n * now AWAITED on the response path, exactly like `refund.ts`. The one call to a\n * stranger's server becomes a row here, and the response no longer waits on it.\n *\n * ## The mechanism\n *\n * A Firestore-backed outbox drained by the plugin-job beat that already runs\n * the two commerce recovery passes (`server.ts`). No new infrastructure: the\n * beat is a Cloud Scheduler function POSTing the tenant runner route, and it\n * has carried bounded scans since AGL-2227.\n *\n * * **Idempotent enqueue.** The id is `{hostId}__{orderId}__{supplierId}` and\n * the write is `create()`, which REFUSES an existing document, so a\n * redelivery cannot reset a retry part-way through its backoff. The AGL-498\n * existence guard upstream already returns before this fan-out, but\n * `reconcile-stock.ts` documents at length why one flag over a dozen effects\n * is not something to lean on.\n * * **Backoff.** 1m, 5m, 15m, 1h, 6h — about seven and a half hours of cover,\n * which is a supplier's overnight deploy but not their week off.\n * * **Dead letter.** After {@link SUPPLIER_DELIVERY_MAX_ATTEMPTS} the row is\n * left as `failed`, the ORDER's own timeline is stamped, and host managers\n * get a notification. The merchant reading the order they were paid for can\n * see that it never reached the supplier — the thing the old code could not\n * tell anyone, because it wrote nothing down.\n *\n * ## Two placement decisions, both deliberate\n *\n * **TOP-LEVEL, not `hosts/{hostId}/supplierDeliveries`.** The host catch-all in\n * the rules file is PERMISSIVE by default — a subcollection nobody names in its\n * three exclusion lists is one every site editor can write from the browser —\n * and the row carries the exact JSON body we sign and POST, so an editor-\n * writable row is an editor-chosen payload delivered under the supplier's own\n * shared secret. A top-level collection matches no rule in\n * `cloud/firebase-firestore.rules` at all and is therefore denied to every\n * client by default, the same footing as `platform/pluginJobs`. It also keeps\n * the drain on an ordinary collection query: a collection-GROUP `where` would\n * need a `COLLECTION_GROUP_ASC` fieldOverride, and AGL-1793 is the record of\n * both commerce crons being silently dead in production for want of exactly\n * that. Neither a rules deploy nor an index deploy is owed for this change.\n *\n * **A delivered row is DELETED, not stamped `delivered`.** The body holds the\n * buyer's email and name, and a top-level document is outside the\n * `hosts/{hostId}` recursive delete an erasure request runs. Retaining the\n * happy path would accumulate third-party PII in a place erasure does not\n * reach, to record something the order's own `routed` timeline entry already\n * says. Dead-lettered rows survive, because those are the ones a human has to\n * act on and the merchant has been told about them.\n */\n\n/** Top-level collection. Matches no rule, so no client may read or write it. */\nexport const SUPPLIER_DELIVERY_COLLECTION = 'supplierDeliveries'\n\n/** Attempts before a delivery is dead-lettered. */\nexport const SUPPLIER_DELIVERY_MAX_ATTEMPTS = 6\n\n/**\n * Delay before the next retry, indexed by attempts ALREADY made.\n *\n * The first entry is 60s and the job beat is also 60s, so a supplier that\n * blipped is retried on the next tick rather than a quarter of an hour later —\n * the difference between a parcel that ships today and one that does not.\n */\nexport const SUPPLIER_DELIVERY_BACKOFF_MS: readonly number[] = [\n 60_000,\n 300_000,\n 900_000,\n 3_600_000,\n 21_600_000,\n]\n\n/**\n * Per-attempt timeout. Nothing user-facing waits on this — it exists so one\n * hung supplier cannot hold the shared job beat open for the whole scan.\n */\nexport const SUPPLIER_DELIVERY_TIMEOUT_MS = 10_000\n\n/** Rows examined per drain. Bounded like every other scan on the beat. */\nexport const SUPPLIER_DELIVERY_SCAN_LIMIT = 100\n\nexport type SupplierDeliveryStatus = 'pending' | 'failed'\n\nexport interface SupplierDeliveryRecord {\n status: SupplierDeliveryStatus\n hostId: string\n orderId: string\n supplierId: string\n supplierName: string\n /** The endpoint as configured when the order was routed, for the record. */\n url: string\n /** The exact JSON the supplier is sent; signed at delivery time. */\n body: string\n attempts: number\n nextAttemptAtMs: number\n createdAtMs: number\n lastError?: string\n lastStatus?: number\n failedAtMs?: number\n}\n\nexport interface SupplierDeliveryScanResult {\n scanned: number\n delivered: number\n retried: number\n deadLettered: number\n /** Retired without delivery because the merchant removed the endpoint. */\n cancelled: number\n /** Left pending, with the retry budget intact, because the site is locked. */\n skippedLocked: number\n}\n\n/** gRPC `Status.ALREADY_EXISTS` — what `create()` on a live path carries. */\nconst GRPC_ALREADY_EXISTS = 6\n\n/**\n * The delay owed after `attempts` failures, clamped to the last step.\n *\n * Clamped rather than extrapolated: the tail entry is already six hours, and a\n * dead letter is what comes after it, not a longer and longer silence.\n */\nexport function supplierDeliveryBackoffMs(attempts: number): number {\n const last = SUPPLIER_DELIVERY_BACKOFF_MS.length - 1\n return SUPPLIER_DELIVERY_BACKOFF_MS[\n Math.max(0, Math.min(attempts - 1, last))\n ]\n}\n\n/**\n * The outbox document id for one host/order/supplier triple.\n *\n * Deterministic, so `create()` is the idempotency. `__` rather than `:` or `/`:\n * a slash would make `.doc()` build a PATH instead of an id (AGL-1771), and the\n * separator has to survive a Stripe session id, which already contains `_`.\n * Firestore reserves ids fully matching `__.*__`; this never is, because all\n * three parts are non-empty and the id neither starts nor ends with `__`.\n */\nexport function supplierDeliveryId(\n hostId: string,\n orderId: string,\n supplierId: string,\n): string {\n return `${hostId}__${orderId}__${supplierId}`\n}\n\n/**\n * Records that a supplier POST is owed. One Firestore write, awaited.\n *\n * Returns `'queued'`, `'exists'` when a row for this triple is already in\n * flight, or `'skipped'`. NEVER THROWS: the caller is a paid order's fulfilment\n * path, and a queue that cannot be written must not take the receipt down with\n * it. The refusal is logged, which is strictly more than the old code managed\n * on the same failure.\n */\nexport async function enqueueSupplierDelivery(options: {\n firestore: FirebaseFirestore.Firestore\n hostId: string\n orderId: string\n supplierId: string\n supplierName: string\n url: string\n body: string\n now?: number\n}): Promise<'queued' | 'exists' | 'skipped'> {\n const { firestore, hostId, orderId, supplierId, supplierName, url, body } =\n options\n const now = options.now ?? Date.now()\n const id = supplierDeliveryId(hostId, orderId, supplierId)\n if (!isDocumentId(id)) {\n console.error('Supplier delivery not queued: unusable id', id)\n return 'skipped'\n }\n const record: SupplierDeliveryRecord = {\n status: 'pending',\n hostId,\n orderId,\n supplierId,\n supplierName,\n url,\n body,\n attempts: 0,\n // Due immediately: the next beat picks it up, at most a minute out.\n nextAttemptAtMs: now,\n createdAtMs: now,\n }\n try {\n await firestore\n .collection(SUPPLIER_DELIVERY_COLLECTION)\n .doc(id)\n .create(record as unknown as FirebaseFirestore.DocumentData)\n return 'queued'\n } catch (error) {\n if ((error as { code?: number })?.code === GRPC_ALREADY_EXISTS) {\n return 'exists'\n }\n console.error('Supplier delivery not queued', hostId, orderId, error)\n return 'skipped'\n }\n}\n\n/**\n * Stamps a routing failure on the order the merchant actually reads.\n *\n * `update()`, not a merge-set, for `recordRedemptionOrphan`'s reason: the order\n * exists — the delivery was queued from its own fulfilment — and a merge would\n * mint an order stub on the one path where it does not.\n */\nasync function stampOrderRoutingFailure(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n orderId: string,\n detail: string,\n): Promise<void> {\n if (!isDocumentId(hostId) || !isDocumentId(orderId)) return\n await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('orders')\n .doc(orderId)\n .update({\n timeline: firebaseAdmin.firestore.FieldValue.arrayUnion({\n atMs: Date.now(),\n event: 'routing-failed',\n detail,\n }),\n })\n .catch(() => undefined)\n}\n\ntype DeliveryOutcome = 'delivered' | 'retried' | 'dead-lettered' | 'cancelled'\n\n/**\n * One delivery attempt.\n *\n * Never throws: the drain is a shared beat, and one merchant's supplier must\n * not abort every other merchant's queue — the `process-restock.ts` lesson,\n * where a single poisoned row killed back-in-stock email platform-wide.\n */\nasync function attemptSupplierDelivery(\n firestore: FirebaseFirestore.Firestore,\n snapshot: FirebaseFirestore.QueryDocumentSnapshot,\n now: number,\n): Promise<DeliveryOutcome> {\n const data = snapshot.data() as SupplierDeliveryRecord\n const hostId = String(data.hostId ?? '')\n const attempts = Number(data.attempts ?? 0) + 1\n\n const retire = async (): Promise<'cancelled'> => {\n await snapshot.ref.delete().catch(() => undefined)\n return 'cancelled'\n }\n if (!isDocumentId(hostId) || !isDocumentId(String(data.supplierId ?? ''))) {\n return retire()\n }\n\n // The endpoint and the secret are read FRESH rather than frozen into the row:\n // a merchant correcting a typo'd URL corrects what is already queued, and the\n // supplier's shared secret is never copied into a second document.\n const supplierSnapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('suppliers')\n .doc(String(data.supplierId))\n .get()\n .catch(() => null)\n // A read that FAILED is not a supplier that is gone. Retrying beats retiring\n // a real delivery on a transient Firestore error — the `reconcile-stock.ts`\n // rule that a failed read is not an absent marker.\n if (!supplierSnapshot) {\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n 'could not read the supplier record',\n undefined,\n )\n }\n const url = String(supplierSnapshot.get('webhookUrl') ?? '')\n if (!url) {\n // Deliberately NOT a dead letter: the merchant removed the endpoint (or the\n // supplier), which is a decision rather than an outage. Alarming them about\n // a delivery they cancelled is how an alert stops being read.\n return retire()\n }\n const secret = String(supplierSnapshot.get('webhookSecret') ?? '')\n const body = String(data.body ?? '')\n\n let status: number\n try {\n const response = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n // NO EMPTY-KEY HMAC (AGL-2455). A signature computed under `''` is one\n // any party can compute, and a supplier who also left their secret\n // unset would verify it and believe the delivery was authenticated. An\n // absent header fails their check closed; a forgeable one passes it.\n ...(secret\n ? {\n 'x-aglyn-signature': createHmac('sha256', secret)\n .update(body)\n .digest('hex'),\n }\n : {}),\n },\n body,\n signal: AbortSignal.timeout(SUPPLIER_DELIVERY_TIMEOUT_MS),\n })\n status = Number(response?.status ?? 0)\n if (!response?.ok) {\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n `supplier answered HTTP ${status}`,\n status,\n )\n }\n } catch (error) {\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n String((error as Error)?.message ?? error),\n undefined,\n )\n }\n\n // Delivered rows are DELETED rather than stamped — see the module note: the\n // body holds the buyer's email and name, and a top-level document is outside\n // the recursive delete an erasure request runs over `hosts/{hostId}`.\n await snapshot.ref.delete().catch(() => undefined)\n return 'delivered'\n}\n\n/** Books one failure: another retry, or the dead letter and its two alarms. */\nasync function recordFailure(\n firestore: FirebaseFirestore.Firestore,\n snapshot: FirebaseFirestore.QueryDocumentSnapshot,\n data: SupplierDeliveryRecord,\n attempts: number,\n now: number,\n reason: string,\n status: number | undefined,\n): Promise<'retried' | 'dead-lettered'> {\n const exhausted = attempts >= SUPPLIER_DELIVERY_MAX_ATTEMPTS\n await snapshot.ref\n .update({\n status: exhausted ? 'failed' : 'pending',\n attempts,\n lastError: reason,\n ...(status ? { lastStatus: status } : {}),\n ...(exhausted\n ? { failedAtMs: now }\n : { nextAttemptAtMs: now + supplierDeliveryBackoffMs(attempts) }),\n })\n .catch(() => undefined)\n if (!exhausted) return 'retried'\n\n const hostId = String(data.hostId ?? '')\n const orderId = String(data.orderId ?? '')\n const supplierName = String(data.supplierName ?? '') || 'the supplier'\n await stampOrderRoutingFailure(\n firestore,\n hostId,\n orderId,\n `Could not reach ${supplierName} after ${attempts} attempts ` +\n `(${reason}). This order was never routed.`,\n )\n // The bell as well as the timeline: nobody opens an order they have no\n // reason to suspect.\n await notifyHostManagers(hostId, {\n type: 'content.order',\n title: `Order not routed to ${supplierName}`,\n body: `Order ${orderId} could not be sent after ${attempts} attempts.`,\n link: `/${hostId}/products`,\n }).catch(() => undefined)\n return 'dead-lettered'\n}\n\n/**\n * Drains the supplier outbox. Bounded, and safe to run twice.\n *\n * `nextAttemptAtMs` is filtered IN MEMORY rather than added to the query, which\n * would need a composite index for one field's worth of selectivity. The cost\n * is that a row still inside its backoff occupies one of the\n * {@link SUPPLIER_DELIVERY_SCAN_LIMIT} slots; with at most\n * {@link SUPPLIER_DELIVERY_MAX_ATTEMPTS} attempts per row the queue always\n * drains rather than growing a permanent head.\n */\nexport async function scanSupplierDeliveries(\n /** The lockdown gate, injected by the caller (AGL-2495). Not optional. */\n gate: PluginJobHostGate,\n now: number = Date.now(),\n): Promise<SupplierDeliveryScanResult> {\n const firestore = firebaseAdmin.app().firestore()\n const pending = await firestore\n .collection(SUPPLIER_DELIVERY_COLLECTION)\n .where('status', '==', 'pending')\n .limit(SUPPLIER_DELIVERY_SCAN_LIMIT)\n .get()\n const result: SupplierDeliveryScanResult = {\n scanned: pending.size,\n delivered: 0,\n retried: 0,\n deadLettered: 0,\n cancelled: 0,\n skippedLocked: 0,\n }\n for (const snapshot of pending.docs) {\n const data = snapshot.data() as SupplierDeliveryRecord\n // LOCKDOWN (AGL-2495). The row is a TOP-LEVEL document carrying its own\n // `hostId` rather than living under `hosts/{hostId}`, so the host is read\n // from the record instead of the parent chain — the one job of the six\n // whose subject is not in its path.\n //\n // An outbound POST to the merchant's supplier is this platform acting in\n // a suspended merchant's name against a third party, which is precisely\n // what a takedown is for. The attempt also mutates the row (`attempts`,\n // `nextAttemptAtMs`, or the dead-letter) and can notify the host's\n // managers.\n //\n // SKIPPED, NOT DROPPED, and the strongest case of the six: the row stays\n // `pending` with its `attempts` count untouched, so a long lock cannot\n // burn a paid order's retry budget and dead-letter it for being locked.\n // The queue drains from where it stood the minute the lock lifts.\n if (await gate.isLocked(String(data?.hostId ?? ''))) {\n result.skippedLocked += 1\n continue\n }\n if (Number(data?.nextAttemptAtMs ?? 0) > now) continue\n const outcome = await attemptSupplierDelivery(\n firestore,\n snapshot,\n now,\n ).catch((error) => {\n console.error(\n 'Supplier delivery attempt failed',\n snapshot.ref.path,\n error,\n )\n return null\n })\n if (outcome === 'delivered') result.delivered += 1\n else if (outcome === 'retried') result.retried += 1\n else if (outcome === 'dead-lettered') result.deadLettered += 1\n else if (outcome === 'cancelled') result.cancelled += 1\n }\n return result\n}\n"],"names":["firebaseAdmin","notifyHostManagers","isDocumentId","createHmac","SUPPLIER_DELIVERY_COLLECTION","SUPPLIER_DELIVERY_MAX_ATTEMPTS","SUPPLIER_DELIVERY_BACKOFF_MS","SUPPLIER_DELIVERY_TIMEOUT_MS","SUPPLIER_DELIVERY_SCAN_LIMIT","GRPC_ALREADY_EXISTS","supplierDeliveryBackoffMs","attempts","last","length","Math","max","min","supplierDeliveryId","hostId","orderId","supplierId","enqueueSupplierDelivery","options","firestore","supplierName","url","body","now","Date","id","console","error","record","status","nextAttemptAtMs","createdAtMs","collection","doc","create","code","stampOrderRoutingFailure","detail","update","timeline","FieldValue","arrayUnion","atMs","event","catch","undefined","attemptSupplierDelivery","snapshot","data","supplierSnapshot","String","Number","retire","ref","delete","get","recordFailure","secret","response","fetch","method","headers","digest","signal","AbortSignal","timeout","ok","message","reason","exhausted","lastError","lastStatus","failedAtMs","type","title","link","scanSupplierDeliveries","gate","app","pending","where","limit","result","scanned","size","delivered","retried","deadLettered","cancelled","skippedLocked","docs","isLocked","outcome","path"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/supplier-outbox.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { type PluginJobHostGate } from '@aglyn/aglyn/server'\nimport {\n describeConfiguredUrlRefusal,\n fetchConfiguredPublicUrl,\n firebaseAdmin,\n flagLookalikeDomain,\n notifyHostManagers,\n} from '@aglyn/tenant-data-admin'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport { createHmac } from 'crypto'\n\n/**\n * The durable outbox for the dropship supplier notification (AGL-2473).\n *\n * ## What was wrong\n *\n * `billing-webhook.ts` routed a paid dropship order inside\n * `void (async () => { … })()`, and the last thing that block did was\n * `await fetch(supplier.webhookUrl, …)` — an endpoint the MERCHANT typed into\n * a console field, on a host Aglyn neither runs nor monitors. Vercel freezes\n * the container the instant the response is written, so a supplier that is\n * slow, rate-limiting, or simply down is a supplier that is never told. The\n * order is paid, the buyer is thanked, the merchant sees \"Sent to supplier X\"\n * on the timeline, and NOTHING ANYWHERE records that the POST is still owed —\n * so no later pass can find it and no report can list it. It surfaces when the\n * customer asks where their parcel is.\n *\n * ## Why not just `await` it\n *\n * AGL-2161 fixed the other four fire-and-forget sites in that file and\n * deliberately left this one, because awaiting it is worse rather than better:\n * a supplier taking 30s pushes the handler past Stripe's delivery window,\n * Stripe redelivers, and the redelivery re-enters a fan-out whose only\n * idempotency is one `created` flag upstream. A silently dropped notification\n * would have been traded for a DUPLICATED order. `refund.ts` awaits its own\n * sibling and says so in a comment — correctly, because that sibling only\n * touches our own Firestore.\n *\n * So the line this module draws is not \"fast vs slow\" but **ours vs theirs**.\n * Everything the routing block does to Aglyn's own storage — the callback\n * token, the timeline entry, the supplier email through our own provider — is\n * now AWAITED on the response path, exactly like `refund.ts`. The one call to a\n * stranger's server becomes a row here, and the response no longer waits on it.\n *\n * ## The mechanism\n *\n * A Firestore-backed outbox drained by the plugin-job beat that already runs\n * the two commerce recovery passes (`server.ts`). No new infrastructure: the\n * beat is a Cloud Scheduler function POSTing the tenant runner route, and it\n * has carried bounded scans since AGL-2227.\n *\n * * **Idempotent enqueue.** The id is `{hostId}__{orderId}__{supplierId}` and\n * the write is `create()`, which REFUSES an existing document, so a\n * redelivery cannot reset a retry part-way through its backoff. The AGL-498\n * existence guard upstream already returns before this fan-out, but\n * `reconcile-stock.ts` documents at length why one flag over a dozen effects\n * is not something to lean on.\n * * **Backoff.** 1m, 5m, 15m, 1h, 6h — about seven and a half hours of cover,\n * which is a supplier's overnight deploy but not their week off.\n * * **Dead letter.** After {@link SUPPLIER_DELIVERY_MAX_ATTEMPTS} the row is\n * left as `failed`, the ORDER's own timeline is stamped, and host managers\n * get a notification. The merchant reading the order they were paid for can\n * see that it never reached the supplier — the thing the old code could not\n * tell anyone, because it wrote nothing down.\n *\n * ## Two placement decisions, both deliberate\n *\n * **TOP-LEVEL, not `hosts/{hostId}/supplierDeliveries`.** The host catch-all in\n * the rules file is PERMISSIVE by default — a subcollection nobody names in its\n * three exclusion lists is one every site editor can write from the browser —\n * and the row carries the exact JSON body we sign and POST, so an editor-\n * writable row is an editor-chosen payload delivered under the supplier's own\n * shared secret. A top-level collection matches no rule in\n * `cloud/firebase-firestore.rules` at all and is therefore denied to every\n * client by default, the same footing as `platform/pluginJobs`. It also keeps\n * the drain on an ordinary collection query: a collection-GROUP `where` would\n * need a `COLLECTION_GROUP_ASC` fieldOverride, and AGL-1793 is the record of\n * both commerce crons being silently dead in production for want of exactly\n * that. Neither a rules deploy nor an index deploy is owed for this change.\n *\n * **A delivered row is DELETED, not stamped `delivered`.** The body holds the\n * buyer's email and name, and a top-level document is outside the\n * `hosts/{hostId}` recursive delete an erasure request runs. Retaining the\n * happy path would accumulate third-party PII in a place erasure does not\n * reach, to record something the order's own `routed` timeline entry already\n * says. Dead-lettered rows survive, because those are the ones a human has to\n * act on and the merchant has been told about them.\n */\n\n/** Top-level collection. Matches no rule, so no client may read or write it. */\nexport const SUPPLIER_DELIVERY_COLLECTION = 'supplierDeliveries'\n\n/** Attempts before a delivery is dead-lettered. */\nexport const SUPPLIER_DELIVERY_MAX_ATTEMPTS = 6\n\n/**\n * Delay before the next retry, indexed by attempts ALREADY made.\n *\n * The first entry is 60s and the job beat is also 60s, so a supplier that\n * blipped is retried on the next tick rather than a quarter of an hour later —\n * the difference between a parcel that ships today and one that does not.\n */\nexport const SUPPLIER_DELIVERY_BACKOFF_MS: readonly number[] = [\n 60_000,\n 300_000,\n 900_000,\n 3_600_000,\n 21_600_000,\n]\n\n/**\n * Per-attempt timeout. Nothing user-facing waits on this — it exists so one\n * hung supplier cannot hold the shared job beat open for the whole scan.\n */\nexport const SUPPLIER_DELIVERY_TIMEOUT_MS = 10_000\n\n/** Rows examined per drain. Bounded like every other scan on the beat. */\nexport const SUPPLIER_DELIVERY_SCAN_LIMIT = 100\n\nexport type SupplierDeliveryStatus = 'pending' | 'failed'\n\nexport interface SupplierDeliveryRecord {\n status: SupplierDeliveryStatus\n hostId: string\n orderId: string\n supplierId: string\n supplierName: string\n /** The endpoint as configured when the order was routed, for the record. */\n url: string\n /** The exact JSON the supplier is sent; signed at delivery time. */\n body: string\n attempts: number\n nextAttemptAtMs: number\n createdAtMs: number\n lastError?: string\n lastStatus?: number\n failedAtMs?: number\n}\n\nexport interface SupplierDeliveryScanResult {\n scanned: number\n delivered: number\n retried: number\n deadLettered: number\n /** Retired without delivery because the merchant removed the endpoint. */\n cancelled: number\n /** Left pending, with the retry budget intact, because the site is locked. */\n skippedLocked: number\n}\n\n/** gRPC `Status.ALREADY_EXISTS` — what `create()` on a live path carries. */\nconst GRPC_ALREADY_EXISTS = 6\n\n/**\n * The delay owed after `attempts` failures, clamped to the last step.\n *\n * Clamped rather than extrapolated: the tail entry is already six hours, and a\n * dead letter is what comes after it, not a longer and longer silence.\n */\nexport function supplierDeliveryBackoffMs(attempts: number): number {\n const last = SUPPLIER_DELIVERY_BACKOFF_MS.length - 1\n return SUPPLIER_DELIVERY_BACKOFF_MS[\n Math.max(0, Math.min(attempts - 1, last))\n ]\n}\n\n/**\n * The outbox document id for one host/order/supplier triple.\n *\n * Deterministic, so `create()` is the idempotency. `__` rather than `:` or `/`:\n * a slash would make `.doc()` build a PATH instead of an id (AGL-1771), and the\n * separator has to survive a Stripe session id, which already contains `_`.\n * Firestore reserves ids fully matching `__.*__`; this never is, because all\n * three parts are non-empty and the id neither starts nor ends with `__`.\n */\nexport function supplierDeliveryId(\n hostId: string,\n orderId: string,\n supplierId: string,\n): string {\n return `${hostId}__${orderId}__${supplierId}`\n}\n\n/**\n * Records that a supplier POST is owed. One Firestore write, awaited.\n *\n * Returns `'queued'`, `'exists'` when a row for this triple is already in\n * flight, or `'skipped'`. NEVER THROWS: the caller is a paid order's fulfilment\n * path, and a queue that cannot be written must not take the receipt down with\n * it. The refusal is logged, which is strictly more than the old code managed\n * on the same failure.\n */\nexport async function enqueueSupplierDelivery(options: {\n firestore: FirebaseFirestore.Firestore\n hostId: string\n orderId: string\n supplierId: string\n supplierName: string\n url: string\n body: string\n now?: number\n}): Promise<'queued' | 'exists' | 'skipped'> {\n const { firestore, hostId, orderId, supplierId, supplierName, url, body } =\n options\n const now = options.now ?? Date.now()\n const id = supplierDeliveryId(hostId, orderId, supplierId)\n if (!isDocumentId(id)) {\n console.error('Supplier delivery not queued: unusable id', id)\n return 'skipped'\n }\n const record: SupplierDeliveryRecord = {\n status: 'pending',\n hostId,\n orderId,\n supplierId,\n supplierName,\n url,\n body,\n attempts: 0,\n // Due immediately: the next beat picks it up, at most a minute out.\n nextAttemptAtMs: now,\n createdAtMs: now,\n }\n try {\n await firestore\n .collection(SUPPLIER_DELIVERY_COLLECTION)\n .doc(id)\n .create(record as unknown as FirebaseFirestore.DocumentData)\n return 'queued'\n } catch (error) {\n if ((error as { code?: number })?.code === GRPC_ALREADY_EXISTS) {\n return 'exists'\n }\n console.error('Supplier delivery not queued', hostId, orderId, error)\n return 'skipped'\n }\n}\n\n/**\n * Stamps a routing failure on the order the merchant actually reads.\n *\n * `update()`, not a merge-set, for `recordRedemptionOrphan`'s reason: the order\n * exists — the delivery was queued from its own fulfilment — and a merge would\n * mint an order stub on the one path where it does not.\n */\nasync function stampOrderRoutingFailure(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n orderId: string,\n detail: string,\n): Promise<void> {\n if (!isDocumentId(hostId) || !isDocumentId(orderId)) return\n await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('orders')\n .doc(orderId)\n .update({\n timeline: firebaseAdmin.firestore.FieldValue.arrayUnion({\n atMs: Date.now(),\n event: 'routing-failed',\n detail,\n }),\n })\n .catch(() => undefined)\n}\n\ntype DeliveryOutcome = 'delivered' | 'retried' | 'dead-lettered' | 'cancelled'\n\n/**\n * One delivery attempt.\n *\n * Never throws: the drain is a shared beat, and one merchant's supplier must\n * not abort every other merchant's queue — the `process-restock.ts` lesson,\n * where a single poisoned row killed back-in-stock email platform-wide.\n */\nasync function attemptSupplierDelivery(\n firestore: FirebaseFirestore.Firestore,\n snapshot: FirebaseFirestore.QueryDocumentSnapshot,\n now: number,\n): Promise<DeliveryOutcome> {\n const data = snapshot.data() as SupplierDeliveryRecord\n const hostId = String(data.hostId ?? '')\n const attempts = Number(data.attempts ?? 0) + 1\n\n const retire = async (): Promise<'cancelled'> => {\n await snapshot.ref.delete().catch(() => undefined)\n return 'cancelled'\n }\n if (!isDocumentId(hostId) || !isDocumentId(String(data.supplierId ?? ''))) {\n return retire()\n }\n\n // The endpoint and the secret are read FRESH rather than frozen into the row:\n // a merchant correcting a typo'd URL corrects what is already queued, and the\n // supplier's shared secret is never copied into a second document.\n const supplierSnapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('suppliers')\n .doc(String(data.supplierId))\n .get()\n .catch(() => null)\n // A read that FAILED is not a supplier that is gone. Retrying beats retiring\n // a real delivery on a transient Firestore error — the `reconcile-stock.ts`\n // rule that a failed read is not an absent marker.\n if (!supplierSnapshot) {\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n 'could not read the supplier record',\n undefined,\n )\n }\n const url = String(supplierSnapshot.get('webhookUrl') ?? '')\n if (!url) {\n // Deliberately NOT a dead letter: the merchant removed the endpoint (or the\n // supplier), which is a decision rather than an outage. Alarming them about\n // a delivery they cancelled is how an alert stops being read.\n return retire()\n }\n const secret = String(supplierSnapshot.get('webhookSecret') ?? '')\n const body = String(data.body ?? '')\n\n let status: number\n try {\n // A merchant-typed address, called from the platform's servers: https\n // only, public addresses only (pinned), never a lookalike, never a\n // followed redirect (AGL-3363). A bare `fetch` here reached any address a\n // merchant typed, the cloud metadata endpoint included.\n const called = await fetchConfiguredPublicUrl(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n // NO EMPTY-KEY HMAC (AGL-2455). A signature computed under `''` is one\n // any party can compute, and a supplier who also left their secret\n // unset would verify it and believe the delivery was authenticated. An\n // absent header fails their check closed; a forgeable one passes it.\n ...(secret\n ? {\n 'x-aglyn-signature': createHmac('sha256', secret)\n .update(body)\n .digest('hex'),\n }\n : {}),\n },\n body,\n signal: AbortSignal.timeout(SUPPLIER_DELIVERY_TIMEOUT_MS),\n })\n if (called.ok === false) {\n if (called.refusal === 'lookalike' && called.host) {\n await flagLookalikeDomain({\n kind: 'link',\n hostId,\n orgId: null,\n domain: called.host,\n where: 'a supplier order webhook',\n })\n }\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n `the supplier's webhook address was not called: ${describeConfiguredUrlRefusal(called.refusal)}`,\n undefined,\n )\n }\n status = called.status\n if (!(status >= 200 && status < 300)) {\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n `supplier answered HTTP ${status}`,\n status,\n )\n }\n } catch (error) {\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n String((error as Error)?.message ?? error),\n undefined,\n )\n }\n\n // Delivered rows are DELETED rather than stamped — see the module note: the\n // body holds the buyer's email and name, and a top-level document is outside\n // the recursive delete an erasure request runs over `hosts/{hostId}`.\n await snapshot.ref.delete().catch(() => undefined)\n return 'delivered'\n}\n\n/** Books one failure: another retry, or the dead letter and its two alarms. */\nasync function recordFailure(\n firestore: FirebaseFirestore.Firestore,\n snapshot: FirebaseFirestore.QueryDocumentSnapshot,\n data: SupplierDeliveryRecord,\n attempts: number,\n now: number,\n reason: string,\n status: number | undefined,\n): Promise<'retried' | 'dead-lettered'> {\n const exhausted = attempts >= SUPPLIER_DELIVERY_MAX_ATTEMPTS\n await snapshot.ref\n .update({\n status: exhausted ? 'failed' : 'pending',\n attempts,\n lastError: reason,\n ...(status ? { lastStatus: status } : {}),\n ...(exhausted\n ? { failedAtMs: now }\n : { nextAttemptAtMs: now + supplierDeliveryBackoffMs(attempts) }),\n })\n .catch(() => undefined)\n if (!exhausted) return 'retried'\n\n const hostId = String(data.hostId ?? '')\n const orderId = String(data.orderId ?? '')\n const supplierName = String(data.supplierName ?? '') || 'the supplier'\n await stampOrderRoutingFailure(\n firestore,\n hostId,\n orderId,\n `Could not reach ${supplierName} after ${attempts} attempts ` +\n `(${reason}). This order was never routed.`,\n )\n // The bell as well as the timeline: nobody opens an order they have no\n // reason to suspect.\n await notifyHostManagers(hostId, {\n type: 'content.order',\n title: `Order not routed to ${supplierName}`,\n body: `Order ${orderId} could not be sent after ${attempts} attempts.`,\n link: `/${hostId}/products`,\n }).catch(() => undefined)\n return 'dead-lettered'\n}\n\n/**\n * Drains the supplier outbox. Bounded, and safe to run twice.\n *\n * `nextAttemptAtMs` is filtered IN MEMORY rather than added to the query, which\n * would need a composite index for one field's worth of selectivity. The cost\n * is that a row still inside its backoff occupies one of the\n * {@link SUPPLIER_DELIVERY_SCAN_LIMIT} slots; with at most\n * {@link SUPPLIER_DELIVERY_MAX_ATTEMPTS} attempts per row the queue always\n * drains rather than growing a permanent head.\n */\nexport async function scanSupplierDeliveries(\n /** The lockdown gate, injected by the caller (AGL-2495). Not optional. */\n gate: PluginJobHostGate,\n now: number = Date.now(),\n): Promise<SupplierDeliveryScanResult> {\n const firestore = firebaseAdmin.app().firestore()\n const pending = await firestore\n .collection(SUPPLIER_DELIVERY_COLLECTION)\n .where('status', '==', 'pending')\n .limit(SUPPLIER_DELIVERY_SCAN_LIMIT)\n .get()\n const result: SupplierDeliveryScanResult = {\n scanned: pending.size,\n delivered: 0,\n retried: 0,\n deadLettered: 0,\n cancelled: 0,\n skippedLocked: 0,\n }\n for (const snapshot of pending.docs) {\n const data = snapshot.data() as SupplierDeliveryRecord\n // LOCKDOWN (AGL-2495). The row is a TOP-LEVEL document carrying its own\n // `hostId` rather than living under `hosts/{hostId}`, so the host is read\n // from the record instead of the parent chain — the one job of the six\n // whose subject is not in its path.\n //\n // An outbound POST to the merchant's supplier is this platform acting in\n // a suspended merchant's name against a third party, which is precisely\n // what a takedown is for. The attempt also mutates the row (`attempts`,\n // `nextAttemptAtMs`, or the dead-letter) and can notify the host's\n // managers.\n //\n // SKIPPED, NOT DROPPED, and the strongest case of the six: the row stays\n // `pending` with its `attempts` count untouched, so a long lock cannot\n // burn a paid order's retry budget and dead-letter it for being locked.\n // The queue drains from where it stood the minute the lock lifts.\n if (await gate.isLocked(String(data?.hostId ?? ''))) {\n result.skippedLocked += 1\n continue\n }\n if (Number(data?.nextAttemptAtMs ?? 0) > now) continue\n const outcome = await attemptSupplierDelivery(\n firestore,\n snapshot,\n now,\n ).catch((error) => {\n console.error(\n 'Supplier delivery attempt failed',\n snapshot.ref.path,\n error,\n )\n return null\n })\n if (outcome === 'delivered') result.delivered += 1\n else if (outcome === 'retried') result.retried += 1\n else if (outcome === 'dead-lettered') result.deadLettered += 1\n else if (outcome === 'cancelled') result.cancelled += 1\n }\n return result\n}\n"],"names":["describeConfiguredUrlRefusal","fetchConfiguredPublicUrl","firebaseAdmin","flagLookalikeDomain","notifyHostManagers","isDocumentId","createHmac","SUPPLIER_DELIVERY_COLLECTION","SUPPLIER_DELIVERY_MAX_ATTEMPTS","SUPPLIER_DELIVERY_BACKOFF_MS","SUPPLIER_DELIVERY_TIMEOUT_MS","SUPPLIER_DELIVERY_SCAN_LIMIT","GRPC_ALREADY_EXISTS","supplierDeliveryBackoffMs","attempts","last","length","Math","max","min","supplierDeliveryId","hostId","orderId","supplierId","enqueueSupplierDelivery","options","firestore","supplierName","url","body","now","Date","id","console","error","record","status","nextAttemptAtMs","createdAtMs","collection","doc","create","code","stampOrderRoutingFailure","detail","update","timeline","FieldValue","arrayUnion","atMs","event","catch","undefined","attemptSupplierDelivery","snapshot","data","supplierSnapshot","String","Number","retire","ref","delete","get","recordFailure","secret","called","method","headers","digest","signal","AbortSignal","timeout","ok","refusal","host","kind","orgId","domain","where","message","reason","exhausted","lastError","lastStatus","failedAtMs","type","title","link","scanSupplierDeliveries","gate","app","pending","limit","result","scanned","size","delivered","retried","deadLettered","cancelled","skippedLocked","docs","isLocked","outcome","path"],"mappings":"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"}