@aglyn/aglyn 1.0.0-beta.200 → 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 (56) hide show
  1. package/package.json +11 -11
  2. package/src/lib/app-utils/artifact-list-keys.js +10 -0
  3. package/src/lib/app-utils/artifact-list-keys.js.map +1 -1
  4. package/src/lib/app-utils/crm.d.ts +19 -0
  5. package/src/lib/app-utils/crm.js +27 -1
  6. package/src/lib/app-utils/crm.js.map +1 -1
  7. package/src/lib/app-utils/docs-help.generated.d.ts +4 -4
  8. package/src/lib/app-utils/docs-help.generated.js +10 -0
  9. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  10. package/src/lib/app-utils/docs-index.generated.js +78 -64
  11. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  12. package/src/lib/app-utils/email-state.d.ts +7 -3
  13. package/src/lib/app-utils/email-state.js +22 -6
  14. package/src/lib/app-utils/email-state.js.map +1 -1
  15. package/src/lib/app-utils/forms.d.ts +101 -9
  16. package/src/lib/app-utils/forms.js +137 -7
  17. package/src/lib/app-utils/forms.js.map +1 -1
  18. package/src/lib/app-utils/health-report.d.ts +3 -3
  19. package/src/lib/app-utils/health-report.js +9 -9
  20. package/src/lib/app-utils/health-report.js.map +1 -1
  21. package/src/lib/app-utils/host-naming.d.ts +12 -7
  22. package/src/lib/app-utils/host-naming.js +18 -3
  23. package/src/lib/app-utils/host-naming.js.map +1 -1
  24. package/src/lib/app-utils/lockdown.d.ts +19 -0
  25. package/src/lib/app-utils/lockdown.js +21 -0
  26. package/src/lib/app-utils/lockdown.js.map +1 -1
  27. package/src/lib/app-utils/media-embedded-fields.d.ts +40 -0
  28. package/src/lib/app-utils/media-embedded-fields.js +77 -0
  29. package/src/lib/app-utils/media-embedded-fields.js.map +1 -1
  30. package/src/lib/app-utils/media-metadata.d.ts +17 -1
  31. package/src/lib/app-utils/media-metadata.js +22 -2
  32. package/src/lib/app-utils/media-metadata.js.map +1 -1
  33. package/src/lib/app-utils/payment-risk.d.ts +75 -0
  34. package/src/lib/app-utils/payment-risk.js +139 -0
  35. package/src/lib/app-utils/payment-risk.js.map +1 -0
  36. package/src/lib/app-utils/plugin-api-request-host.d.ts +29 -0
  37. package/src/lib/app-utils/plugin-api-request-host.js +90 -0
  38. package/src/lib/app-utils/plugin-api-request-host.js.map +1 -0
  39. package/src/lib/app-utils/plugin-bundle-checks.d.ts +3 -2
  40. package/src/lib/app-utils/plugin-bundle-checks.js +76 -5
  41. package/src/lib/app-utils/plugin-bundle-checks.js.map +1 -1
  42. package/src/lib/app-utils/webhook-delivery.js +4 -1
  43. package/src/lib/app-utils/webhook-delivery.js.map +1 -1
  44. package/src/lib/plugin-manager/billing-webhook-hooks.d.ts +9 -0
  45. package/src/lib/plugin-manager/billing-webhook-hooks.js +5 -1
  46. package/src/lib/plugin-manager/billing-webhook-hooks.js.map +1 -1
  47. package/src/lib/plugin-manager/plugin-activity-actions.d.ts +9 -1
  48. package/src/lib/plugin-manager/plugin-activity-actions.js +11 -1
  49. package/src/lib/plugin-manager/plugin-activity-actions.js.map +1 -1
  50. package/src/lib/plugin-manager/plugin-contact-capture.d.ts +8 -0
  51. package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
  52. package/src/lib/plugin-manager/plugin-events.d.ts +22 -0
  53. package/src/lib/plugin-manager/plugin-events.js.map +1 -1
  54. package/src/lib/plugin-manager/plugin-jobs.d.ts +8 -4
  55. package/src/lib/plugin-manager/plugin-jobs.js +8 -4
  56. package/src/lib/plugin-manager/plugin-jobs.js.map +1 -1
