@aglyn/aglyn 1.0.0-beta.199 → 1.0.0-beta.201

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 (84) hide show
  1. package/package.json +11 -11
  2. package/src/lib/app-utils/activity-search.d.ts +39 -0
  3. package/src/lib/app-utils/activity-search.js +85 -0
  4. package/src/lib/app-utils/activity-search.js.map +1 -0
  5. package/src/lib/app-utils/admin-audit-index.d.ts +104 -0
  6. package/src/lib/app-utils/admin-audit-index.js +177 -0
  7. package/src/lib/app-utils/admin-audit-index.js.map +1 -0
  8. package/src/lib/app-utils/artifact-list-keys.d.ts +51 -0
  9. package/src/lib/app-utils/artifact-list-keys.js +154 -0
  10. package/src/lib/app-utils/artifact-list-keys.js.map +1 -0
  11. package/src/lib/app-utils/content-query-fields.d.ts +69 -0
  12. package/src/lib/app-utils/content-query-fields.js +79 -0
  13. package/src/lib/app-utils/content-query-fields.js.map +1 -0
  14. package/src/lib/app-utils/crm.d.ts +170 -1
  15. package/src/lib/app-utils/crm.js +414 -1
  16. package/src/lib/app-utils/crm.js.map +1 -1
  17. package/src/lib/app-utils/dataset-models.d.ts +75 -24
  18. package/src/lib/app-utils/dataset-models.js +107 -40
  19. package/src/lib/app-utils/dataset-models.js.map +1 -1
  20. package/src/lib/app-utils/docs-help.generated.d.ts +4 -4
  21. package/src/lib/app-utils/docs-help.generated.js +10 -0
  22. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  23. package/src/lib/app-utils/docs-index.generated.js +89 -75
  24. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  25. package/src/lib/app-utils/email-search.d.ts +36 -0
  26. package/src/lib/app-utils/email-search.js +53 -0
  27. package/src/lib/app-utils/email-search.js.map +1 -0
  28. package/src/lib/app-utils/email-state.d.ts +7 -3
  29. package/src/lib/app-utils/email-state.js +22 -6
  30. package/src/lib/app-utils/email-state.js.map +1 -1
  31. package/src/lib/app-utils/forms.d.ts +178 -6
  32. package/src/lib/app-utils/forms.js +197 -0
  33. package/src/lib/app-utils/forms.js.map +1 -1
  34. package/src/lib/app-utils/health-report.d.ts +3 -3
  35. package/src/lib/app-utils/health-report.js +9 -9
  36. package/src/lib/app-utils/health-report.js.map +1 -1
  37. package/src/lib/app-utils/host-naming.d.ts +12 -7
  38. package/src/lib/app-utils/host-naming.js +18 -3
  39. package/src/lib/app-utils/host-naming.js.map +1 -1
  40. package/src/lib/app-utils/lockdown.d.ts +19 -0
  41. package/src/lib/app-utils/lockdown.js +21 -0
  42. package/src/lib/app-utils/lockdown.js.map +1 -1
  43. package/src/lib/app-utils/marketplace-listing-visibility.d.ts +7 -6
  44. package/src/lib/app-utils/marketplace-listing-visibility.js +12 -10
  45. package/src/lib/app-utils/marketplace-listing-visibility.js.map +1 -1
  46. package/src/lib/app-utils/media-embedded-fields.d.ts +40 -0
  47. package/src/lib/app-utils/media-embedded-fields.js +77 -0
  48. package/src/lib/app-utils/media-embedded-fields.js.map +1 -1
  49. package/src/lib/app-utils/media-metadata.d.ts +95 -0
  50. package/src/lib/app-utils/media-metadata.js +94 -0
  51. package/src/lib/app-utils/media-metadata.js.map +1 -1
  52. package/src/lib/app-utils/message-search.d.ts +40 -0
  53. package/src/lib/app-utils/message-search.js +168 -0
  54. package/src/lib/app-utils/message-search.js.map +1 -0
  55. package/src/lib/app-utils/name-search.d.ts +83 -0
  56. package/src/lib/app-utils/name-search.js +97 -0
  57. package/src/lib/app-utils/name-search.js.map +1 -1
  58. package/src/lib/app-utils/payment-risk.d.ts +75 -0
  59. package/src/lib/app-utils/payment-risk.js +139 -0
  60. package/src/lib/app-utils/payment-risk.js.map +1 -0
  61. package/src/lib/app-utils/plugin-api-request-host.d.ts +29 -0
  62. package/src/lib/app-utils/plugin-api-request-host.js +90 -0
  63. package/src/lib/app-utils/plugin-api-request-host.js.map +1 -0
  64. package/src/lib/app-utils/plugin-bundle-checks.d.ts +3 -2
  65. package/src/lib/app-utils/plugin-bundle-checks.js +76 -5
  66. package/src/lib/app-utils/plugin-bundle-checks.js.map +1 -1
  67. package/src/lib/app-utils/server.d.ts +2 -0
  68. package/src/lib/app-utils/server.js +2 -0
  69. package/src/lib/app-utils/server.js.map +1 -1
  70. package/src/lib/app-utils/webhook-delivery.js +4 -1
  71. package/src/lib/app-utils/webhook-delivery.js.map +1 -1
  72. package/src/lib/plugin-manager/billing-webhook-hooks.d.ts +9 -0
  73. package/src/lib/plugin-manager/billing-webhook-hooks.js +5 -1
  74. package/src/lib/plugin-manager/billing-webhook-hooks.js.map +1 -1
  75. package/src/lib/plugin-manager/plugin-activity-actions.d.ts +9 -1
  76. package/src/lib/plugin-manager/plugin-activity-actions.js +11 -1
  77. package/src/lib/plugin-manager/plugin-activity-actions.js.map +1 -1
  78. package/src/lib/plugin-manager/plugin-contact-capture.d.ts +8 -0
  79. package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
  80. package/src/lib/plugin-manager/plugin-events.d.ts +22 -0
  81. package/src/lib/plugin-manager/plugin-events.js.map +1 -1
  82. package/src/lib/plugin-manager/plugin-jobs.d.ts +8 -4
  83. package/src/lib/plugin-manager/plugin-jobs.js +8 -4
  84. package/src/lib/plugin-manager/plugin-jobs.js.map +1 -1
@@ -141,6 +141,35 @@
141
141
  nameReversed: nameSearchReversed(name)
142
142
  };
143
143
  }
