@firedrill-tools/trolley 0.1.1
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/LICENSE +201 -0
- package/README.md +219 -0
- package/firedrill/agent.target.json +16 -0
- package/firedrill/baseline.scenario.json +2109 -0
- package/firedrill/conformance.suite.json +19 -0
- package/firedrill/partner-outage.scenario.json +11 -0
- package/firedrill/processing-response-lost.scenario.json +11 -0
- package/firedrill/rate-limited.scenario.json +11 -0
- package/firedrill/tight-limits.scenario.json +16 -0
- package/firedrill/tools/trolley/app/assets/ATTRIBUTION.md +35 -0
- package/firedrill/tools/trolley/app/assets/cropped-Site-Icon-512x512-px-1-192x192.png +0 -0
- package/firedrill/tools/trolley/app/assets/fonts/OFL.txt +93 -0
- package/firedrill/tools/trolley/app/assets/full-logo.svg +1 -0
- package/firedrill/tools/trolley/app/assets/logo-black.svg +18 -0
- package/firedrill/tools/trolley/app/site/app.js +139 -0
- package/firedrill/tools/trolley/app/site/assets/fonts/inter-latin.woff2 +0 -0
- package/firedrill/tools/trolley/app/site/assets/trolley-icon.png +0 -0
- package/firedrill/tools/trolley/app/site/assets/trolley-logo.svg +1 -0
- package/firedrill/tools/trolley/app/site/base.css +99 -0
- package/firedrill/tools/trolley/app/site/batch-actions.js +139 -0
- package/firedrill/tools/trolley/app/site/components.css +149 -0
- package/firedrill/tools/trolley/app/site/icons.js +50 -0
- package/firedrill/tools/trolley/app/site/index.html +53 -0
- package/firedrill/tools/trolley/app/site/list.js +87 -0
- package/firedrill/tools/trolley/app/site/ui.js +181 -0
- package/firedrill/tools/trolley/app/site/view-balances.js +30 -0
- package/firedrill/tools/trolley/app/site/view-batch.js +86 -0
- package/firedrill/tools/trolley/app/site/view-batches.js +78 -0
- package/firedrill/tools/trolley/app/site/view-dashboard.js +79 -0
- package/firedrill/tools/trolley/app/site/view-methods.js +80 -0
- package/firedrill/tools/trolley/app/site/view-recipient.js +124 -0
- package/firedrill/tools/trolley/app/site/view-recipients.js +115 -0
- package/firedrill/tools/trolley/behavior.mjs +78 -0
- package/firedrill/tools/trolley/lib/access.mjs +21 -0
- package/firedrill/tools/trolley/lib/ids.mjs +28 -0
- package/firedrill/tools/trolley/lib/money.mjs +55 -0
- package/firedrill/tools/trolley/lib/paging.mjs +65 -0
- package/firedrill/tools/trolley/lib/pricing.mjs +88 -0
- package/firedrill/tools/trolley/lib/records.mjs +87 -0
- package/firedrill/tools/trolley/lib/serialize.mjs +152 -0
- package/firedrill/tools/trolley/lib/store.mjs +61 -0
- package/firedrill/tools/trolley/lib/time.mjs +60 -0
- package/firedrill/tools/trolley/lib/validate.mjs +111 -0
- package/firedrill/tools/trolley/lib/wire.mjs +145 -0
- package/firedrill/tools/trolley/ops/accounts-write.mjs +117 -0
- package/firedrill/tools/trolley/ops/accounts.mjs +129 -0
- package/firedrill/tools/trolley/ops/balances.mjs +23 -0
- package/firedrill/tools/trolley/ops/batches.mjs +139 -0
- package/firedrill/tools/trolley/ops/payment-build.mjs +79 -0
- package/firedrill/tools/trolley/ops/payments.mjs +112 -0
- package/firedrill/tools/trolley/ops/processing.mjs +104 -0
- package/firedrill/tools/trolley/ops/recipients-list.mjs +58 -0
- package/firedrill/tools/trolley/ops/recipients.mjs +127 -0
- package/firedrill/tools/trolley/trolley.tool.json +10095 -0
- package/firedrill/trolley-accounts.drill.json +205 -0
- package/firedrill/trolley-batch-lifecycle.drill.json +522 -0
- package/firedrill/trolley-denied.drill.json +74 -0
- package/firedrill/trolley-fresh-install.drill.json +68 -0
- package/firedrill/trolley-invalid-key.drill.json +743 -0
- package/firedrill/trolley-partner-outage.drill.json +97 -0
- package/firedrill/trolley-processing-rules.drill.json +138 -0
- package/firedrill/trolley-rate-limited.drill.json +118 -0
- package/firedrill/trolley-read-only.drill.json +468 -0
- package/firedrill/trolley-recipients.drill.json +183 -0
- package/firedrill/trolley-response-lost.drill.json +127 -0
- package/firedrill/trolley-tight-limits.drill.json +348 -0
- package/firedrill/world.json +2539 -0
- package/firedrill.json +5 -0
- package/package.json +64 -0
- package/starter.json +2108 -0
- package/test/conformance.mjs +12 -0
- package/test/flow-access.mjs +54 -0
- package/test/flow-accounts.mjs +63 -0
- package/test/flow-batches.mjs +107 -0
- package/test/flow-faults.mjs +58 -0
- package/test/flow-processing.mjs +41 -0
- package/test/flow-recipients.mjs +93 -0
- package/test/harness.mjs +87 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Shared record lookups. Malformed ids are NOT_FOUND, never a crash.
|
|
2
|
+
import { isId } from "./ids.mjs";
|
|
3
|
+
import { merchant } from "./access.mjs";
|
|
4
|
+
import { deriveStatus, primaryAccount } from "./serialize.mjs";
|
|
5
|
+
import { accountRowId, paymentRowId, scanAll, scanChildren } from "./store.mjs";
|
|
6
|
+
import { checkEmail, checkText, hasOwn, invalid } from "./validate.mjs";
|
|
7
|
+
import { fieldError } from "./validate.mjs";
|
|
8
|
+
|
|
9
|
+
export const notFound = (context, what, field) => fieldError(context, "NOT_FOUND", field, `${what} not found`);
|
|
10
|
+
|
|
11
|
+
export function recipientOrFail(context, id, field) {
|
|
12
|
+
const row = isId("R", id) ? context.state.get("recipients", id) : null;
|
|
13
|
+
if (row === null) notFound(context, "Recipient", field);
|
|
14
|
+
return row;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function batchOrFail(context, id) {
|
|
18
|
+
const row = isId("B", id) ? context.state.get("batches", id) : null;
|
|
19
|
+
if (row === null) notFound(context, "Batch");
|
|
20
|
+
return row;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function paymentOrFail(context, batchId, paymentId) {
|
|
24
|
+
const row = isId("B", batchId) && isId("P", paymentId) ? context.state.get("payments", paymentRowId(batchId, paymentId)) : null;
|
|
25
|
+
if (row === null) notFound(context, "Payment");
|
|
26
|
+
return row;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function accountOrFail(context, recipientId, accountId) {
|
|
30
|
+
const row = isId("R", recipientId) && isId("A", accountId) ? context.state.get("recipient_accounts", accountRowId(recipientId, accountId)) : null;
|
|
31
|
+
if (row === null) notFound(context, "Recipient account");
|
|
32
|
+
return row;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const accountsOf = (context, recipientId) => scanChildren(context, "recipient_accounts", recipientId);
|
|
36
|
+
export const paymentsOf = (context, batchId) => scanChildren(context, "payments", batchId);
|
|
37
|
+
export const merchantId = (context) => merchant(context).id;
|
|
38
|
+
|
|
39
|
+
/** Re-derives and stores the recipient status after account or profile changes. */
|
|
40
|
+
export function refreshRecipient(context, recipient, accounts, now) {
|
|
41
|
+
const status = deriveStatus(recipient, primaryAccount(accounts) !== null);
|
|
42
|
+
if (status === recipient.status) return recipient;
|
|
43
|
+
const next = { ...recipient, status, updatedAt: now };
|
|
44
|
+
context.state.put("recipients", recipient.id, next);
|
|
45
|
+
return next;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `{ id | email | referenceId }` → recipient row (id wins). Unknown → NOT_FOUND on `field`. */
|
|
49
|
+
export function resolveRecipient(context, reference, field) {
|
|
50
|
+
if (reference === null || typeof reference !== "object" || Array.isArray(reference)) invalid(context, field);
|
|
51
|
+
if (hasOwn(reference, "id") && reference.id !== undefined) {
|
|
52
|
+
checkText(context, reference.id, `${field}.id`, 64);
|
|
53
|
+
return recipientOrFail(context, reference.id, field);
|
|
54
|
+
}
|
|
55
|
+
let match;
|
|
56
|
+
if (hasOwn(reference, "email")) {
|
|
57
|
+
const email = checkEmail(context, reference.email, `${field}.email`);
|
|
58
|
+
match = (row) => row.email.toLowerCase() === email;
|
|
59
|
+
} else if (hasOwn(reference, "referenceId")) {
|
|
60
|
+
const ref = checkText(context, reference.referenceId, `${field}.referenceId`, 100);
|
|
61
|
+
match = (row) => row.referenceId === ref;
|
|
62
|
+
} else {
|
|
63
|
+
fieldError(context, "EMPTY_FIELD", field, "Value cannot be empty");
|
|
64
|
+
}
|
|
65
|
+
const rows = scanAll(context, "recipients").filter((row) => row.status !== "archived" && match(row));
|
|
66
|
+
if (rows.length === 0) notFound(context, "Recipient", field);
|
|
67
|
+
return rows[0];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** True when a `pending` payment in an `open` batch satisfies `predicate`. */
|
|
71
|
+
export function hasPendingOpenPayment(context, predicate) {
|
|
72
|
+
for (const batch of scanAll(context, "batches")) {
|
|
73
|
+
if (batch.status !== "open") continue;
|
|
74
|
+
if (paymentsOf(context, batch.id).some((payment) => payment.status === "pending" && predicate(payment))) return true;
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** E-mail and referenceId uniqueness among non-archived recipients other than `selfId`. */
|
|
80
|
+
export function assertUnique(context, { email, referenceId }, selfId) {
|
|
81
|
+
const rows = scanAll(context, "recipients");
|
|
82
|
+
for (const row of rows) {
|
|
83
|
+
if (row.id === selfId || row.status === "archived") continue;
|
|
84
|
+
if (email !== undefined && row.email.toLowerCase() === email) invalid(context, "email", "Email already exists");
|
|
85
|
+
if (typeof referenceId === "string" && row.referenceId === referenceId) invalid(context, "referenceId", "Reference ID already exists");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Wire objects assembled at read time from stored rows, and the recipient status rule.
|
|
2
|
+
import { fromCents, toCents } from "./money.mjs";
|
|
3
|
+
import { feeCents, minimumCents, routeType } from "./pricing.mjs";
|
|
4
|
+
|
|
5
|
+
const HOLDS = ["disabled", "suspended", "blocked"];
|
|
6
|
+
|
|
7
|
+
/** archived > hold > compliance blocked > active (address + primary account) > incomplete. */
|
|
8
|
+
export function deriveStatus(recipient, hasPrimary) {
|
|
9
|
+
if (recipient.status === "archived") return "archived";
|
|
10
|
+
if (HOLDS.includes(recipient.holdStatus)) return recipient.holdStatus;
|
|
11
|
+
if (recipient.complianceStatus === "blocked") return "blocked";
|
|
12
|
+
const a = recipient.address ?? {};
|
|
13
|
+
const complete = [a.street1, a.city, a.postalCode, a.country].every((part) => typeof part === "string" && part.trim() !== "");
|
|
14
|
+
return complete && hasPrimary ? "active" : "incomplete";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const mask = (last4) => (typeof last4 === "string" && last4 !== "" ? `*****${last4}` : null);
|
|
18
|
+
|
|
19
|
+
export function accountOut(row) {
|
|
20
|
+
return {
|
|
21
|
+
id: row.id,
|
|
22
|
+
recipientAccountId: row.id,
|
|
23
|
+
recipientId: row.recipientId,
|
|
24
|
+
type: row.type,
|
|
25
|
+
primary: row.primary,
|
|
26
|
+
status: row.status,
|
|
27
|
+
currency: row.currency,
|
|
28
|
+
country: row.country,
|
|
29
|
+
accountHolderName: row.accountHolderName ?? null,
|
|
30
|
+
accountNum: mask(row.accountNumLast4),
|
|
31
|
+
iban: mask(row.ibanLast4),
|
|
32
|
+
bankId: row.bankId ?? null,
|
|
33
|
+
branchId: row.branchId ?? null,
|
|
34
|
+
swiftBic: row.swiftBic ?? null,
|
|
35
|
+
bankName: row.bankName ?? null,
|
|
36
|
+
emailAddress: row.emailAddress ?? null,
|
|
37
|
+
phoneNumber: row.phoneNumber ?? null,
|
|
38
|
+
mailing: row.mailing ?? null,
|
|
39
|
+
routeType: routeType(row),
|
|
40
|
+
disabledAt: row.disabledAt ?? null,
|
|
41
|
+
createdAt: row.createdAt,
|
|
42
|
+
updatedAt: row.updatedAt,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Active accounts, primary first, then by creation. */
|
|
47
|
+
export function activeAccounts(rows) {
|
|
48
|
+
return rows
|
|
49
|
+
.filter((row) => row.status !== "disabled")
|
|
50
|
+
.sort((a, b) => (a.primary === b.primary ? (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : a.id < b.id ? -1 : 1) : a.primary ? -1 : 1));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function primaryAccount(rows) {
|
|
54
|
+
return rows.find((row) => row.status !== "disabled" && row.primary === true) ?? null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function recipientOut(context, row, accountRows) {
|
|
58
|
+
const accounts = activeAccounts(accountRows);
|
|
59
|
+
const primary = primaryAccount(accountRows);
|
|
60
|
+
const currency = primary?.currency ?? null;
|
|
61
|
+
return {
|
|
62
|
+
id: row.id,
|
|
63
|
+
referenceId: row.referenceId ?? null,
|
|
64
|
+
email: row.email,
|
|
65
|
+
name: row.name,
|
|
66
|
+
firstName: row.firstName ?? null,
|
|
67
|
+
lastName: row.lastName ?? null,
|
|
68
|
+
type: row.type,
|
|
69
|
+
status: row.status,
|
|
70
|
+
complianceStatus: row.complianceStatus,
|
|
71
|
+
dob: row.dob ?? null,
|
|
72
|
+
phone: row.phone ?? null,
|
|
73
|
+
language: row.language ?? "en",
|
|
74
|
+
tags: row.tags ?? [],
|
|
75
|
+
address: row.address,
|
|
76
|
+
payoutMethod: primary?.type ?? null,
|
|
77
|
+
primaryCurrency: currency,
|
|
78
|
+
routeType: routeType(primary),
|
|
79
|
+
routeMinimum: primary === null ? null : fromCents(minimumCents(context, primary.type, currency)),
|
|
80
|
+
estimatedFees: primary === null ? null : fromCents(feeCents(context, primary.type, currency)),
|
|
81
|
+
accounts: accounts.map(accountOut),
|
|
82
|
+
createdAt: row.createdAt,
|
|
83
|
+
updatedAt: row.updatedAt,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function batchOut(row, paymentRows) {
|
|
88
|
+
let amount = 0n;
|
|
89
|
+
for (const payment of paymentRows) amount += toCents(payment.sourceAmount);
|
|
90
|
+
return {
|
|
91
|
+
id: row.id,
|
|
92
|
+
status: row.status,
|
|
93
|
+
amount: fromCents(amount),
|
|
94
|
+
totalPayments: paymentRows.length,
|
|
95
|
+
currency: row.currency,
|
|
96
|
+
description: row.description,
|
|
97
|
+
tags: row.tags ?? [],
|
|
98
|
+
quoteExpiredAt: row.quoteExpiredAt ?? null,
|
|
99
|
+
sentAt: row.sentAt ?? null,
|
|
100
|
+
completedAt: row.completedAt ?? null,
|
|
101
|
+
createdAt: row.createdAt,
|
|
102
|
+
updatedAt: row.updatedAt,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Full payment object; `recipient` may be null only for a consumer seed that references a missing recipient. */
|
|
107
|
+
export function paymentOut(row, recipient, batch, merchantId) {
|
|
108
|
+
return {
|
|
109
|
+
id: row.id,
|
|
110
|
+
recipient: {
|
|
111
|
+
id: row.recipientId,
|
|
112
|
+
referenceId: recipient?.referenceId ?? null,
|
|
113
|
+
email: recipient?.email ?? null,
|
|
114
|
+
name: recipient?.name ?? null,
|
|
115
|
+
status: recipient?.status ?? null,
|
|
116
|
+
countryCode: recipient?.address?.country ?? null,
|
|
117
|
+
},
|
|
118
|
+
batch: {
|
|
119
|
+
id: row.batchId,
|
|
120
|
+
createdAt: batch?.createdAt ?? null,
|
|
121
|
+
updatedAt: batch?.updatedAt ?? null,
|
|
122
|
+
sentAt: batch?.sentAt ?? null,
|
|
123
|
+
completedAt: batch?.completedAt ?? null,
|
|
124
|
+
},
|
|
125
|
+
status: row.status,
|
|
126
|
+
sourceAmount: row.sourceAmount,
|
|
127
|
+
sourceCurrency: row.sourceCurrency,
|
|
128
|
+
targetAmount: row.targetAmount,
|
|
129
|
+
targetCurrency: row.targetCurrency,
|
|
130
|
+
exchangeRate: row.exchangeRate,
|
|
131
|
+
fees: row.fees,
|
|
132
|
+
recipientFees: row.recipientFees,
|
|
133
|
+
merchantFees: row.merchantFees,
|
|
134
|
+
coverFees: row.coverFees,
|
|
135
|
+
payoutMethod: row.payoutMethod,
|
|
136
|
+
memo: row.memo ?? "",
|
|
137
|
+
externalId: row.externalId ?? null,
|
|
138
|
+
category: row.category ?? null,
|
|
139
|
+
tags: row.tags ?? [],
|
|
140
|
+
failureMessage: row.failureMessage ?? null,
|
|
141
|
+
returnedAmount: "0.00",
|
|
142
|
+
withholdingAmount: "0.00",
|
|
143
|
+
withholdingCurrency: row.sourceCurrency,
|
|
144
|
+
equivalentWithholdingAmount: "0.00",
|
|
145
|
+
equivalentWithholdingCurrency: row.targetCurrency,
|
|
146
|
+
merchantId,
|
|
147
|
+
initiatedAt: row.initiatedAt ?? null,
|
|
148
|
+
processedAt: row.processedAt ?? null,
|
|
149
|
+
createdAt: row.createdAt,
|
|
150
|
+
updatedAt: row.updatedAt,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Bounded state access. Every scan is capped by `meta/limits.maxRows` (default and ceiling 10000); a scan that would
|
|
2
|
+
// exceed it fails INTERNAL_SERVER_ERROR instead of returning a silently truncated result.
|
|
3
|
+
const CEILING = 10000;
|
|
4
|
+
|
|
5
|
+
export function maxRows(context) {
|
|
6
|
+
const row = context.state.get("meta", "limits");
|
|
7
|
+
const value = row !== null ? row.maxRows : undefined;
|
|
8
|
+
return Number.isSafeInteger(value) && value >= 1 && value <= CEILING ? value : CEILING;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function overflow(context, bound) {
|
|
12
|
+
context.fail({ code: "INTERNAL_SERVER_ERROR", message: `state exceeds the supported bound of ${bound} rows` });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** All row values of a namespace, in rowId order. */
|
|
16
|
+
export function scanAll(context, namespace) {
|
|
17
|
+
const bound = maxRows(context);
|
|
18
|
+
const rows = context.state.scan(namespace, { limit: Math.min(bound + 1, CEILING) });
|
|
19
|
+
if (rows.length > bound) overflow(context, bound);
|
|
20
|
+
if (rows.length === CEILING && context.state.scan(namespace, { afterRowId: rows[rows.length - 1].rowId, limit: 1 }).length > 0) {
|
|
21
|
+
overflow(context, bound);
|
|
22
|
+
}
|
|
23
|
+
return rows.map((row) => row.value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Row values whose rowId starts with `<prefix>:`, in rowId order. */
|
|
27
|
+
export function scanChildren(context, namespace, prefix) {
|
|
28
|
+
const bound = maxRows(context);
|
|
29
|
+
const start = `${prefix}:`;
|
|
30
|
+
const rows = context.state.scan(namespace, { afterRowId: start, limit: Math.min(bound + 1, CEILING) });
|
|
31
|
+
const matched = [];
|
|
32
|
+
for (const row of rows) {
|
|
33
|
+
if (!row.rowId.startsWith(start)) break;
|
|
34
|
+
matched.push(row);
|
|
35
|
+
}
|
|
36
|
+
if (matched.length > bound) overflow(context, bound);
|
|
37
|
+
if (matched.length === CEILING) {
|
|
38
|
+
const more = context.state.scan(namespace, { afterRowId: matched[matched.length - 1].rowId, limit: 1 });
|
|
39
|
+
if (more.length > 0 && more[0].rowId.startsWith(start)) overflow(context, bound);
|
|
40
|
+
}
|
|
41
|
+
return matched.map((entry) => entry.value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const accountRowId = (recipientId, accountId) => `${recipientId}:${accountId}`;
|
|
45
|
+
export const paymentRowId = (batchId, paymentId) => `${batchId}:${paymentId}`;
|
|
46
|
+
|
|
47
|
+
/** UTF-8 byte length of a JSON value's encoding, without TextEncoder. */
|
|
48
|
+
export function jsonBytes(value) {
|
|
49
|
+
const text = JSON.stringify(value);
|
|
50
|
+
let bytes = 0;
|
|
51
|
+
for (let i = 0; i < text.length; i++) {
|
|
52
|
+
const code = text.charCodeAt(i);
|
|
53
|
+
if (code < 0x80) bytes += 1;
|
|
54
|
+
else if (code < 0x800) bytes += 2;
|
|
55
|
+
else if (code >= 0xd800 && code <= 0xdbff) {
|
|
56
|
+
bytes += 4;
|
|
57
|
+
i++;
|
|
58
|
+
} else bytes += 3;
|
|
59
|
+
}
|
|
60
|
+
return bytes;
|
|
61
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// ISO-8601 UTC timestamps with milliseconds from virtual time (microseconds). No wall clock.
|
|
2
|
+
const DAYS_BEFORE_MONTH = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
|
|
3
|
+
|
|
4
|
+
function isLeap(year) {
|
|
5
|
+
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Civil date from days since 1970-01-01 (Howard Hinnant's algorithm). */
|
|
9
|
+
function civil(days) {
|
|
10
|
+
const z = days + 719468;
|
|
11
|
+
const era = Math.floor(z / 146097);
|
|
12
|
+
const doe = z - era * 146097;
|
|
13
|
+
const yoe = Math.floor((doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365);
|
|
14
|
+
const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100));
|
|
15
|
+
const mp = Math.floor((5 * doy + 2) / 153);
|
|
16
|
+
const day = doy - Math.floor((153 * mp + 2) / 5) + 1;
|
|
17
|
+
const month = mp < 10 ? mp + 3 : mp - 9;
|
|
18
|
+
return { year: yoe + era * 400 + (month <= 2 ? 1 : 0), month, day };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const pad = (n, w = 2) => String(n).padStart(w, "0");
|
|
22
|
+
|
|
23
|
+
export function isoFromMs(ms) {
|
|
24
|
+
const days = Math.floor(ms / 86400000);
|
|
25
|
+
const rest = ms - days * 86400000;
|
|
26
|
+
const { year, month, day } = civil(days);
|
|
27
|
+
const h = Math.floor(rest / 3600000);
|
|
28
|
+
const m = Math.floor((rest % 3600000) / 60000);
|
|
29
|
+
const s = Math.floor((rest % 60000) / 1000);
|
|
30
|
+
return `${pad(year, 4)}-${pad(month)}-${pad(day)}T${pad(h)}:${pad(m)}:${pad(s)}.${pad(rest % 1000, 3)}Z`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function nowMs(context) {
|
|
34
|
+
return Math.floor(context.clock.nowUs() / 1000);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function nowIso(context) {
|
|
38
|
+
return isoFromMs(nowMs(context));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Milliseconds since the epoch for a stored `YYYY-MM-DDTHH:MM:SS.mmmZ` string, or null. */
|
|
42
|
+
export function msFromIso(value) {
|
|
43
|
+
const m = typeof value === "string" ? /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})\.(\d{3})Z$/.exec(value) : null;
|
|
44
|
+
if (m === null) return null;
|
|
45
|
+
const [year, month, day, h, min, s, ms] = m.slice(1).map(Number);
|
|
46
|
+
let days = (year - 1970) * 365;
|
|
47
|
+
for (let y = 1970; y < year; y++) if (isLeap(y)) days++;
|
|
48
|
+
days += DAYS_BEFORE_MONTH[month - 1] + (month > 2 && isLeap(year) ? 1 : 0) + day - 1;
|
|
49
|
+
return days * 86400000 + h * 3600000 + min * 60000 + s * 1000 + ms;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A real calendar date `YYYY-MM-DD` between 1900 and 2100. */
|
|
53
|
+
export function isDate(value) {
|
|
54
|
+
const m = typeof value === "string" ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) : null;
|
|
55
|
+
if (m === null) return false;
|
|
56
|
+
const [year, month, day] = m.slice(1).map(Number);
|
|
57
|
+
if (year < 1900 || year > 2100 || month < 1 || month > 12 || day < 1) return false;
|
|
58
|
+
const lengths = [31, isLeap(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
59
|
+
return day <= lengths[month - 1];
|
|
60
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Trolley field checks. Every failure names the field (`details.field`) so the wire envelope can carry it.
|
|
2
|
+
export const CURRENCIES = ["USD", "CAD", "EUR", "GBP", "AUD", "MXN"];
|
|
3
|
+
export const COUNTRIES = ["US", "CA", "GB", "DE", "FR", "ES", "NL", "IE", "IT", "PT", "BE", "AT", "FI", "SE", "DK", "NO", "CH", "PL",
|
|
4
|
+
"CZ", "AU", "NZ", "MX", "BR", "AR", "CO", "IN", "JP", "SG", "PH", "ZA"];
|
|
5
|
+
export const IBAN_COUNTRIES = ["DE", "FR", "ES", "NL", "IE", "GB"];
|
|
6
|
+
export const PAYOUT_METHODS = ["bank-transfer", "paypal", "check", "venmo"];
|
|
7
|
+
export const CATEGORIES = ["services", "rent", "royalties", "royalties_film", "prizes", "education", "refunds"];
|
|
8
|
+
const RESERVED_KEYS = ["__proto__", "constructor", "prototype"];
|
|
9
|
+
const EMAIL = /^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/;
|
|
10
|
+
|
|
11
|
+
/** True for C0 controls, DEL and U+FFFD (a mangled request). */
|
|
12
|
+
function hasBadCharacter(value) {
|
|
13
|
+
for (let i = 0; i < value.length; i++) {
|
|
14
|
+
const code = value.charCodeAt(i);
|
|
15
|
+
if (code < 32 || code === 127 || code === 0xfffd) return true;
|
|
16
|
+
}
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function fieldError(context, code, field, message) {
|
|
21
|
+
const safeField = typeof field === "string" ? field.slice(0, 200) : undefined;
|
|
22
|
+
context.fail({ code, message, ...(safeField === undefined ? {} : { details: { field: safeField } }) });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const invalid = (context, field, message = "Value is invalid") => fieldError(context, "INVALID_FIELD", field, message);
|
|
26
|
+
export const empty = (context, field) => fieldError(context, "EMPTY_FIELD", field, "Value cannot be empty");
|
|
27
|
+
|
|
28
|
+
export function hasOwn(object, key) {
|
|
29
|
+
return object !== null && typeof object === "object" && Object.hasOwn(object, key);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Rejects prototype-polluting keys anywhere in a caller object (explicit stack, no recursion). */
|
|
33
|
+
export function rejectReservedKeys(context, value, root = "") {
|
|
34
|
+
const stack = [[value, root]];
|
|
35
|
+
while (stack.length > 0) {
|
|
36
|
+
const [node, path] = stack.pop();
|
|
37
|
+
if (node === null || typeof node !== "object") continue;
|
|
38
|
+
for (const key of Object.keys(node)) {
|
|
39
|
+
const next = path === "" ? key : `${path}.${key}`;
|
|
40
|
+
if (RESERVED_KEYS.includes(key)) invalid(context, next, "Field is not allowed");
|
|
41
|
+
stack.push([node[key], next]);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function checkText(context, value, field, max = 1024) {
|
|
47
|
+
if (typeof value !== "string") invalid(context, field);
|
|
48
|
+
if (hasBadCharacter(value)) invalid(context, field, "Value contains invalid characters");
|
|
49
|
+
if (value.length > max) invalid(context, field, `Value must be at most ${max} characters`);
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Non-empty string (after trim) or EMPTY_FIELD. */
|
|
54
|
+
export function requireText(context, input, key, max, field = key) {
|
|
55
|
+
const value = hasOwn(input, key) ? input[key] : undefined;
|
|
56
|
+
if (value === undefined || value === null || (typeof value === "string" && value.trim() === "")) empty(context, field);
|
|
57
|
+
return checkText(context, value, field, max);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Present → checked string; absent → undefined; null → null. */
|
|
61
|
+
export function optionalText(context, input, key, max, field = key) {
|
|
62
|
+
if (!hasOwn(input, key)) return undefined;
|
|
63
|
+
if (input[key] === null) return null;
|
|
64
|
+
return checkText(context, input[key], field, max);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function checkEmail(context, value, field = "email") {
|
|
68
|
+
if (typeof value !== "string" || value.trim() === "") empty(context, field);
|
|
69
|
+
if (value.length > 254 || !EMAIL.test(value)) invalid(context, field, "Email is invalid");
|
|
70
|
+
return value.toLowerCase();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function checkEnum(context, value, allowed, field) {
|
|
74
|
+
if (typeof value !== "string" || !allowed.includes(value)) invalid(context, field);
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function checkCountry(context, value, field) {
|
|
79
|
+
if (typeof value !== "string" || value === "") empty(context, field);
|
|
80
|
+
return checkEnum(context, value.toUpperCase(), COUNTRIES, field);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function checkCurrency(context, value, field) {
|
|
84
|
+
if (typeof value !== "string" || value === "") empty(context, field);
|
|
85
|
+
return checkEnum(context, value.toUpperCase(), CURRENCIES, field);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function checkTags(context, value, field = "tags") {
|
|
89
|
+
if (!Array.isArray(value) || value.length > 20) invalid(context, field);
|
|
90
|
+
const tags = [];
|
|
91
|
+
for (const [index, tag] of value.entries()) {
|
|
92
|
+
checkText(context, tag, `${field}[${index}]`, 50);
|
|
93
|
+
if (tag.trim() === "") invalid(context, `${field}[${index}]`);
|
|
94
|
+
if (!tags.includes(tag)) tags.push(tag);
|
|
95
|
+
}
|
|
96
|
+
return tags;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function checkDigits(context, value, field, min, max) {
|
|
100
|
+
if (typeof value !== "string" || value === "") empty(context, field);
|
|
101
|
+
if (!/^[0-9]+$/.test(value) || value.length < min || value.length > max) invalid(context, field, "Value must contain only digits");
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Rejects fields the operation does not accept (read-only or unknown), naming the first one. */
|
|
106
|
+
export function onlyFields(context, input, allowed, readOnly = []) {
|
|
107
|
+
for (const key of Object.keys(input)) {
|
|
108
|
+
if (readOnly.includes(key)) invalid(context, key, "Field is read-only");
|
|
109
|
+
if (!allowed.includes(key)) invalid(context, key, "Field is not allowed");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Trolley REST wire helpers: pure request decoding and the `{ ok:false, errors:[{ code, field, message }] }` envelope.
|
|
2
|
+
// No state, no clock. Statuses are framework-owned (declared per route in the manifest).
|
|
3
|
+
export const DECODE_ERROR_KEY = "__trolleyDecodeError";
|
|
4
|
+
const MAX_DEPTH = 512;
|
|
5
|
+
|
|
6
|
+
function carry(field, message) {
|
|
7
|
+
return { arguments: { [DECODE_ERROR_KEY]: { code: "invalid_field", field, message } } };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function lastHeader(request, name) {
|
|
11
|
+
const values = request.headers?.[name];
|
|
12
|
+
return Array.isArray(values) && values.length > 0 ? values[values.length - 1] : undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const IDEMPOTENCY_HEADER = "Idempotency-Key";
|
|
16
|
+
const MAX_IDEMPOTENCY_KEY = 255;
|
|
17
|
+
/** Framework idempotency outcomes rendered as Trolley `invalid_field` on the header (Trolley documents no such header). */
|
|
18
|
+
const IDEMPOTENCY_MESSAGES = new Map([
|
|
19
|
+
["world.IDEMPOTENCY_CONFLICT", "Idempotency-Key was already used with a different request body"],
|
|
20
|
+
["framework.IDEMPOTENCY_NOT_SUPPORTED", "Idempotency-Key is not supported on this endpoint"],
|
|
21
|
+
["framework.IDEMPOTENCY_REQUIRED", "Idempotency-Key is required on this endpoint"],
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
/** `{ idempotencyKey }` for a usable header, `{}` when absent or blank, or a carried invalid_field for an over-long key. */
|
|
25
|
+
function idempotency(request) {
|
|
26
|
+
const key = lastHeader(request, "idempotency-key");
|
|
27
|
+
if (typeof key !== "string" || key.trim().length === 0) return {};
|
|
28
|
+
if (key.length > MAX_IDEMPOTENCY_KEY) return carry(IDEMPOTENCY_HEADER, `Idempotency-Key must be at most ${MAX_IDEMPOTENCY_KEY} characters`);
|
|
29
|
+
return { idempotencyKey: key };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function hasReplacement(value) {
|
|
33
|
+
return typeof value === "string" && value.includes("�");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Depth of a JSON value, measured with an explicit stack (stops counting past MAX_DEPTH). */
|
|
37
|
+
function tooDeep(value) {
|
|
38
|
+
const stack = [[value, 1]];
|
|
39
|
+
while (stack.length > 0) {
|
|
40
|
+
const [node, depth] = stack.pop();
|
|
41
|
+
if (node === null || typeof node !== "object") continue;
|
|
42
|
+
if (depth > MAX_DEPTH) return true;
|
|
43
|
+
for (const child of Array.isArray(node) ? node : Object.values(node)) {
|
|
44
|
+
if (child !== null && typeof child === "object") stack.push([child, depth + 1]);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Builds a codec decode function. `query` maps allowed query names to "integer" | "string"; `body` is "json", "empty"
|
|
52
|
+
* or "none". Decode never throws: unmappable input travels under DECODE_ERROR_KEY, which every closed input schema rejects,
|
|
53
|
+
* and `encode` renders it as Trolley's 400 invalid_field.
|
|
54
|
+
*/
|
|
55
|
+
export function decoder({ query = {}, body = "none", pathKeys = {} } = {}) {
|
|
56
|
+
return (request) => {
|
|
57
|
+
const args = Object.create(null);
|
|
58
|
+
for (const [name, value] of Object.entries(request.path ?? {})) {
|
|
59
|
+
if (hasReplacement(value)) return carry(pathKeys[name] ?? name, "Value contains invalid characters");
|
|
60
|
+
args[pathKeys[name] ?? name] = value;
|
|
61
|
+
}
|
|
62
|
+
for (const [name, values] of Object.entries(request.query ?? {})) {
|
|
63
|
+
if (!Object.hasOwn(query, name)) return carry(name.slice(0, 200), "Unsupported query parameter");
|
|
64
|
+
const value = Array.isArray(values) && values.length > 0 ? values[values.length - 1] : "";
|
|
65
|
+
if (hasReplacement(value)) return carry(name, "Value contains invalid characters");
|
|
66
|
+
if (query[name] === "integer") {
|
|
67
|
+
if (!/^[0-9]{1,9}$/.test(value)) return carry(name, "Value must be a positive integer");
|
|
68
|
+
args[name] = Number(value);
|
|
69
|
+
} else {
|
|
70
|
+
args[name] = value;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (body === "json") {
|
|
74
|
+
const value = request.body?.kind === "json" ? request.body.value : undefined;
|
|
75
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return carry(null, "Request body must be a JSON object");
|
|
76
|
+
if (tooDeep(value)) return carry(null, "Request body is nested too deeply");
|
|
77
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
78
|
+
if (Object.hasOwn(args, key)) return carry(key.slice(0, 200), "Field is not allowed");
|
|
79
|
+
args[key] = entry;
|
|
80
|
+
}
|
|
81
|
+
} else if (body === "empty") {
|
|
82
|
+
const text = request.body?.kind === "text" ? request.body.value.trim() : "";
|
|
83
|
+
if (text !== "" && text !== "{}") {
|
|
84
|
+
let parsed;
|
|
85
|
+
try {
|
|
86
|
+
parsed = JSON.parse(text);
|
|
87
|
+
} catch {
|
|
88
|
+
return carry(null, "Request body must be empty or a JSON object");
|
|
89
|
+
}
|
|
90
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return carry(null, "Request body must be empty or a JSON object");
|
|
91
|
+
const first = Object.keys(parsed)[0];
|
|
92
|
+
if (first !== undefined) return carry(first.slice(0, 200), "Field is not allowed");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const key = idempotency(request);
|
|
96
|
+
if (Object.hasOwn(key, "arguments")) return key;
|
|
97
|
+
return { arguments: { ...args }, ...key };
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function carried(args) {
|
|
102
|
+
if (args === null || typeof args !== "object") return undefined;
|
|
103
|
+
const keys = Object.keys(args);
|
|
104
|
+
if (keys.length !== 1 || keys[0] !== DECODE_ERROR_KEY) return undefined;
|
|
105
|
+
const error = args[DECODE_ERROR_KEY];
|
|
106
|
+
return error !== null && typeof error === "object" && typeof error.message === "string" ? error : undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function entry(code, field, message) {
|
|
110
|
+
const out = { code, message: String(message).slice(0, 1000) };
|
|
111
|
+
if (typeof field === "string" && field.length > 0) out.field = field.slice(0, 200);
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The Trolley error body for a non-ok outcome. */
|
|
116
|
+
export function errorBody(invocation, outcome) {
|
|
117
|
+
const error = outcome.error ?? {};
|
|
118
|
+
const decodeError = outcome.status === "invalid" ? carried(invocation.arguments) : undefined;
|
|
119
|
+
if (decodeError !== undefined) return { ok: false, errors: [entry("invalid_field", decodeError.field, decodeError.message)] };
|
|
120
|
+
if (outcome.status === "denied") {
|
|
121
|
+
return { ok: false, errors: [entry("not_authorized", undefined, "Authentication not permitted to access resource: this actor is not granted the operation in this Firedrill world")] };
|
|
122
|
+
}
|
|
123
|
+
if (outcome.status === "unsupported") return { ok: false, errors: [entry("not_found", undefined, "Resource not found")] };
|
|
124
|
+
if (outcome.status === "invalid") {
|
|
125
|
+
const idempotencyMessage = IDEMPOTENCY_MESSAGES.get(error.code);
|
|
126
|
+
if (idempotencyMessage !== undefined) return { ok: false, errors: [entry("invalid_field", IDEMPOTENCY_HEADER, idempotencyMessage)] };
|
|
127
|
+
const issue = Array.isArray(error.issues) ? error.issues.find((item) => Array.isArray(item?.path)) : undefined;
|
|
128
|
+
const field = issue === undefined ? undefined : issue.path.filter((part) => typeof part === "string" || typeof part === "number").join(".");
|
|
129
|
+
return { ok: false, errors: [entry("invalid_field", field, issue?.message ?? "Value is invalid")] };
|
|
130
|
+
}
|
|
131
|
+
const code = String(error.code ?? "tool.INTERNAL_SERVER_ERROR").replace(/^[a-z-]+\./, "").toLowerCase();
|
|
132
|
+
const field = error.details !== null && typeof error.details === "object" ? error.details.field : undefined;
|
|
133
|
+
return { ok: false, errors: [entry(code, field, error.message ?? "Internal server error")] };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A route codec: decode with `options`, pass success bodies through, render errors in Trolley's envelope. */
|
|
137
|
+
export function route(options) {
|
|
138
|
+
return {
|
|
139
|
+
decode: decoder(options),
|
|
140
|
+
encode({ invocation, outcome }) {
|
|
141
|
+
if (outcome.status === "ok") return { body: { kind: "json", value: outcome.value } };
|
|
142
|
+
return { body: { kind: "json", value: errorBody(invocation, outcome) } };
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|