@@ -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;
@@ -34,6 +34,9 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
34
34
  * chain (sandbox iframe + per-manifest CSP / staff review + realm signing /
35
35
  * sha pinning / the kill switch).
36
36
  */ import { parse } from "acorn";
37
+ // The leaf, not the barrel: the phishing screen's own reading of an input's
38
+ // type, autocomplete token and raw HTML (AGL-3362).
39
+ import { credentialForInputAttribute, credentialInHtml } from "@aglyn/shared-util-email/hosted-page-screen";
37
40
  const CHECK_LABELS = {
38
41
  parse: 'Parses as an ES module',
39
42
  entry: 'Exports an entry point',
@@ -45,7 +48,8 @@ const CHECK_LABELS = {
45
48
  'dynamic-import': 'No dynamic import of unknown code',
46
49
  network: 'Network calls match the manifest',
47
50
  obfuscation: 'No obfuscation shapes',
48
- contributions: 'Registrations match the declared contributions'
51
+ contributions: 'Registrations match the declared contributions',
52
+ credentials: 'No password, card or one-time-code inputs'
49
53
  };
50
54
  /** Check order as displayed — findings first would reorder on every bundle. */ const CHECK_ORDER = [
51
55
  'parse',
@@ -58,7 +62,8 @@ const CHECK_LABELS = {
58
62
  'dynamic-import',
59
63
  'network',
60
64
  'obfuscation',
61
- 'contributions'
65
+ 'contributions',
66
+ 'credentials'
62
67
  ];
63
68
  export const MAX_PLUGIN_BUNDLE_BYTES = 1000000;
64
69
  /**
@@ -77,7 +82,8 @@ export const MAX_PLUGIN_BUNDLE_BYTES = 1000000;
77
82
  * 4 — calls through an alias are resolved (AGL-1090).
78
83
  * 5 — URLs held in a constant are resolved (AGL-1093).
79
84
  * 6 — registrations are compared with the declared contributions (AGL-3116).
80
- */ export const PLUGIN_VERIFIER_VERSION = 6;
85
+ * 7 — inputs that collect a password, card or one-time code (AGL-3362).
86
+ */ export const PLUGIN_VERIFIER_VERSION = 7;
81
87
  /**
82
88
  * Whether a stored verdict may be served instead of re-running the checks.
83
89
  *
@@ -505,6 +511,68 @@ const BASE64_BLOB = /^[A-Za-z0-9+/\r\n]+={0,2}$/;
505
511
  unused('the site feature', (_declared_site3 = declared.site) == null ? void 0 : _declared_site3.features, (_found_site3 = found.site) == null ? void 0 : _found_site3.features);
506
512
  unused('the console slot', (_declared_console4 = declared.console) == null ? void 0 : _declared_console4.slots, (_found_console4 = found.console) == null ? void 0 : _found_console4.slots);
507
513
  }
514
+ /**
515
+ * Inputs a bundle builds that collect a password, a card number or a
516
+ * one-time code (AGL-3362) — a reviewer's QUESTION, never a failure.
517
+ *
518
+ * The phishing screen holds a published page whose author defined such a
519
+ * field, but a plugin renders its own inputs from code the page screen never
520
+ * reads. So the same reading is applied here, to the bundle, at review: an
521
+ * input's `type` or `autocomplete` in an object literal (`createElement`
522
+ * props, a JSX runtime call, a DOM `Object.assign`), in `setAttribute`, and
523
+ * in any HTML string. A warning rather than an error because a legitimate
524
+ * plugin can own a sign-in (a members-only area, a booking account); what the
525
+ * reviewer asks is whether its credentials go anywhere but its declared
526
+ * origins. The sandbox tier cannot post a native form at all — its frame
527
+ * has no `allow-forms` and its CSP says `form-action 'none'` — so the
528
+ * question is about `connect-src`, which the network check already lists.
529
+ */ function credentialInputsIn(program, constants) {
530
+ const found = new Set();
531
+ const keyOf = (node)=>{
532
+ const key = node['key'];
533
+ if (!key) return null;
534
+ if (!node['computed'] && key.type === 'Identifier') return key['name'];
535
+ return staticString(key, constants);
536
+ };
537
+ walk(program, (node)=>{
538
+ if (node.type === 'Property') {
539
+ const key = keyOf(node);
540
+ const value = staticString(node['value'], constants);
541
+ const field = key && value !== null ? credentialForInputAttribute(key, value) : null;
542
+ if (field) found.add(field);
543
+ } else if (node.type === 'CallExpression') {
544
+ var _node_arguments;
545
+ const callee = node['callee'];
546
+ const args = (_node_arguments = node['arguments']) != null ? _node_arguments : [];
547
+ if ((callee == null ? void 0 : callee.type) === 'MemberExpression' && propertyName(callee, constants) === 'setAttribute') {
548
+ const name = staticString(args[0], constants);
549
+ const value = staticString(args[1], constants);
550
+ const field = name !== null && value !== null ? credentialForInputAttribute(name, value) : null;
551
+ if (field) found.add(field);
552
+ }
553
+ } else if (node.type === 'Literal' && typeof node['value'] === 'string') {
554
+ const field = credentialInHtml(node['value']);
555
+ if (field) found.add(field);
556
+ } else if (node.type === 'TemplateLiteral') {
557
+ var _node_quasis;
558
+ const text = ((_node_quasis = node['quasis']) != null ? _node_quasis : []).map((quasi)=>{
559
+ var _ref;
560
+ var _quasi_value;
561
+ return String((_ref = (_quasi_value = quasi['value']) == null ? void 0 : _quasi_value.cooked) != null ? _ref : '');
562
+ }).join('');
563
+ const field = credentialInHtml(text);
564
+ if (field) found.add(field);
565
+ }
566
+ });
567
+ const words = {
568
+ password: 'a password',
569
+ card: 'a card number or security code',
570
+ otp: 'a one-time code'
571
+ };
572
+ return [
573
+ ...found
574
+ ].map((field)=>`builds an input that collects ${words[field]} — confirm what it is for, and that ` + 'it is sent only to the origins the manifest declares');
575
+ }
508
576
  function analyseBundle(source, options) {
509
577
  var _ref;
510
578
  const problems = [];
@@ -545,7 +613,7 @@ function analyseBundle(source, options) {
545
613
  details.set('size', `${bytes.toLocaleString('en-US')} bytes of ${maxBytes.toLocaleString('en-US')}`);
546
614
  if (bytes === 0) {
547
615
  add('error', 'size', 'bundle is empty');
548
- unknownFrom('parse', 'entry', 'self-contained', 'code-execution', 'globals', 'storage', 'dynamic-import', 'network', 'obfuscation', 'contributions');
616
+ unknownFrom('parse', 'entry', 'self-contained', 'code-execution', 'globals', 'storage', 'dynamic-import', 'network', 'obfuscation', 'contributions', 'credentials');
549
617
  return {
550
618
  ok: false,
551
619
  problems,
@@ -574,7 +642,7 @@ function analyseBundle(source, options) {
574
642
  // Nothing else ran, and saying so is the whole point of `unknown`: a
575
643
  // bundle nobody could read must not show nine green rows.
576
644
  add('error', 'parse', `bundle does not parse as an ES module: ${error instanceof Error ? error.message : String(error)}`);
577
- unknownFrom('entry', 'self-contained', 'code-execution', 'globals', 'storage', 'dynamic-import', 'network', 'obfuscation', 'contributions');
645
+ unknownFrom('entry', 'self-contained', 'code-execution', 'globals', 'storage', 'dynamic-import', 'network', 'obfuscation', 'contributions', 'credentials');
578
646
  return {
579
647
  ok: false,
580
648
  problems,
@@ -939,6 +1007,9 @@ function analyseBundle(source, options) {
939
1007
  ].filter(Boolean).join(' · '));
940
1008
  }
941
1009
  const detected = detectRegistrations(program, constants);
1010
+ for (const message of credentialInputsIn(program, constants)){
1011
+ add('warning', 'credentials', message);
1012
+ }
942
1013
  compareContributions(detected, options, add, details, (check)=>status.set(check, 'unknown'));
943
1014
  return {
944
1015
  ok: !problems.some((problem)=>problem.level === 'error'),