144
+ /**
145
+ * The search fields a document NAMED BY `displayName` carries (AGL-3321):
146
+ * the site artifacts (screens of every kind, layouts, reusable components,
147
+ * templates) and the marketplace listings.
148
+ *
149
+ * `nameSearchFields` is for documents whose name IS `name`. These documents
150
+ * name themselves `displayName`, and have since before search existed, so
151
+ * the keys ride beside it under the same three names every list declares —
152
+ * `lowerPath: 'nameLower'`, `tokensPath: 'nameTokens'`,
153
+ * `reversedPath: 'nameReversed'` — and one list query grammar reads them all.
154
+ *
155
+ * `displayName` is NOT returned. A create that carries no name must not gain
156
+ * an empty one: readers fall back with `displayName ?? id`, which an empty
157
+ * string defeats. But the KEYS are always returned, empty for a missing name,
158
+ * so a create stamps them unconditionally — an `orderBy('nameLower')` drops
159
+ * any document that lacks the field, and a document created without a name
160
+ * is still one a list must reach.
161
+ *
162
+ * ⚠️ Spread at every write that sets `displayName`, and at every create —
163
+ * and nowhere else: a partial update that stamped keys for a name it did not
164
+ * write would make the document unfindable by the name it still shows.
165
+ */ export function displayNameSearchFields(displayName) {
166
+ const name = typeof displayName === 'string' ? displayName : '';
167
+ return {
168
+ nameLower: nameSearchKey(name),
169
+ nameTokens: nameSearchTokens(name),
170
+ nameReversed: nameSearchReversed(name)
171
+ };
172
+ }
144
173
  /**
145
174
  * The normalizers a list query plan (`planListQuery` in
146
175
  * `@aglyn/shared-ui-jsx/const/list-query-plan`) turns typed values into keys
@@ -152,5 +181,73 @@
152
181
  reversed: (value)=>nameSearchReversed(value),
153
182
  maxPrefix: NAME_TOKEN_MAX_PREFIX
154
183
  };
184
+ /**
185
+ * What joins a scope token to a search prefix in a SCOPED search token
186
+ * (AGL-3321): `host:abc~acm`.
187
+ *
188
+ * A list under a scope clause (`visibleTo array-contains-any [...]`) has
189
+ * spent Firestore's one array clause on the scope, so its search cannot be a
190
+ * second `array-contains`. The list query plan folds the typed word INTO the scope
191
+ * clause instead (`ListQueryDeclaration.search.scoped`), asking for each
192
+ * scope token joined to the word; the writers stamp every scope token
193
+ * joined to every prefix, through {@link scopedSearchTokens}. A declaration
194
+ * names this constant as its `join`, so the writer and the reader cannot
195
+ * disagree about the character between the two halves.
196
+ *
197
+ * ⛔ Security rules cannot prove a folded query for a reader limited to some
198
+ * sites. Rules like `canReadScoped` grant a list from
199
+ * `visibleTo.hasAny(<the reader's scopes>)`, and a query that constrains only
200
+ * the scoped-token field says nothing about `visibleTo`, so Firestore denies
201
+ * it (pinned in cloud/rules-tests/firestore-rules.test.mjs). A folded search
202
+ * is therefore for readers the rules admit WITHOUT a `visibleTo` term (an
203
+ * org-wide member, whose rule short-circuits) or for an Admin-SDK route that
204
+ * checks the reader's scope itself. A partial-reach collaborator's search
205
+ * stays beside the `visibleTo` clause instead, as a prefix range on one
206
+ * ordered key (media: `nameLower`; inbox leads: `email`).
207
+ *
208
+ * `~` appears in no scope token (`org`, `host:{id}`), so the split is never
209
+ * ambiguous; a prefix that itself holds a `~` is matched whole on both sides
210
+ * and needs no escaping.
211
+ */ export const SCOPED_SEARCH_JOIN = '~';
212
+ /**
213
+ * The `search.scoped` half of a list declaration, over the array the
214
+ * writers stamp with {@link scopedSearchTokens}.
215
+ */ export function scopedSearch(tokensPath) {
216
+ return {
217
+ tokensPath,
218
+ join: SCOPED_SEARCH_JOIN
219
+ };
220
+ }
221
+ /**
222
+ * Every scope a record is visible to, joined to every search prefix it
223
+ * carries: `[org~a, org~ac, host:x~a, …]`, in scope order then token order,
224
+ * with no duplicates (AGL-3321).
225
+ *
226
+ * `visibleTo` is read defensively — a record's stored array — and keeps
227
+ * every non-empty string in it; anything else answers no scope, and so no
228
+ * scoped tokens: a record visible to nobody is found by no scoped search.
229
+ * `tokens` is whatever the record's plain search array holds, so a list that
230
+ * searches several fields passes their merged prefixes rather than one
231
+ * name's. Each is stamped beside the plain array on every write that sets
232
+ * either the searched text or `visibleTo`.
233
+ *
234
+ * The count is the product of the two, so a record placed on thirty sites
235
+ * with a long name carries thousands of entries. That stays far inside
236
+ * Firestore's per-document index-entry ceiling, and it is the price of a
237
+ * search a scoped reader can run at all.
238
+ */ export function scopedSearchTokens(visibleTo, tokens) {
239
+ if (!Array.isArray(visibleTo)) return [];
240
+ const scopes = [
241
+ ...new Set(visibleTo.filter((entry)=>typeof entry === 'string' && entry.length > 0))
242
+ ];
243
+ const words = [
244
+ ...new Set(tokens.filter((token)=>typeof token === 'string' && token))
245
+ ];
246
+ const scoped = [];
247
+ for (const scope of scopes){
248
+ for (const word of words)scoped.push(`${scope}${SCOPED_SEARCH_JOIN}${word}`);
249
+ }
250
+ return scoped;
251
+ }
155
252
 
