@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.
- package/package.json +11 -11
- package/src/lib/app-utils/artifact-list-keys.js +10 -0
- package/src/lib/app-utils/artifact-list-keys.js.map +1 -1
- package/src/lib/app-utils/crm.d.ts +19 -0
- package/src/lib/app-utils/crm.js +27 -1
- package/src/lib/app-utils/crm.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +4 -4
- package/src/lib/app-utils/docs-help.generated.js +10 -0
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +78 -64
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/email-state.d.ts +7 -3
- package/src/lib/app-utils/email-state.js +22 -6
- package/src/lib/app-utils/email-state.js.map +1 -1
- package/src/lib/app-utils/forms.d.ts +101 -9
- package/src/lib/app-utils/forms.js +137 -7
- package/src/lib/app-utils/forms.js.map +1 -1
- package/src/lib/app-utils/health-report.d.ts +3 -3
- package/src/lib/app-utils/health-report.js +9 -9
- package/src/lib/app-utils/health-report.js.map +1 -1
- package/src/lib/app-utils/host-naming.d.ts +12 -7
- package/src/lib/app-utils/host-naming.js +18 -3
- package/src/lib/app-utils/host-naming.js.map +1 -1
- package/src/lib/app-utils/lockdown.d.ts +19 -0
- package/src/lib/app-utils/lockdown.js +21 -0
- package/src/lib/app-utils/lockdown.js.map +1 -1
- package/src/lib/app-utils/media-embedded-fields.d.ts +40 -0
- package/src/lib/app-utils/media-embedded-fields.js +77 -0
- package/src/lib/app-utils/media-embedded-fields.js.map +1 -1
- package/src/lib/app-utils/media-metadata.d.ts +17 -1
- package/src/lib/app-utils/media-metadata.js +22 -2
- package/src/lib/app-utils/media-metadata.js.map +1 -1
- package/src/lib/app-utils/payment-risk.d.ts +75 -0
- package/src/lib/app-utils/payment-risk.js +139 -0
- package/src/lib/app-utils/payment-risk.js.map +1 -0
- package/src/lib/app-utils/plugin-api-request-host.d.ts +29 -0
- package/src/lib/app-utils/plugin-api-request-host.js +90 -0
- package/src/lib/app-utils/plugin-api-request-host.js.map +1 -0
- package/src/lib/app-utils/plugin-bundle-checks.d.ts +3 -2
- package/src/lib/app-utils/plugin-bundle-checks.js +76 -5
- package/src/lib/app-utils/plugin-bundle-checks.js.map +1 -1
- package/src/lib/app-utils/webhook-delivery.js +4 -1
- package/src/lib/app-utils/webhook-delivery.js.map +1 -1
- package/src/lib/plugin-manager/billing-webhook-hooks.d.ts +9 -0
- package/src/lib/plugin-manager/billing-webhook-hooks.js +5 -1
- package/src/lib/plugin-manager/billing-webhook-hooks.js.map +1 -1
- package/src/lib/plugin-manager/plugin-activity-actions.d.ts +9 -1
- package/src/lib/plugin-manager/plugin-activity-actions.js +11 -1
- package/src/lib/plugin-manager/plugin-activity-actions.js.map +1 -1
- package/src/lib/plugin-manager/plugin-contact-capture.d.ts +8 -0
- package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
- package/src/lib/plugin-manager/plugin-events.d.ts +22 -0
- package/src/lib/plugin-manager/plugin-events.js.map +1 -1
- package/src/lib/plugin-manager/plugin-jobs.d.ts +8 -4
- package/src/lib/plugin-manager/plugin-jobs.js +8 -4
- 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 =
|
|
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
|
-
|
|
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'),
|