156
253
  //# sourceMappingURL=name-search.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/name-search.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\n/**\n * Normalized search key for a human display name (AGL-835).\n *\n * Firestore has no case-insensitive query, so name search is done by storing\n * a normalized `nameLower` alongside the display name and running a prefix\n * range query against it: `orderBy(nameLower) startAt(q) endAt(q + '')`.\n * For that to match, the stored key and the typed query MUST be normalized the\n * same way — so both the write paths (screen/host creates and renames) and the\n * switcher's query builder run the raw text through this one function.\n *\n * Normalization is deliberately minimal and reversible-in-spirit: lower-case,\n * trim, and collapse internal whitespace. Diacritics are intentionally NOT\n * stripped — a user who types \"café\" should match the stored \"café\", and one\n * who types \"cafe\" is doing a different search; folding accents here would make\n * the prefix range silently disagree with the key. Returns '' for nullish or\n * blank input, which callers treat as \"no searchable name / skip the field\".\n */\nexport function nameSearchKey(name: string | null | undefined): string {\n return (name ?? '').trim().replace(/\\s+/g, ' ').toLowerCase()\n}\n\n/**\n * The longest word-prefix a token may be.\n *\n * Every prefix of every word is stored, so the token count grows with name\n * LENGTH rather than word count. Twelve characters is past the point where a\n * search box narrows anything — a reader who has typed twelve characters has\n * already found it — and it keeps a long name from spending a hundred index\n * entries on the tail of one word.\n */\nexport const NAME_TOKEN_MAX_PREFIX = 12\n\n/** Ceiling on tokens per document, so a pathological name cannot bloat the index. */\nexport const NAME_TOKEN_LIMIT = 120\n\n/**\n * Word-prefix tokens for `array-contains` search (AGL-2501).\n *\n * `nameSearchKey` supports a PREFIX range, which is anchored at the start of\n * the whole name: \"acme\" finds \"Acme Coffee\" and \"coffee\" does not. That is\n * the wrong shape for a search box, where the word a reader remembers is\n * rarely the first one.\n *\n * Firestore cannot answer `contains` on a string, but it can answer\n * `array-contains` on a field the write path prepared. Storing every prefix\n * of every WORD turns \"does this name contain a word starting with X\" into a\n * single indexed equality:\n *\n * \"Acme Coffee\" → a, ac, acm, acme, c, co, cof, coff, coffe, coffee\n *\n * So \"cof\" finds it, and so does \"acme\". What it still cannot do is match\n * mid-word — \"offee\" is not a prefix of any word — which is the honest edge\n * of doing this without a search service.\n *\n * Normalized through `nameSearchKey` so the stored tokens and the typed query\n * agree on case, trimming and internal whitespace; diacritics are kept, for\n * the reason given there.\n */\nexport function nameSearchTokens(name: string | null | undefined): string[] {\n const key = nameSearchKey(name)\n if (!key) return []\n const tokens = new Set<string>()\n for (const word of key.split(' ')) {\n if (!word) continue\n const capped = word.slice(0, NAME_TOKEN_MAX_PREFIX)\n for (let end = 1; end <= capped.length; end += 1) {\n tokens.add(capped.slice(0, end))\n if (tokens.size >= NAME_TOKEN_LIMIT) return [...tokens]\n }\n }\n return [...tokens]\n}\n\n/**\n * The one token a typed query becomes.\n *\n * `array-contains` takes a single value, and Firestore allows only one such\n * clause per query — so a multi-word query cannot be an AND on the server.\n * The FIRST word is used, which is what a reader is narrowing by when they\n * type \"acme cof\": they see the Acme results and read the rest themselves.\n * Capped to the same prefix length the tokens were written at, or a longer\n * query would match a token that was never stored.\n */\nexport function nameSearchToken(query: string | null | undefined): string {\n const key = nameSearchKey(query)\n if (!key) return ''\n return (key.split(' ')[0] ?? '').slice(0, NAME_TOKEN_MAX_PREFIX)\n}\n\n/**\n * The normalized name, reversed, so \"ends with\" becomes a prefix range.\n *\n * Firestore has one string operator that is not equality: the range. That\n * gives \"starts with\" directly — `>= q` and `<= q + ''` over\n * `nameLower` — and gives \"ends with\" nothing at all, because a range is\n * anchored at the front of the stored value.\n *\n * Reversing the stored key moves the end to the front. \"Acme Coffee\" is\n * stored as \"eeffoc emca\", and a search for names ending \"coffee\" becomes a\n * prefix range for \"eeffoc\" — the same query, on the same kind of index.\n *\n * Reversed by CODEPOINT (`[...key]`), not by UTF-16 unit: `split('')` cuts\n * surrogate pairs in half, so an emoji or a non-BMP character in a workspace\n * name would reverse into two lone surrogates and never match anything.\n */\nexport function nameSearchReversed(name: string | null | undefined): string {\n return [...nameSearchKey(name)].reverse().join('')\n}\n\n/**\n * The three search fields that travel with a `name`, as one object.\n *\n * Every collection whose list is SEARCHED on the server carries them, and\n * carrying only some is the failure mode worth designing against: `orderBy`\n * drops a document missing the field it sorts by, and `array-contains` drops\n * one missing the array. Either way the record still LISTS normally, so the\n * gap shows up only as a search that quietly cannot find one row.\n *\n * ⚠️ Spread this at every write that sets `name`, and only where `name` is\n * actually being written — a partial write that stamped an empty key over a\n * real one would make the document unfindable by the name it still displays.\n */\nexport function nameSearchFields(name: string): {\n name: string\n nameLower: string\n nameTokens: string[]\n nameReversed: string\n} {\n // `name` is INCLUDED so this is a drop-in replacement for writing `{ name }`.\n // A helper that returned only the derived keys would be spread beside the\n // name at four call sites, and the one that forgot would write search keys\n // for a name it never stored.\n return {\n name,\n nameLower: nameSearchKey(name),\n nameTokens: nameSearchTokens(name),\n nameReversed: nameSearchReversed(name),\n }\n}\n\n/**\n * The normalizers a list query plan (`planListQuery` in\n * `@aglyn/shared-ui-jsx/const/list-query-plan`) turns typed values into keys\n * with: the same functions the writers stamp `nameLower`, `nameTokens` and\n * `nameReversed` with, so a query asks for what was stored (AGL-3321).\n */\nexport const nameSearchNormalizers = {\n key: (value: string) => nameSearchKey(value),\n token: (value: string) => nameSearchToken(value),\n reversed: (value: string) => nameSearchReversed(value),\n maxPrefix: NAME_TOKEN_MAX_PREFIX,\n} as const\n\n"],"names":["nameSearchKey","name","trim","replace","toLowerCase","NAME_TOKEN_MAX_PREFIX","NAME_TOKEN_LIMIT","nameSearchTokens","key","tokens","Set","word","split","capped","slice","end","length","add","size","nameSearchToken","query","nameSearchReversed","reverse","join","nameSearchFields","nameLower","nameTokens","nameReversed","nameSearchNormalizers","value","token","reversed","maxPrefix"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASA,cAAcC,IAA+B;IAC3D,OAAO,CAACA,eAAAA,OAAQ,IAAIC,IAAI,GAAGC,OAAO,CAAC,QAAQ,KAAKC,WAAW;AAC7D;AAEA;;;;;;;;CAQC,GACD,OAAO,MAAMC,wBAAwB,GAAE;AAEvC,mFAAmF,GACnF,OAAO,MAAMC,mBAAmB,IAAG;AAEnC;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,SAASC,iBAAiBN,IAA+B;IAC9D,MAAMO,MAAMR,cAAcC;IAC1B,IAAI,CAACO,KAAK,OAAO,EAAE;IACnB,MAAMC,SAAS,IAAIC;IACnB,KAAK,MAAMC,QAAQH,IAAII,KAAK,CAAC,KAAM;QACjC,IAAI,CAACD,MAAM;QACX,MAAME,SAASF,KAAKG,KAAK,CAAC,GAAGT;QAC7B,IAAK,IAAIU,MAAM,GAAGA,OAAOF,OAAOG,MAAM,EAAED,OAAO,EAAG;YAChDN,OAAOQ,GAAG,CAACJ,OAAOC,KAAK,CAAC,GAAGC;YAC3B,IAAIN,OAAOS,IAAI,IAAIZ,kBAAkB,OAAO;mBAAIG;aAAO;QACzD;IACF;IACA,OAAO;WAAIA;KAAO;AACpB;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASU,gBAAgBC,KAAgC;QAGtDZ;IAFR,MAAMA,MAAMR,cAAcoB;IAC1B,IAAI,CAACZ,KAAK,OAAO;IACjB,OAAO,EAACA,cAAAA,IAAII,KAAK,CAAC,IAAI,CAAC,EAAE,YAAjBJ,cAAqB,IAAIM,KAAK,CAAC,GAAGT;AAC5C;AAEA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASgB,mBAAmBpB,IAA+B;IAChE,OAAO;WAAID,cAAcC;KAAM,CAACqB,OAAO,GAAGC,IAAI,CAAC;AACjD;AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC,iBAAiBvB,IAAY;IAM3C,8EAA8E;IAC9E,0EAA0E;IAC1E,2EAA2E;IAC3E,8BAA8B;IAC9B,OAAO;QACLA;QACAwB,WAAWzB,cAAcC;QACzByB,YAAYnB,iBAAiBN;QAC7B0B,cAAcN,mBAAmBpB;IACnC;AACF;AAEA;;;;;CAKC,GACD,OAAO,MAAM2B,wBAAwB;IACnCpB,KAAK,CAACqB,QAAkB7B,cAAc6B;IACtCC,OAAO,CAACD,QAAkBV,gBAAgBU;IAC1CE,UAAU,CAACF,QAAkBR,mBAAmBQ;IAChDG,WAAW3B;AACb,EAAU"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/name-search.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\n/**\n * Normalized search key for a human display name (AGL-835).\n *\n * Firestore has no case-insensitive query, so name search is done by storing\n * a normalized `nameLower` alongside the display name and running a prefix\n * range query against it: `orderBy(nameLower) startAt(q) endAt(q + '')`.\n * For that to match, the stored key and the typed query MUST be normalized the\n * same way — so both the write paths (screen/host creates and renames) and the\n * switcher's query builder run the raw text through this one function.\n *\n * Normalization is deliberately minimal and reversible-in-spirit: lower-case,\n * trim, and collapse internal whitespace. Diacritics are intentionally NOT\n * stripped — a user who types \"café\" should match the stored \"café\", and one\n * who types \"cafe\" is doing a different search; folding accents here would make\n * the prefix range silently disagree with the key. Returns '' for nullish or\n * blank input, which callers treat as \"no searchable name / skip the field\".\n */\nexport function nameSearchKey(name: string | null | undefined): string {\n return (name ?? '').trim().replace(/\\s+/g, ' ').toLowerCase()\n}\n\n/**\n * The longest word-prefix a token may be.\n *\n * Every prefix of every word is stored, so the token count grows with name\n * LENGTH rather than word count. Twelve characters is past the point where a\n * search box narrows anything — a reader who has typed twelve characters has\n * already found it — and it keeps a long name from spending a hundred index\n * entries on the tail of one word.\n */\nexport const NAME_TOKEN_MAX_PREFIX = 12\n\n/** Ceiling on tokens per document, so a pathological name cannot bloat the index. */\nexport const NAME_TOKEN_LIMIT = 120\n\n/**\n * Word-prefix tokens for `array-contains` search (AGL-2501).\n *\n * `nameSearchKey` supports a PREFIX range, which is anchored at the start of\n * the whole name: \"acme\" finds \"Acme Coffee\" and \"coffee\" does not. That is\n * the wrong shape for a search box, where the word a reader remembers is\n * rarely the first one.\n *\n * Firestore cannot answer `contains` on a string, but it can answer\n * `array-contains` on a field the write path prepared. Storing every prefix\n * of every WORD turns \"does this name contain a word starting with X\" into a\n * single indexed equality:\n *\n * \"Acme Coffee\" → a, ac, acm, acme, c, co, cof, coff, coffe, coffee\n *\n * So \"cof\" finds it, and so does \"acme\". What it still cannot do is match\n * mid-word — \"offee\" is not a prefix of any word — which is the honest edge\n * of doing this without a search service.\n *\n * Normalized through `nameSearchKey` so the stored tokens and the typed query\n * agree on case, trimming and internal whitespace; diacritics are kept, for\n * the reason given there.\n */\nexport function nameSearchTokens(name: string | null | undefined): string[] {\n const key = nameSearchKey(name)\n if (!key) return []\n const tokens = new Set<string>()\n for (const word of key.split(' ')) {\n if (!word) continue\n const capped = word.slice(0, NAME_TOKEN_MAX_PREFIX)\n for (let end = 1; end <= capped.length; end += 1) {\n tokens.add(capped.slice(0, end))\n if (tokens.size >= NAME_TOKEN_LIMIT) return [...tokens]\n }\n }\n return [...tokens]\n}\n\n/**\n * The one token a typed query becomes.\n *\n * `array-contains` takes a single value, and Firestore allows only one such\n * clause per query — so a multi-word query cannot be an AND on the server.\n * The FIRST word is used, which is what a reader is narrowing by when they\n * type \"acme cof\": they see the Acme results and read the rest themselves.\n * Capped to the same prefix length the tokens were written at, or a longer\n * query would match a token that was never stored.\n */\nexport function nameSearchToken(query: string | null | undefined): string {\n const key = nameSearchKey(query)\n if (!key) return ''\n return (key.split(' ')[0] ?? '').slice(0, NAME_TOKEN_MAX_PREFIX)\n}\n\n/**\n * The normalized name, reversed, so \"ends with\" becomes a prefix range.\n *\n * Firestore has one string operator that is not equality: the range. That\n * gives \"starts with\" directly — `>= q` and `<= q + ''` over\n * `nameLower` — and gives \"ends with\" nothing at all, because a range is\n * anchored at the front of the stored value.\n *\n * Reversing the stored key moves the end to the front. \"Acme Coffee\" is\n * stored as \"eeffoc emca\", and a search for names ending \"coffee\" becomes a\n * prefix range for \"eeffoc\" — the same query, on the same kind of index.\n *\n * Reversed by CODEPOINT (`[...key]`), not by UTF-16 unit: `split('')` cuts\n * surrogate pairs in half, so an emoji or a non-BMP character in a workspace\n * name would reverse into two lone surrogates and never match anything.\n */\nexport function nameSearchReversed(name: string | null | undefined): string {\n return [...nameSearchKey(name)].reverse().join('')\n}\n\n/**\n * The three search fields that travel with a `name`, as one object.\n *\n * Every collection whose list is SEARCHED on the server carries them, and\n * carrying only some is the failure mode worth designing against: `orderBy`\n * drops a document missing the field it sorts by, and `array-contains` drops\n * one missing the array. Either way the record still LISTS normally, so the\n * gap shows up only as a search that quietly cannot find one row.\n *\n * ⚠️ Spread this at every write that sets `name`, and only where `name` is\n * actually being written — a partial write that stamped an empty key over a\n * real one would make the document unfindable by the name it still displays.\n */\nexport function nameSearchFields(name: string): {\n name: string\n nameLower: string\n nameTokens: string[]\n nameReversed: string\n} {\n // `name` is INCLUDED so this is a drop-in replacement for writing `{ name }`.\n // A helper that returned only the derived keys would be spread beside the\n // name at four call sites, and the one that forgot would write search keys\n // for a name it never stored.\n return {\n name,\n nameLower: nameSearchKey(name),\n nameTokens: nameSearchTokens(name),\n nameReversed: nameSearchReversed(name),\n }\n}\n\n/**\n * The search fields a document NAMED BY `displayName` carries (AGL-3321):\n * the site artifacts (screens of every kind, layouts, reusable components,\n * templates) and the marketplace listings.\n *\n * `nameSearchFields` is for documents whose name IS `name`. These documents\n * name themselves `displayName`, and have since before search existed, so\n * the keys ride beside it under the same three names every list declares —\n * `lowerPath: 'nameLower'`, `tokensPath: 'nameTokens'`,\n * `reversedPath: 'nameReversed'` — and one list query grammar reads them all.\n *\n * `displayName` is NOT returned. A create that carries no name must not gain\n * an empty one: readers fall back with `displayName ?? id`, which an empty\n * string defeats. But the KEYS are always returned, empty for a missing name,\n * so a create stamps them unconditionally — an `orderBy('nameLower')` drops\n * any document that lacks the field, and a document created without a name\n * is still one a list must reach.\n *\n * ⚠️ Spread at every write that sets `displayName`, and at every create —\n * and nowhere else: a partial update that stamped keys for a name it did not\n * write would make the document unfindable by the name it still shows.\n */\nexport function displayNameSearchFields(displayName: unknown): {\n nameLower: string\n nameTokens: string[]\n nameReversed: string\n} {\n const name = typeof displayName === 'string' ? displayName : ''\n return {\n nameLower: nameSearchKey(name),\n nameTokens: nameSearchTokens(name),\n nameReversed: nameSearchReversed(name),\n }\n}\n\n/**\n * The normalizers a list query plan (`planListQuery` in\n * `@aglyn/shared-ui-jsx/const/list-query-plan`) turns typed values into keys\n * with: the same functions the writers stamp `nameLower`, `nameTokens` and\n * `nameReversed` with, so a query asks for what was stored (AGL-3321).\n */\nexport const nameSearchNormalizers = {\n key: (value: string) => nameSearchKey(value),\n token: (value: string) => nameSearchToken(value),\n reversed: (value: string) => nameSearchReversed(value),\n maxPrefix: NAME_TOKEN_MAX_PREFIX,\n} as const\n\n\n/**\n * What joins a scope token to a search prefix in a SCOPED search token\n * (AGL-3321): `host:abc~acm`.\n *\n * A list under a scope clause (`visibleTo array-contains-any [...]`) has\n * spent Firestore's one array clause on the scope, so its search cannot be a\n * second `array-contains`. The list query plan folds the typed word INTO the scope\n * clause instead (`ListQueryDeclaration.search.scoped`), asking for each\n * scope token joined to the word; the writers stamp every scope token\n * joined to every prefix, through {@link scopedSearchTokens}. A declaration\n * names this constant as its `join`, so the writer and the reader cannot\n * disagree about the character between the two halves.\n *\n * ⛔ Security rules cannot prove a folded query for a reader limited to some\n * sites. Rules like `canReadScoped` grant a list from\n * `visibleTo.hasAny(<the reader's scopes>)`, and a query that constrains only\n * the scoped-token field says nothing about `visibleTo`, so Firestore denies\n * it (pinned in cloud/rules-tests/firestore-rules.test.mjs). A folded search\n * is therefore for readers the rules admit WITHOUT a `visibleTo` term (an\n * org-wide member, whose rule short-circuits) or for an Admin-SDK route that\n * checks the reader's scope itself. A partial-reach collaborator's search\n * stays beside the `visibleTo` clause instead, as a prefix range on one\n * ordered key (media: `nameLower`; inbox leads: `email`).\n *\n * `~` appears in no scope token (`org`, `host:{id}`), so the split is never\n * ambiguous; a prefix that itself holds a `~` is matched whole on both sides\n * and needs no escaping.\n */\nexport const SCOPED_SEARCH_JOIN = '~'\n\n/**\n * The `search.scoped` half of a list declaration, over the array the\n * writers stamp with {@link scopedSearchTokens}.\n */\nexport function scopedSearch(tokensPath: string): {\n tokensPath: string\n join: typeof SCOPED_SEARCH_JOIN\n} {\n return { tokensPath, join: SCOPED_SEARCH_JOIN }\n}\n\n/**\n * Every scope a record is visible to, joined to every search prefix it\n * carries: `[org~a, org~ac, host:x~a, …]`, in scope order then token order,\n * with no duplicates (AGL-3321).\n *\n * `visibleTo` is read defensively — a record's stored array — and keeps\n * every non-empty string in it; anything else answers no scope, and so no\n * scoped tokens: a record visible to nobody is found by no scoped search.\n * `tokens` is whatever the record's plain search array holds, so a list that\n * searches several fields passes their merged prefixes rather than one\n * name's. Each is stamped beside the plain array on every write that sets\n * either the searched text or `visibleTo`.\n *\n * The count is the product of the two, so a record placed on thirty sites\n * with a long name carries thousands of entries. That stays far inside\n * Firestore's per-document index-entry ceiling, and it is the price of a\n * search a scoped reader can run at all.\n */\nexport function scopedSearchTokens(\n visibleTo: unknown,\n tokens: readonly string[],\n): string[] {\n if (!Array.isArray(visibleTo)) return []\n const scopes = [\n ...new Set(\n visibleTo.filter(\n (entry): entry is string => typeof entry === 'string' && entry.length > 0,\n ),\n ),\n ]\n const words = [...new Set(tokens.filter((token) => typeof token === 'string' && token))]\n const scoped: string[] = []\n for (const scope of scopes) {\n for (const word of words) scoped.push(`${scope}${SCOPED_SEARCH_JOIN}${word}`)\n }\n return scoped\n}\n"],"names":["nameSearchKey","name","trim","replace","toLowerCase","NAME_TOKEN_MAX_PREFIX","NAME_TOKEN_LIMIT","nameSearchTokens","key","tokens","Set","word","split","capped","slice","end","length","add","size","nameSearchToken","query","nameSearchReversed","reverse","join","nameSearchFields","nameLower","nameTokens","nameReversed","displayNameSearchFields","displayName","nameSearchNormalizers","value","token","reversed","maxPrefix","SCOPED_SEARCH_JOIN","scopedSearch","tokensPath","scopedSearchTokens","visibleTo","Array","isArray","scopes","filter","entry","words","scoped","scope","push"],"mappings":"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"}
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /** Which Stripe signal arrived. */
18
+ export type PaymentRiskSignalKind = 'early-fraud-warning' | 'radar-review' | 'dispute';
19
+ /** One signal, as stamped on the merchant's record. */
20
+ export interface PaymentRiskSignal {
21
+ kind: PaymentRiskSignalKind;
22
+ /** The EFW, review or dispute id: one entry per signal, whatever redelivers. */
23
+ stripeObjectId: string;
24
+ /** `fraud_type`, the review's `reason`, the dispute's `reason`. */
25
+ detail?: string;
26
+ /** A dispute's outcome once `charge.dispute.closed` lands. */
27
+ outcome?: string;
28
+ atMs: number;
29
+ }
30
+ /** The `paymentRisk` field. */
31
+ export interface PaymentRisk {
32
+ signals: PaymentRiskSignal[];
33
+ latestKind: PaymentRiskSignalKind;
34
+ latestAtMs: number;
35
+ }
36
+ export declare const PAYMENT_RISK_FIELD = "paymentRisk";
37
+ /** The Stripe events that carry a signal, and which kind each is. */
38
+ export declare const PAYMENT_RISK_EVENT_KINDS: Readonly<Record<string, PaymentRiskSignalKind>>;
39
+ /** What a webhook handler needs from one of those events. */
40
+ export interface PaymentRiskEvent {
41
+ signal: PaymentRiskSignal;
42
+ paymentIntentId: string;
43
+ chargeId: string;
44
+ }
45
+ /**
46
+ * The signal an event carries, or null for any other event. Every one of
47
+ * the three objects names its `charge` and `payment_intent`, which is how a
48
+ * plugin finds its own record.
49
+ */
50
+ export declare function paymentRiskEventFrom(type: string, object: unknown, nowMs?: number): PaymentRiskEvent | null;
51
+ /**
52
+ * The field with `signal` added, or null when this signal is already on it
53
+ * — the redelivery answer, so the caller neither writes nor notifies twice.
54
+ */
55
+ export declare function addPaymentRiskSignal(existing: PaymentRisk | null | undefined, signal: PaymentRiskSignal): PaymentRisk | null;
56
+ /**
57
+ * The field with a dispute's outcome recorded, or null when the dispute is
58
+ * not on it or already carries that outcome.
59
+ */
60
+ export declare function settlePaymentRiskDispute(existing: PaymentRisk | null | undefined, disputeId: string, outcome: string): PaymentRisk | null;
61
+ /** Said with every signal, so a warning never reads as money already gone. */
62
+ export declare const NOTHING_MOVED: string;
63
+ /** The merchant-facing title for one signal, e.g. for a notification. */
64
+ export declare function paymentRiskTitle(kind: PaymentRiskSignalKind): string;
65
+ /** The merchant-facing sentence for one signal. */
66
+ export declare function paymentRiskAdvice(kind: PaymentRiskSignalKind): string;
67
+ /**
68
+ * A chip's label and tooltip for the record's latest signal, or null when
69
+ * there is none. `Nothing was refunded` is said every time: the merchant
70
+ * must never read a warning as money already gone.
71
+ */
72
+ export declare function describePaymentRisk(risk: PaymentRisk | null | undefined): {
73
+ label: string;
74
+ detail: string;
75
+ } | null;
@@ -0,0 +1,139 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ /*==========================================
18
+ * PAYMENT RISK ON A MERCHANT'S RECORD (AGL-3360).
19
+ *
20
+ * Stripe tells us three ways that a card may not be its holder's: an
21
+ * issuer's EARLY FRAUD WARNING, a Radar REVIEW that holds the payment, and a
22
+ * DISPUTE. For a site's own sale — an order, a booking, a membership — the
23
+ * merchant is the one who decides what to do about it, so the signal lands
24
+ * on the record the sale belongs to, in the merchant's own workspace.
25
+ *
26
+ * This is the one shape every plugin stamps (`paymentRisk` on its own
27
+ * document) and the one sentence every plugin renders, so an order chip and
28
+ * a booking chip say the same thing about the same event. Pure and
29
+ * client-safe; the server write is `payment-risk-record.ts` in
30
+ * tenant-data-admin.
31
+ *
32
+ * Nothing here moves money. An early fraud warning is not yet a chargeback,
33
+ * a review can clear, and a dispute can be won: the words tell the merchant
34
+ * what arrived and where to act (Stripe), never that anything was refunded.
35
+ *=========================================*/ import { PLATFORM_BRAND_NAME } from "./platform-brand.js";
36
+ export const PAYMENT_RISK_FIELD = 'paymentRisk';
37
+ /** Bounded so a pathological charge cannot grow a document without end. */ const MAX_SIGNALS = 20;
38
+ /** The Stripe events that carry a signal, and which kind each is. */ export const PAYMENT_RISK_EVENT_KINDS = {
39
+ 'radar.early_fraud_warning.created': 'early-fraud-warning',
40
+ 'review.opened': 'radar-review',
41
+ 'charge.dispute.created': 'dispute'
42
+ };
43
+ const refOf = (value)=>{
44
+ var _ref;
45
+ return typeof value === 'string' ? value : String((_ref = value == null ? void 0 : value.id) != null ? _ref : '');
46
+ };
47
+ /**
48
+ * The signal an event carries, or null for any other event. Every one of
49
+ * the three objects names its `charge` and `payment_intent`, which is how a
50
+ * plugin finds its own record.
51
+ */ export function paymentRiskEventFrom(type, object, nowMs = Date.now()) {
52
+ var _source_id, _ref, _ref1, _source_fraud_type;
53
+ const kind = PAYMENT_RISK_EVENT_KINDS[type];
54
+ if (!kind) return null;
55
+ const source = object != null ? object : {};
56
+ const stripeObjectId = String((_source_id = source['id']) != null ? _source_id : '');
57
+ if (!stripeObjectId) return null;
58
+ const detail = String((_ref = (_ref1 = (_source_fraud_type = source['fraud_type']) != null ? _source_fraud_type : source['reason']) != null ? _ref1 : source['opened_reason']) != null ? _ref : '');
59
+ const createdSeconds = Number(source['created']);
60
+ return {
61
+ signal: _extends({
62
+ kind,
63
+ stripeObjectId
64
+ }, detail ? {
65
+ detail
66
+ } : {}, {
67
+ atMs: Number.isFinite(createdSeconds) && createdSeconds > 0 ? createdSeconds * 1000 : nowMs
68
+ }),
69
+ paymentIntentId: refOf(source['payment_intent']),
70
+ chargeId: refOf(source['charge'])
71
+ };
72
+ }
73
+ /**
74
+ * The field with `signal` added, or null when this signal is already on it
75
+ * — the redelivery answer, so the caller neither writes nor notifies twice.
76
+ */ export function addPaymentRiskSignal(existing, signal) {
77
+ const signals = Array.isArray(existing == null ? void 0 : existing.signals) ? existing.signals : [];
78
+ if (signals.some((entry)=>entry.stripeObjectId === signal.stripeObjectId)) {
79
+ return null;
80
+ }
81
+ const next = [
82
+ ...signals,
83
+ signal
84
+ ].slice(-MAX_SIGNALS);
85
+ return {
86
+ signals: next,
87
+ latestKind: signal.kind,
88
+ latestAtMs: signal.atMs
89
+ };
90
+ }
91
+ /**
92
+ * The field with a dispute's outcome recorded, or null when the dispute is
93
+ * not on it or already carries that outcome.
94
+ */ export function settlePaymentRiskDispute(existing, disputeId, outcome) {
95
+ const signals = Array.isArray(existing == null ? void 0 : existing.signals) ? existing.signals : [];
96
+ const index = signals.findIndex((entry)=>entry.stripeObjectId === disputeId);
97
+ if (index < 0 || !outcome || signals[index].outcome === outcome) return null;
98
+ const next = signals.slice();
99
+ next[index] = _extends({}, next[index], {
100
+ outcome
101
+ });
102
+ return _extends({}, existing, {
103
+ signals: next
104
+ });
105
+ }
106
+ /** Said with every signal, so a warning never reads as money already gone. */ export const NOTHING_MOVED = `${PLATFORM_BRAND_NAME} has not refunded or canceled anything.`;
107
+ const LABEL = {
108
+ 'early-fraud-warning': 'Fraud warning',
109
+ 'radar-review': 'Held for review',
110
+ dispute: 'Chargeback'
111
+ };
112
+ const ADVICE = {
113
+ 'early-fraud-warning': 'The card issuer reported this payment as possibly fraudulent. It is not a chargeback yet; refunding it in Stripe now usually prevents one. Hold anything not yet shipped or delivered until you are sure.',
114
+ 'radar-review': 'Stripe Radar is holding this payment for review. Approve or refund it in Stripe; it stays held until you do.',
115
+ dispute: 'The cardholder disputed this payment. Respond in Stripe with your evidence before the deadline, or accept it.'
116
+ };
117
+ /** The merchant-facing title for one signal, e.g. for a notification. */ export function paymentRiskTitle(kind) {
118
+ return kind === 'early-fraud-warning' ? 'Early fraud warning on a payment' : kind === 'radar-review' ? 'A payment is held for review' : 'A payment was disputed';
119
+ }
120
+ /** The merchant-facing sentence for one signal. */ export function paymentRiskAdvice(kind) {
121
+ return ADVICE[kind];
122
+ }
123
+ /**
124
+ * A chip's label and tooltip for the record's latest signal, or null when
125
+ * there is none. `Nothing was refunded` is said every time: the merchant
126
+ * must never read a warning as money already gone.
127
+ */ export function describePaymentRisk(risk) {
128
+ const signals = Array.isArray(risk == null ? void 0 : risk.signals) ? risk.signals : [];
129
+ const latest = signals[signals.length - 1];
130
+ if (!latest) return null;
131
+ const outcome = latest.outcome ? ` (${latest.outcome.replace(/_/g, ' ')})` : '';
132
+ const why = latest.detail ? ` Stripe says: ${latest.detail.replace(/_/g, ' ')}.` : '';
133
+ return {
134
+ label: `${LABEL[latest.kind]}${outcome}`,
135
+ detail: `${ADVICE[latest.kind]}${why} ${NOTHING_MOVED}`
136
+ };
137
+ }
138
+
139
+ //# sourceMappingURL=payment-risk.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/payment-risk.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\n/*==========================================\n * PAYMENT RISK ON A MERCHANT'S RECORD (AGL-3360).\n *\n * Stripe tells us three ways that a card may not be its holder's: an\n * issuer's EARLY FRAUD WARNING, a Radar REVIEW that holds the payment, and a\n * DISPUTE. For a site's own sale — an order, a booking, a membership — the\n * merchant is the one who decides what to do about it, so the signal lands\n * on the record the sale belongs to, in the merchant's own workspace.\n *\n * This is the one shape every plugin stamps (`paymentRisk` on its own\n * document) and the one sentence every plugin renders, so an order chip and\n * a booking chip say the same thing about the same event. Pure and\n * client-safe; the server write is `payment-risk-record.ts` in\n * tenant-data-admin.\n *\n * Nothing here moves money. An early fraud warning is not yet a chargeback,\n * a review can clear, and a dispute can be won: the words tell the merchant\n * what arrived and where to act (Stripe), never that anything was refunded.\n *=========================================*/\n\nimport { PLATFORM_BRAND_NAME } from './platform-brand'\n\n/** Which Stripe signal arrived. */\nexport type PaymentRiskSignalKind =\n | 'early-fraud-warning'\n | 'radar-review'\n | 'dispute'\n\n/** One signal, as stamped on the merchant's record. */\nexport interface PaymentRiskSignal {\n kind: PaymentRiskSignalKind\n /** The EFW, review or dispute id: one entry per signal, whatever redelivers. */\n stripeObjectId: string\n /** `fraud_type`, the review's `reason`, the dispute's `reason`. */\n detail?: string\n /** A dispute's outcome once `charge.dispute.closed` lands. */\n outcome?: string\n atMs: number\n}\n\n/** The `paymentRisk` field. */\nexport interface PaymentRisk {\n signals: PaymentRiskSignal[]\n latestKind: PaymentRiskSignalKind\n latestAtMs: number\n}\n\nexport const PAYMENT_RISK_FIELD = 'paymentRisk'\n\n/** Bounded so a pathological charge cannot grow a document without end. */\nconst MAX_SIGNALS = 20\n\n/** The Stripe events that carry a signal, and which kind each is. */\nexport const PAYMENT_RISK_EVENT_KINDS: Readonly<\n Record<string, PaymentRiskSignalKind>\n> = {\n 'radar.early_fraud_warning.created': 'early-fraud-warning',\n 'review.opened': 'radar-review',\n 'charge.dispute.created': 'dispute',\n}\n\n/** What a webhook handler needs from one of those events. */\nexport interface PaymentRiskEvent {\n signal: PaymentRiskSignal\n paymentIntentId: string\n chargeId: string\n}\n\nconst refOf = (value: unknown): string =>\n typeof value === 'string'\n ? value\n : String((value as { id?: unknown } | null)?.id ?? '')\n\n/**\n * The signal an event carries, or null for any other event. Every one of\n * the three objects names its `charge` and `payment_intent`, which is how a\n * plugin finds its own record.\n */\nexport function paymentRiskEventFrom(\n type: string,\n object: unknown,\n nowMs = Date.now(),\n): PaymentRiskEvent | null {\n const kind = PAYMENT_RISK_EVENT_KINDS[type]\n if (!kind) return null\n const source = (object ?? {}) as Record<string, unknown>\n const stripeObjectId = String(source['id'] ?? '')\n if (!stripeObjectId) return null\n const detail = String(\n source['fraud_type'] ?? source['reason'] ?? source['opened_reason'] ?? '',\n )\n const createdSeconds = Number(source['created'])\n return {\n signal: {\n kind,\n stripeObjectId,\n ...(detail ? { detail } : {}),\n atMs:\n Number.isFinite(createdSeconds) && createdSeconds > 0\n ? createdSeconds * 1000\n : nowMs,\n },\n paymentIntentId: refOf(source['payment_intent']),\n chargeId: refOf(source['charge']),\n }\n}\n\n/**\n * The field with `signal` added, or null when this signal is already on it\n * — the redelivery answer, so the caller neither writes nor notifies twice.\n */\nexport function addPaymentRiskSignal(\n existing: PaymentRisk | null | undefined,\n signal: PaymentRiskSignal,\n): PaymentRisk | null {\n const signals = Array.isArray(existing?.signals) ? existing.signals : []\n if (signals.some((entry) => entry.stripeObjectId === signal.stripeObjectId)) {\n return null\n }\n const next = [...signals, signal].slice(-MAX_SIGNALS)\n return { signals: next, latestKind: signal.kind, latestAtMs: signal.atMs }\n}\n\n/**\n * The field with a dispute's outcome recorded, or null when the dispute is\n * not on it or already carries that outcome.\n */\nexport function settlePaymentRiskDispute(\n existing: PaymentRisk | null | undefined,\n disputeId: string,\n outcome: string,\n): PaymentRisk | null {\n const signals = Array.isArray(existing?.signals) ? existing.signals : []\n const index = signals.findIndex((entry) => entry.stripeObjectId === disputeId)\n if (index < 0 || !outcome || signals[index].outcome === outcome) return null\n const next = signals.slice()\n next[index] = { ...next[index], outcome }\n return { ...(existing as PaymentRisk), signals: next }\n}\n\n/** Said with every signal, so a warning never reads as money already gone. */\nexport const NOTHING_MOVED = `${PLATFORM_BRAND_NAME} has not refunded or canceled anything.`\n\nconst LABEL: Record<PaymentRiskSignalKind, string> = {\n 'early-fraud-warning': 'Fraud warning',\n 'radar-review': 'Held for review',\n dispute: 'Chargeback',\n}\n\nconst ADVICE: Record<PaymentRiskSignalKind, string> = {\n 'early-fraud-warning':\n 'The card issuer reported this payment as possibly fraudulent. It is not a chargeback yet; refunding it in Stripe now usually prevents one. Hold anything not yet shipped or delivered until you are sure.',\n 'radar-review':\n 'Stripe Radar is holding this payment for review. Approve or refund it in Stripe; it stays held until you do.',\n dispute:\n 'The cardholder disputed this payment. Respond in Stripe with your evidence before the deadline, or accept it.',\n}\n\n/** The merchant-facing title for one signal, e.g. for a notification. */\nexport function paymentRiskTitle(kind: PaymentRiskSignalKind): string {\n return kind === 'early-fraud-warning'\n ? 'Early fraud warning on a payment'\n : kind === 'radar-review'\n ? 'A payment is held for review'\n : 'A payment was disputed'\n}\n\n/** The merchant-facing sentence for one signal. */\nexport function paymentRiskAdvice(kind: PaymentRiskSignalKind): string {\n return ADVICE[kind]\n}\n\n/**\n * A chip's label and tooltip for the record's latest signal, or null when\n * there is none. `Nothing was refunded` is said every time: the merchant\n * must never read a warning as money already gone.\n */\nexport function describePaymentRisk(\n risk: PaymentRisk | null | undefined,\n): { label: string; detail: string } | null {\n const signals = Array.isArray(risk?.signals) ? risk.signals : []\n const latest = signals[signals.length - 1]\n if (!latest) return null\n const outcome = latest.outcome ? ` (${latest.outcome.replace(/_/g, ' ')})` : ''\n const why = latest.detail ? ` Stripe says: ${latest.detail.replace(/_/g, ' ')}.` : ''\n return {\n label: `${LABEL[latest.kind]}${outcome}`,\n detail: `${ADVICE[latest.kind]}${why} ${NOTHING_MOVED}`,\n }\n}\n"],"names":["PLATFORM_BRAND_NAME","PAYMENT_RISK_FIELD","MAX_SIGNALS","PAYMENT_RISK_EVENT_KINDS","refOf","value","String","id","paymentRiskEventFrom","type","object","nowMs","Date","now","source","kind","stripeObjectId","detail","createdSeconds","Number","signal","atMs","isFinite","paymentIntentId","chargeId","addPaymentRiskSignal","existing","signals","Array","isArray","some","entry","next","slice","latestKind","latestAtMs","settlePaymentRiskDispute","disputeId","outcome","index","findIndex","NOTHING_MOVED","LABEL","dispute","ADVICE","paymentRiskTitle","paymentRiskAdvice","describePaymentRisk","risk","latest","length","replace","why","label"],"mappings":"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"}
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ export interface PluginApiRequestHost {
18
+ /** The one site the request names, or '' when it names none. */
19
+ hostId: string;
20
+ /** True when the query and the body (or repeated params) disagree. */
21
+ conflict: boolean;
22
+ }
23
+ /**
24
+ * Every site the request names, read off a clone so the handler still gets
25
+ * the untouched stream. Never throws: an unreadable body names no site.
26
+ */
27
+ export declare function pluginApiRequestHost(request: Request): Promise<PluginApiRequestHost>;
28
+ /** The dispatcher's answer to a conflict: refuse, and say why. */
29
+ export declare function conflictingHostIdResponse(): Response;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /*==========================================
17
+ * WHICH SITE A PLUGIN API REQUEST IS ABOUT (AGL-3360).
18
+ *
19
+ * Both plugin API dispatchers gate a request on its site — per-site plugin
20
+ * enablement, the org/host lockdown, the visitor rate limit — and then hand
21
+ * the request to a handler that reads the site AGAIN, its own way. The two
22
+ * readings disagreed in two ways, and each let a suspended site take money:
23
+ *
24
+ * 1. `?hostId=` won over the body. A checkout posted with
25
+ * `?hostId=<any open site>` and `{"hostId": "<suspended site>"}` was
26
+ * gated on the open site and charged for the suspended one: every
27
+ * storefront, booking and reservation handler reads `body.hostId`.
28
+ * 2. Only a JSON body was read. A urlencoded body parsed to nothing here,
29
+ * so no gate ran at all, while `pluginRequestFromWeb` hands the handler
30
+ * the form fields as an object and `body.hostId` is right there.
31
+ *
32
+ * This reads the request exactly as `pluginRequestFromWeb` will present it
33
+ * to the handler — form fields for urlencoded, JSON for anything that
34
+ * parses as JSON (handlers `JSON.parse` a string body themselves) — and
35
+ * collects EVERY hostId the request names. More than one distinct site is
36
+ * a conflict the dispatcher refuses: no honest caller names two sites, and
37
+ * any single choice between them is a guess an attacker gets to make.
38
+ *
39
+ * Coerced with `String(value ?? '')`, the handlers' own coercion, so the
40
+ * site gated is the site charged byte for byte.
41
+ *=========================================*/ function bodyHostId(raw, contentType) {
42
+ if (!raw) return '';
43
+ if (contentType.includes('application/x-www-form-urlencoded')) {
44
+ var _get;
45
+ return String((_get = new URLSearchParams(raw).get('hostId')) != null ? _get : '');
46
+ }
47
+ try {
48
+ var _parsed_hostId;
49
+ const parsed = JSON.parse(raw);
50
+ return parsed && typeof parsed === 'object' ? String((_parsed_hostId = parsed.hostId) != null ? _parsed_hostId : '') : '';
51
+ } catch (unused) {
52
+ return '';
53
+ }
54
+ }
55
+ /**
56
+ * Every site the request names, read off a clone so the handler still gets
57
+ * the untouched stream. Never throws: an unreadable body names no site.
58
+ */ export async function pluginApiRequestHost(request) {
59
+ var _request_method;
60
+ const named = new Set();
61
+ const url = new URL(request.url);
62
+ for (const value of url.searchParams.getAll('hostId')){
63
+ if (value) named.add(value);
64
+ }
65
+ const method = ((_request_method = request.method) != null ? _request_method : 'GET').toUpperCase();
66
+ if (method !== 'GET' && method !== 'HEAD') {
67
+ try {
68
+ var _request_headers_get;
69
+ const raw = await request.clone().text();
70
+ const fromBody = bodyHostId(raw, (_request_headers_get = request.headers.get('content-type')) != null ? _request_headers_get : '');
71
+ if (fromBody) named.add(fromBody);
72
+ } catch (unused) {
73
+ // An unreadable body names no site; the handler cannot read one either.
74
+ }
75
+ }
76
+ const [first = ''] = named;
77
+ return {
78
+ hostId: named.size === 1 ? first : '',
79
+ conflict: named.size > 1
80
+ };
81
+ }
82
+ /** The dispatcher's answer to a conflict: refuse, and say why. */ export function conflictingHostIdResponse() {
83
+ return Response.json({
84
+ error: 'The request names more than one site.'
85
+ }, {
86
+ status: 400
87
+ });
88
+ }
89
+
90
+ //# sourceMappingURL=plugin-api-request-host.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/plugin-api-request-host.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\n/*==========================================\n * WHICH SITE A PLUGIN API REQUEST IS ABOUT (AGL-3360).\n *\n * Both plugin API dispatchers gate a request on its site — per-site plugin\n * enablement, the org/host lockdown, the visitor rate limit — and then hand\n * the request to a handler that reads the site AGAIN, its own way. The two\n * readings disagreed in two ways, and each let a suspended site take money:\n *\n * 1. `?hostId=` won over the body. A checkout posted with\n * `?hostId=<any open site>` and `{\"hostId\": \"<suspended site>\"}` was\n * gated on the open site and charged for the suspended one: every\n * storefront, booking and reservation handler reads `body.hostId`.\n * 2. Only a JSON body was read. A urlencoded body parsed to nothing here,\n * so no gate ran at all, while `pluginRequestFromWeb` hands the handler\n * the form fields as an object and `body.hostId` is right there.\n *\n * This reads the request exactly as `pluginRequestFromWeb` will present it\n * to the handler — form fields for urlencoded, JSON for anything that\n * parses as JSON (handlers `JSON.parse` a string body themselves) — and\n * collects EVERY hostId the request names. More than one distinct site is\n * a conflict the dispatcher refuses: no honest caller names two sites, and\n * any single choice between them is a guess an attacker gets to make.\n *\n * Coerced with `String(value ?? '')`, the handlers' own coercion, so the\n * site gated is the site charged byte for byte.\n *=========================================*/\n\nexport interface PluginApiRequestHost {\n /** The one site the request names, or '' when it names none. */\n hostId: string\n /** True when the query and the body (or repeated params) disagree. */\n conflict: boolean\n}\n\nfunction bodyHostId(raw: string, contentType: string): string {\n if (!raw) return ''\n if (contentType.includes('application/x-www-form-urlencoded')) {\n return String(new URLSearchParams(raw).get('hostId') ?? '')\n }\n try {\n const parsed = JSON.parse(raw) as { hostId?: unknown } | null\n return parsed && typeof parsed === 'object'\n ? String(parsed.hostId ?? '')\n : ''\n } catch {\n return ''\n }\n}\n\n/**\n * Every site the request names, read off a clone so the handler still gets\n * the untouched stream. Never throws: an unreadable body names no site.\n */\nexport async function pluginApiRequestHost(\n request: Request,\n): Promise<PluginApiRequestHost> {\n const named = new Set<string>()\n const url = new URL(request.url)\n for (const value of url.searchParams.getAll('hostId')) {\n if (value) named.add(value)\n }\n const method = (request.method ?? 'GET').toUpperCase()\n if (method !== 'GET' && method !== 'HEAD') {\n try {\n const raw = await request.clone().text()\n const fromBody = bodyHostId(\n raw,\n request.headers.get('content-type') ?? '',\n )\n if (fromBody) named.add(fromBody)\n } catch {\n // An unreadable body names no site; the handler cannot read one either.\n }\n }\n const [first = ''] = named\n return { hostId: named.size === 1 ? first : '', conflict: named.size > 1 }\n}\n\n/** The dispatcher's answer to a conflict: refuse, and say why. */\nexport function conflictingHostIdResponse(): Response {\n return Response.json(\n { error: 'The request names more than one site.' },\n { status: 400 },\n )\n}\n"],"names":["bodyHostId","raw","contentType","includes","String","URLSearchParams","get","parsed","JSON","parse","hostId","pluginApiRequestHost","request","named","Set","url","URL","value","searchParams","getAll","add","method","toUpperCase","clone","text","fromBody","headers","first","size","conflict","conflictingHostIdResponse","Response","json","error","status"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;2CAyB2C,GAS3C,SAASA,WAAWC,GAAW,EAAEC,WAAmB;IAClD,IAAI,CAACD,KAAK,OAAO;IACjB,IAAIC,YAAYC,QAAQ,CAAC,sCAAsC;YAC/C;QAAd,OAAOC,QAAO,OAAA,IAAIC,gBAAgBJ,KAAKK,GAAG,CAAC,qBAA7B,OAA0C;IAC1D;IACA,IAAI;YAGSC;QAFX,MAAMA,SAASC,KAAKC,KAAK,CAACR;QAC1B,OAAOM,UAAU,OAAOA,WAAW,WAC/BH,QAAOG,iBAAAA,OAAOG,MAAM,YAAbH,iBAAiB,MACxB;IACN,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAEA;;;CAGC,GACD,OAAO,eAAeI,qBACpBC,OAAgB;QAOAA;IALhB,MAAMC,QAAQ,IAAIC;IAClB,MAAMC,MAAM,IAAIC,IAAIJ,QAAQG,GAAG;IAC/B,KAAK,MAAME,SAASF,IAAIG,YAAY,CAACC,MAAM,CAAC,UAAW;QACrD,IAAIF,OAAOJ,MAAMO,GAAG,CAACH;IACvB;IACA,MAAMI,SAAS,EAACT,kBAAAA,QAAQS,MAAM,YAAdT,kBAAkB,OAAOU,WAAW;IACpD,IAAID,WAAW,SAASA,WAAW,QAAQ;QACzC,IAAI;gBAIAT;YAHF,MAAMX,MAAM,MAAMW,QAAQW,KAAK,GAAGC,IAAI;YACtC,MAAMC,WAAWzB,WACfC,MACAW,uBAAAA,QAAQc,OAAO,CAACpB,GAAG,CAAC,2BAApBM,uBAAuC;YAEzC,IAAIa,UAAUZ,MAAMO,GAAG,CAACK;QAC1B,EAAE,eAAM;QACN,wEAAwE;QAC1E;IACF;IACA,MAAM,CAACE,QAAQ,EAAE,CAAC,GAAGd;IACrB,OAAO;QAAEH,QAAQG,MAAMe,IAAI,KAAK,IAAID,QAAQ;QAAIE,UAAUhB,MAAMe,IAAI,GAAG;IAAE;AAC3E;AAEA,gEAAgE,GAChE,OAAO,SAASE;IACd,OAAOC,SAASC,IAAI,CAClB;QAAEC,OAAO;IAAwC,GACjD;QAAEC,QAAQ;IAAI;AAElB"}
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import type { PluginContributions } from '../plugin-manager/plugin-contributions';
18
18
  /** The areas the verifier reports on, in the order a reviewer reads them. */
19
- export type BundleCheckId = 'parse' | 'entry' | 'self-contained' | 'size' | 'code-execution' | 'globals' | 'storage' | 'dynamic-import' | 'network' | 'obfuscation' | 'contributions';
19
+ export type BundleCheckId = 'parse' | 'entry' | 'self-contained' | 'size' | 'code-execution' | 'globals' | 'storage' | 'dynamic-import' | 'network' | 'obfuscation' | 'contributions' | 'credentials';
20
20
  export interface BundleCheckProblem {
21
21
  level: 'error' | 'warning';
22
22
  message: string;
@@ -85,8 +85,9 @@ export declare const MAX_PLUGIN_BUNDLE_BYTES = 1000000;
85
85
  * 4 — calls through an alias are resolved (AGL-1090).
86
86
  * 5 — URLs held in a constant are resolved (AGL-1093).
87
87
  * 6 — registrations are compared with the declared contributions (AGL-3116).
88
+ * 7 — inputs that collect a password, card or one-time code (AGL-3362).
88
89
  */
89
- export declare const PLUGIN_VERIFIER_VERSION = 6;
90
+ export declare const PLUGIN_VERIFIER_VERSION = 7;
90
91
  /** A verdict as stored on a `pluginVersions` doc (AGL-962). */
91
92
  export interface StoredBundleVerdict {
92
93
  ok?: boolean;