create-ailk 0.19.0 → 0.20.0
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/README.md +64 -7
- package/dist/add-module.js +7 -2
- package/dist/cli.js +35 -0
- package/dist/copy.d.ts +41 -0
- package/dist/copy.js +87 -21
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/fetch-template-snapshot.d.ts +21 -0
- package/dist/lib/fetch-template-snapshot.js +205 -0
- package/dist/lib/merge-three-way.d.ts +31 -0
- package/dist/lib/merge-three-way.js +65 -0
- package/dist/lib/scaffold-render-sweep.d.ts +23 -0
- package/dist/lib/scaffold-render-sweep.js +52 -0
- package/dist/module-architecture.js +64 -5
- package/dist/programmatic.d.ts +7 -1
- package/dist/programmatic.js +40 -3
- package/dist/site-roster.js +21 -4
- package/dist/spoke-checkout.d.ts +7 -0
- package/dist/spoke-checkout.js +8 -1
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +98 -3
- package/dist/template-record.d.ts +59 -0
- package/dist/template-record.js +183 -0
- package/dist/topology.js +29 -1
- package/dist/update.d.ts +48 -0
- package/dist/update.js +504 -0
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +9 -0
- package/templates/.claude/hooks/architecture-check.sh +8 -0
- package/templates/.claude/rules/loop-contract.md +129 -0
- package/templates/.claude/settings.json +3 -2
- package/templates/.env.example +13 -0
- package/templates/CLAUDE.md +1 -1
- package/templates/CONVENTIONS.md +3 -2
- package/templates/_gitignore +162 -0
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +18 -8
- package/templates/apps/api/package.json +2 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
- package/templates/apps/api/src/__tests__/setup.ts +7 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
- package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
- package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
- package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
- package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
- package/templates/apps/api/src/lib/consent-store.ts +335 -0
- package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
- package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
- package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
- package/templates/apps/api/src/lib/stripe.ts +97 -5
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +72 -14
- package/templates/apps/api/src/middleware/auth.ts +16 -4
- package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
- package/templates/apps/api/src/routes/checkout/session.ts +3 -3
- package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
- package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
- package/templates/apps/api/src/routes/consent/index.ts +18 -0
- package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
- package/templates/apps/api/src/routes/leads/index.ts +15 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
- package/templates/apps/api/src/routes/webhooks/README.md +33 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +201 -6
- package/templates/apps/api/src/server.ts +125 -18
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
- package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
- package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
- package/templates/apps/api/src/services/audit-log.ts +72 -10
- package/templates/apps/api/src/services/consent.ts +488 -3
- package/templates/apps/api/src/services/default-tenant.ts +12 -3
- package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
- package/templates/apps/api/src/services/lead-routing.ts +105 -12
- package/templates/apps/api/src/services/person.ts +501 -1
- package/templates/apps/api/src/services/project-listings.ts +7 -2
- package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
- package/templates/apps/api/src/services/request-confirm.ts +52 -0
- package/templates/apps/api/src/services/revalidation.ts +11 -1
- package/templates/apps/api/vercel.json +1 -1
- package/templates/apps/mcp/CLAUDE.md +19 -7
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
- package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
- package/templates/apps/mcp/src/tools/proposals.ts +27 -0
- package/templates/apps/mcp/src/workspace-server.ts +16 -0
- package/templates/apps/web/_gitignore +6 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +124 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +15 -9
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +44 -34
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
- package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
- package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
- package/templates/apps/web/app/robots.txt/route.ts +13 -10
- package/templates/apps/web/auth.ts +20 -1
- package/templates/apps/web/jest.config.cjs +3 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
- package/templates/apps/web/lib/account-config.ts +31 -16
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/consent-visitor.ts +103 -0
- package/templates/apps/web/lib/relay.ts +20 -0
- package/templates/apps/web/lib/topology.generated.ts +9 -0
- package/templates/apps/web/messages/en.json +121 -18
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/database/CHANGELOG.md +55 -0
- package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
- package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
- package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
- package/templates/database/__tests__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person-notes.db.test.ts +249 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
- package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
- package/templates/database/_gitignore +8 -0
- package/templates/database/inbox/schema.prisma +114 -22
- package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
- package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
- package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
- package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
- package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
- package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
- package/templates/database/ops/schema.prisma +16 -0
- package/templates/database/package.json +1 -1
- package/templates/project.yaml +6 -3
- package/versions.json +35 -35
- package/templates/.claude/rules/preserve-existing-style.md +0 -51
|
@@ -39,6 +39,12 @@
|
|
|
39
39
|
* `outcome: "foreign_site"` and answers 200. An unstamped session is the
|
|
40
40
|
* hub's own (roadmap D4) and is handled as before. The hub stamps its own
|
|
41
41
|
* sessions through `siteStamp()`. See README.md § Site filter (#7868).
|
|
42
|
+
* Since #8024 one gate before the switch does the same for every
|
|
43
|
+
* `customer.*`, `customer.subscription.*` and `invoice.*` event, reading the
|
|
44
|
+
* stamp from the Customer's or Subscription's own metadata — for an invoice,
|
|
45
|
+
* its own metadata and then the subscription snapshot under
|
|
46
|
+
* `parent.subscription_details` or (pre-`basil`) `subscription_details` —
|
|
47
|
+
* and only when `data.object.object` is the family's own type.
|
|
42
48
|
*
|
|
43
49
|
* Billing upsert (customer.created / checkout.session.completed):
|
|
44
50
|
* Billing-entity routing (org-scoped billing, #3601, spec D2): a checkout
|
|
@@ -70,6 +76,20 @@
|
|
|
70
76
|
* `"no_payment_required"` (a 100%-off promo code, #4862) — anything else
|
|
71
77
|
* logs `outcome: "unpaid"` and leaves the row `created`.
|
|
72
78
|
*
|
|
79
|
+
* Proposal paid stamp (checkout.session.completed, #8516 D13):
|
|
80
|
+
* A session carrying the server-written `metadata.ailk_proposal` and
|
|
81
|
+
* `ailk_workspace` (the client's proposal Checkout,
|
|
82
|
+
* `services/proposal-payments.ts`) stamps that proposal's `paid_at` and
|
|
83
|
+
* `paid_session_id` once, through the base-tree boundary
|
|
84
|
+
* `lib/proposal-paid-store.ts` (this file names no `piece` delegate), and
|
|
85
|
+
* writes one `people.proposal.paid` audit row as the Stripe system actor
|
|
86
|
+
* keyed by the event id: `succeeded` when stamped, `failed`
|
|
87
|
+
* (`already_paid`) when a different session already paid it, none on a
|
|
88
|
+
* same-session replay, a `warn` when no such proposal exists, and `failed`
|
|
89
|
+
* (`not_paid`) with no stamp for a session that is not paid. A session
|
|
90
|
+
* without both keys makes no query. Runs BESIDE the other stamps; never
|
|
91
|
+
* throws; logs an 8-character prefix and the outcome only.
|
|
92
|
+
*
|
|
73
93
|
* Checkout Person (checkout.session.completed, #852 D6):
|
|
74
94
|
* Chained after that stamp, `recordFlowCheckoutPerson` finds or creates the
|
|
75
95
|
* Person of the checkout row THIS session completed (matched on its own
|
|
@@ -87,10 +107,12 @@
|
|
|
87
107
|
*/
|
|
88
108
|
import { track } from "@working-theory/analytics/server";
|
|
89
109
|
import type { Prisma } from "@working-theory/database";
|
|
110
|
+
import { isId } from "@working-theory/ids";
|
|
90
111
|
import type { FastifyReply, FastifyRequest } from "fastify";
|
|
91
112
|
|
|
92
113
|
import { config } from "../../config/index.js";
|
|
93
114
|
import { prisma } from "../../lib/prisma.js";
|
|
115
|
+
import { stampProposalPaid as writeProposalPaid } from "../../lib/proposal-paid-store.js";
|
|
94
116
|
import {
|
|
95
117
|
resolveCommerceClaim,
|
|
96
118
|
commerceDisabledResponse,
|
|
@@ -104,11 +126,13 @@ import {
|
|
|
104
126
|
SITE_STAMP_KEY,
|
|
105
127
|
} from "../../lib/site.js";
|
|
106
128
|
import {
|
|
129
|
+
stripeKeyMode,
|
|
107
130
|
StripeWebhookSignatureError,
|
|
108
131
|
toSubscriptionLifecycleStatus,
|
|
109
132
|
verifyWebhookSignature,
|
|
110
133
|
} from "../../lib/stripe.js";
|
|
111
134
|
import { hasSeen, markSeen } from "../../lib/webhook-idempotency.js";
|
|
135
|
+
import { recordWorkspaceAuditAction } from "../../services/audit-log.js";
|
|
112
136
|
import { isUniqueViolation } from "../../services/default-tenant.js";
|
|
113
137
|
import {
|
|
114
138
|
DUNNING_MAX_SUBSCRIPTIONS,
|
|
@@ -274,6 +298,22 @@ export const stripeWebhookHandler = async (
|
|
|
274
298
|
|
|
275
299
|
const event = settled.event;
|
|
276
300
|
const eventIdPrefix = event.id.substring(0, 8);
|
|
301
|
+
|
|
302
|
+
// #8450 D10 (#6852 SEC-03) — an event whose livemode is not the key class
|
|
303
|
+
// this process admitted is never one of its own: a live hub must not act on
|
|
304
|
+
// a test-mode event anyone with the test key can forge, nor a test hub on a
|
|
305
|
+
// live one. Dropped before the idempotency check and any write; 200 so
|
|
306
|
+
// Stripe stops retrying, and NOT marked seen, so a corrected deployment
|
|
307
|
+
// still accepts a redelivery.
|
|
308
|
+
if ((event.livemode === true) !== (stripeKeyMode() === "live")) {
|
|
309
|
+
request.log.warn(
|
|
310
|
+
{ eventType: event.type, eventIdPrefix, outcome: "livemode_mismatch" },
|
|
311
|
+
"stripe webhook event dropped — livemode differs from the admitted key class",
|
|
312
|
+
);
|
|
313
|
+
await reply.code(200).send({ received: true });
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
277
317
|
request.log.info(
|
|
278
318
|
{ eventType: event.type, eventIdPrefix },
|
|
279
319
|
"stripe webhook event received",
|
|
@@ -292,6 +332,20 @@ export const stripeWebhookHandler = async (
|
|
|
292
332
|
}
|
|
293
333
|
markSeen(event.id);
|
|
294
334
|
|
|
335
|
+
// #8024 — the billing families' site filter, after `markSeen` as #7868's
|
|
336
|
+
// in-arm check is, so a foreign redelivery is a duplicate. One gate rather
|
|
337
|
+
// than a check per arm: the `default` arm's types are filtered too.
|
|
338
|
+
const foreignStamp = siteStampSourcesOf(event).find(isForeignSiteStamp);
|
|
339
|
+
if (foreignStamp) {
|
|
340
|
+
logForeignSiteSkip(request, {
|
|
341
|
+
stamp: foreignStamp[SITE_STAMP_KEY],
|
|
342
|
+
eventType: event.type,
|
|
343
|
+
eventIdPrefix,
|
|
344
|
+
});
|
|
345
|
+
await reply.code(200).send({ received: true });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
295
349
|
switch (event.type) {
|
|
296
350
|
case "customer.created": {
|
|
297
351
|
const customer = event.data.object as {
|
|
@@ -383,6 +437,7 @@ export const stripeWebhookHandler = async (
|
|
|
383
437
|
}),
|
|
384
438
|
),
|
|
385
439
|
stampRequestPaid(request, { session, eventIdPrefix }),
|
|
440
|
+
stampProposalPaid(request, { session, eventId: event.id, eventIdPrefix }),
|
|
386
441
|
]);
|
|
387
442
|
break;
|
|
388
443
|
}
|
|
@@ -480,11 +535,11 @@ type CheckoutSessionObject = {
|
|
|
480
535
|
};
|
|
481
536
|
|
|
482
537
|
/**
|
|
483
|
-
* The one log line for
|
|
484
|
-
* foreign stamp (first 64 characters) and this site's own alias,
|
|
485
|
-
* event correctly skipped can be told from a hub event whose alias
|
|
486
|
-
* site labels only: it takes the stamp, never the
|
|
487
|
-
* or amount can reach it.
|
|
538
|
+
* The one log line for an event another site stamped (#7868 D3, #8024 D3).
|
|
539
|
+
* Carries the foreign stamp (first 64 characters) and this site's own alias,
|
|
540
|
+
* so a spoke event correctly skipped can be told from a hub event whose alias
|
|
541
|
+
* drifted — site labels only: it takes the stamp, never the object, so no
|
|
542
|
+
* buyer field, customer id or amount can reach it.
|
|
488
543
|
*/
|
|
489
544
|
function logForeignSiteSkip(
|
|
490
545
|
request: FastifyRequest,
|
|
@@ -506,10 +561,61 @@ function logForeignSiteSkip(
|
|
|
506
561
|
stamp: stamp?.slice(0, 64),
|
|
507
562
|
ownSite: currentSiteId(),
|
|
508
563
|
},
|
|
509
|
-
"
|
|
564
|
+
"stripe event stamped for another site — skipped",
|
|
510
565
|
);
|
|
511
566
|
}
|
|
512
567
|
|
|
568
|
+
/** The object type whose metadata carries the site for each billing family (#8024 D2). */
|
|
569
|
+
function stampedObjectTypeOf(eventType: string): "subscription" | "customer" | "invoice" | null {
|
|
570
|
+
if (eventType.startsWith("customer.subscription.")) return "subscription";
|
|
571
|
+
if (eventType.startsWith("customer.")) return "customer";
|
|
572
|
+
if (eventType.startsWith("invoice.")) return "invoice";
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** `value` when it is a non-null object, else `null` — every step of a stamp read is guarded (NF1). */
|
|
577
|
+
function objectOrNull(value: unknown): Record<string, unknown> | null {
|
|
578
|
+
return typeof value === "object" && value !== null
|
|
579
|
+
? (value as Record<string, unknown>)
|
|
580
|
+
: null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* The metadata objects that may carry an event's site stamp (#8024 D2), in the
|
|
585
|
+
* order they are checked: a Subscription's or a Customer's own metadata; an
|
|
586
|
+
* invoice's own metadata, then the subscription snapshot at the pinned API
|
|
587
|
+
* version (`parent.subscription_details.metadata`), then at a pre-`basil` one
|
|
588
|
+
* (`subscription_details.metadata`). Empty for every other event type, and
|
|
589
|
+
* when `data.object.object` is not the family's own type — a
|
|
590
|
+
* `customer.source.*` card, say. Reads the payload alone and never throws on
|
|
591
|
+
* any shape: an absent, `null` or non-object step yields no source, and
|
|
592
|
+
* `isForeignSiteStamp` treats a non-string stamp as unstamped.
|
|
593
|
+
*/
|
|
594
|
+
function siteStampSourcesOf(event: {
|
|
595
|
+
type: string;
|
|
596
|
+
data?: unknown;
|
|
597
|
+
}): Record<string, string>[] {
|
|
598
|
+
const objectType = stampedObjectTypeOf(event.type);
|
|
599
|
+
const object = objectOrNull(objectOrNull(event.data)?.["object"]);
|
|
600
|
+
if (objectType === null || object?.["object"] !== objectType) return [];
|
|
601
|
+
|
|
602
|
+
const candidates =
|
|
603
|
+
objectType === "invoice"
|
|
604
|
+
? [
|
|
605
|
+
object["metadata"],
|
|
606
|
+
objectOrNull(objectOrNull(object["parent"])?.["subscription_details"])?.[
|
|
607
|
+
"metadata"
|
|
608
|
+
],
|
|
609
|
+
objectOrNull(object["subscription_details"])?.["metadata"],
|
|
610
|
+
]
|
|
611
|
+
: [object["metadata"]];
|
|
612
|
+
// The cast only narrows the value type for `isForeignSiteStamp`, which
|
|
613
|
+
// reads the stamp as `unknown` and `typeof`-checks it.
|
|
614
|
+
return candidates
|
|
615
|
+
.map(objectOrNull)
|
|
616
|
+
.filter((source) => source !== null) as Record<string, string>[];
|
|
617
|
+
}
|
|
618
|
+
|
|
513
619
|
/**
|
|
514
620
|
* Minimal projected shape of a Stripe Invoice object for the dunning arms
|
|
515
621
|
* (#6034 FR-1). `subscription` and `customer` are EXPANDABLE Stripe fields —
|
|
@@ -786,6 +892,95 @@ async function stampRequestPaid(
|
|
|
786
892
|
}
|
|
787
893
|
}
|
|
788
894
|
|
|
895
|
+
/**
|
|
896
|
+
* #8516 D13 — stamp a client's proposal paid from its own Checkout Session,
|
|
897
|
+
* and audit the outcome as Stripe. The two ids come only from the session's
|
|
898
|
+
* metadata, which this server wrote when it opened the session — a payer can
|
|
899
|
+
* never set it — and the stamp is scoped by both. Never throws.
|
|
900
|
+
*/
|
|
901
|
+
async function stampProposalPaid(
|
|
902
|
+
request: FastifyRequest,
|
|
903
|
+
{
|
|
904
|
+
session,
|
|
905
|
+
eventId,
|
|
906
|
+
eventIdPrefix,
|
|
907
|
+
}: { session: CheckoutSessionObject; eventId: string; eventIdPrefix: string },
|
|
908
|
+
): Promise<void> {
|
|
909
|
+
const pieceId = session.metadata?.["ailk_proposal"];
|
|
910
|
+
const workspaceId = session.metadata?.["ailk_workspace"];
|
|
911
|
+
const sessionId = session.id;
|
|
912
|
+
if (
|
|
913
|
+
typeof pieceId !== "string" ||
|
|
914
|
+
typeof workspaceId !== "string" ||
|
|
915
|
+
typeof sessionId !== "string" ||
|
|
916
|
+
!isId("piece", pieceId) ||
|
|
917
|
+
!isId("workspace", workspaceId)
|
|
918
|
+
) {
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
const ref = pieceId.substring(6, 14);
|
|
922
|
+
|
|
923
|
+
const audit = async (outcome: { ok: true } | { ok: false; reason: string }) => {
|
|
924
|
+
const result = await recordWorkspaceAuditAction({
|
|
925
|
+
workspaceId,
|
|
926
|
+
actor: { type: "system", systemId: "stripe" },
|
|
927
|
+
domain: "people",
|
|
928
|
+
tool: "proposal.paid",
|
|
929
|
+
outcome: outcome.ok ? "succeeded" : "failed",
|
|
930
|
+
...(!outcome.ok && { failureReason: outcome.reason }),
|
|
931
|
+
idempotencyKey: eventId,
|
|
932
|
+
targetType: "piece",
|
|
933
|
+
targetId: pieceId,
|
|
934
|
+
});
|
|
935
|
+
if (!result.ok) {
|
|
936
|
+
request.log.error(
|
|
937
|
+
{ eventIdPrefix, ref, error: result.error.kind },
|
|
938
|
+
"proposal paid: audit write failed",
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
if (
|
|
944
|
+
session.payment_status !== "paid" &&
|
|
945
|
+
session.payment_status !== "no_payment_required"
|
|
946
|
+
) {
|
|
947
|
+
request.log.warn(
|
|
948
|
+
{ eventIdPrefix, ref, outcome: "not_paid" },
|
|
949
|
+
"proposal paid: completed session is not paid — not stamping",
|
|
950
|
+
);
|
|
951
|
+
await audit({ ok: false, reason: "not_paid" });
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
try {
|
|
956
|
+
const outcome = await writeProposalPaid(workspaceId, pieceId, sessionId, new Date());
|
|
957
|
+
if (outcome === "stamped") {
|
|
958
|
+
request.log.info({ eventIdPrefix, ref, outcome }, "proposal stamped paid");
|
|
959
|
+
await audit({ ok: true });
|
|
960
|
+
} else if (outcome === "already_paid") {
|
|
961
|
+
request.log.warn(
|
|
962
|
+
{ eventIdPrefix, ref, outcome },
|
|
963
|
+
"proposal paid: already paid by another session — a second charge",
|
|
964
|
+
);
|
|
965
|
+
await audit({ ok: false, reason: "already_paid" });
|
|
966
|
+
} else if (outcome === "not_found") {
|
|
967
|
+
request.log.warn({ eventIdPrefix, ref, outcome }, "proposal paid: no such proposal");
|
|
968
|
+
} else {
|
|
969
|
+
request.log.info({ eventIdPrefix, ref, outcome }, "proposal paid: replay");
|
|
970
|
+
}
|
|
971
|
+
} catch (err) {
|
|
972
|
+
request.log.error(
|
|
973
|
+
{
|
|
974
|
+
eventIdPrefix,
|
|
975
|
+
ref,
|
|
976
|
+
outcome: "stamp_failed",
|
|
977
|
+
errName: err instanceof Error ? err.name : "UnknownError",
|
|
978
|
+
},
|
|
979
|
+
"proposal paid stamp failed — continuing",
|
|
980
|
+
);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
789
984
|
/**
|
|
790
985
|
* The hosted-invoice twin of `stampRequestPaid` (#5970 D4/D8).
|
|
791
986
|
*
|
|
@@ -55,6 +55,10 @@ import {
|
|
|
55
55
|
import { tokenRoutes } from "./routes/auth/tokens.js";
|
|
56
56
|
import { transactionalEmailRoute } from "./routes/auth/transactional-email.js";
|
|
57
57
|
import { userRoutes } from "./routes/auth/user.js";
|
|
58
|
+
import {
|
|
59
|
+
consentCookieRoute,
|
|
60
|
+
consentUnsubscribeRoute,
|
|
61
|
+
} from "./routes/consent/index.js";
|
|
58
62
|
import { getPageRoute } from "./routes/content/get-page.js";
|
|
59
63
|
import { listPagesRoute } from "./routes/content/list-pages.js";
|
|
60
64
|
import { listTypesRoute } from "./routes/content/list-types.js";
|
|
@@ -76,6 +80,7 @@ import {
|
|
|
76
80
|
registerTestAuthRoute,
|
|
77
81
|
shouldMountTestAuth,
|
|
78
82
|
} from "./routes/test/auth/session.js";
|
|
83
|
+
import { consentLinkSecretConfigured } from "./services/consent.js";
|
|
79
84
|
|
|
80
85
|
/**
|
|
81
86
|
* What a request looks like in a log line — Fastify's own default shape, minus
|
|
@@ -111,12 +116,18 @@ import {
|
|
|
111
116
|
*/
|
|
112
117
|
const SESSION_ID_LOG_SEGMENT = /^cs_[A-Za-z0-9_]+$/;
|
|
113
118
|
const LEAD_ID_LOG_SEGMENT = idRegex("lead");
|
|
119
|
+
// #8438 SEC-8438-10 — a third arm for `person_…` segments. The consents route
|
|
120
|
+
// (and every other `…/people/:personId…` route) would otherwise write a full
|
|
121
|
+
// Person id into the request log line, beside `remoteAddress`.
|
|
122
|
+
const PERSON_ID_LOG_SEGMENT = idRegex("person");
|
|
114
123
|
|
|
115
124
|
function redactSensitiveIdSegments(url: string): string {
|
|
116
125
|
return url
|
|
117
126
|
.split("/")
|
|
118
127
|
.map((segment) =>
|
|
119
|
-
SESSION_ID_LOG_SEGMENT.test(segment) ||
|
|
128
|
+
SESSION_ID_LOG_SEGMENT.test(segment) ||
|
|
129
|
+
LEAD_ID_LOG_SEGMENT.test(segment) ||
|
|
130
|
+
PERSON_ID_LOG_SEGMENT.test(segment)
|
|
120
131
|
? `${segment.slice(0, 8)}…`
|
|
121
132
|
: segment,
|
|
122
133
|
)
|
|
@@ -224,7 +235,8 @@ const WORKSPACES_SUBSCRIBERS = "./routes/workspaces/subscribers.js";
|
|
|
224
235
|
// #852 D8 — the People reads (the workspace's clients), the SAME scope.
|
|
225
236
|
const WORKSPACES_PEOPLE = "./routes/workspaces/people.js";
|
|
226
237
|
// #7877 D9 — a client's pieces, plan and outbound messages, and the
|
|
227
|
-
// workspace's piece stages: the
|
|
238
|
+
// workspace's piece stages: the writes owners, admins and (#8514) members
|
|
239
|
+
// make, the SAME scope.
|
|
228
240
|
const WORKSPACES_PIECES = "./routes/workspaces/pieces.js";
|
|
229
241
|
const WORKSPACES_PLAN = "./routes/workspaces/plan.js";
|
|
230
242
|
const WORKSPACES_MESSAGES_OUT = "./routes/workspaces/messages-out.js";
|
|
@@ -252,6 +264,14 @@ const MEMBER_ARTIFACT_REVIEW = "./routes/member/artifact-review.js";
|
|
|
252
264
|
const MEMBER_PROFILE = "./routes/member/profile.js";
|
|
253
265
|
const MEMBER_DELETION_REQUEST = "./routes/member/deletion-request.js";
|
|
254
266
|
const MEMBER_BOOKINGS = "./routes/member/bookings.js";
|
|
267
|
+
// #8438 D6 — the member's own consents, under the tenancy predicate beside the
|
|
268
|
+
// other member routes. D8 — the Admin owner's consents to Working Theory,
|
|
269
|
+
// under multi-tenant alone (the module that contributes the Admin app).
|
|
270
|
+
const MEMBER_CONSENTS = "./routes/member/consents.js";
|
|
271
|
+
// #8515 D13 — the client's onboarding read and write, under the tenancy
|
|
272
|
+
// predicate beside the request-quote pair.
|
|
273
|
+
const MEMBER_ONBOARDING = "./routes/member/onboarding.js";
|
|
274
|
+
const CONSENT_PLATFORM = "./routes/consent/platform.js";
|
|
255
275
|
// #3591 PR-B — the agent seat's management routes and an agent's own
|
|
256
276
|
// context, siblings of WORKSPACES_MEMBERS in the same workspace/multi-tenant
|
|
257
277
|
// scope.
|
|
@@ -332,7 +352,8 @@ const SLICE_PLUGINS = {
|
|
|
332
352
|
specifier: WORKSPACES_PEOPLE,
|
|
333
353
|
load: () => import(WORKSPACES_PEOPLE),
|
|
334
354
|
},
|
|
335
|
-
// #7877 D9 — the member area's operator routes,
|
|
355
|
+
// #7877 D9 — the member area's operator routes (owners, admins and, since
|
|
356
|
+
// #8514, members), the SAME scope.
|
|
336
357
|
workspacePieceRoutes: {
|
|
337
358
|
specifier: WORKSPACES_PIECES,
|
|
338
359
|
load: () => import(WORKSPACES_PIECES),
|
|
@@ -394,6 +415,11 @@ const SLICE_PLUGINS = {
|
|
|
394
415
|
specifier: MEMBER_REQUESTS,
|
|
395
416
|
load: () => import(MEMBER_REQUESTS),
|
|
396
417
|
},
|
|
418
|
+
// #8515 D13 — the onboarding read and write.
|
|
419
|
+
memberOnboardingRoutes: {
|
|
420
|
+
specifier: MEMBER_ONBOARDING,
|
|
421
|
+
load: () => import(MEMBER_ONBOARDING),
|
|
422
|
+
},
|
|
397
423
|
memberArtifactReviewRoutes: {
|
|
398
424
|
specifier: MEMBER_ARTIFACT_REVIEW,
|
|
399
425
|
load: () => import(MEMBER_ARTIFACT_REVIEW),
|
|
@@ -406,6 +432,16 @@ const SLICE_PLUGINS = {
|
|
|
406
432
|
specifier: MEMBER_DELETION_REQUEST,
|
|
407
433
|
load: () => import(MEMBER_DELETION_REQUEST),
|
|
408
434
|
},
|
|
435
|
+
// #8438 D6 — the member's own consents, the SAME scope as the writes above.
|
|
436
|
+
memberConsentRoutes: {
|
|
437
|
+
specifier: MEMBER_CONSENTS,
|
|
438
|
+
load: () => import(MEMBER_CONSENTS),
|
|
439
|
+
},
|
|
440
|
+
// #8438 D8 — the owner's consents to Working Theory (multi-tenant only).
|
|
441
|
+
consentPlatformRoutes: {
|
|
442
|
+
specifier: CONSENT_PLATFORM,
|
|
443
|
+
load: () => import(CONSENT_PLATFORM),
|
|
444
|
+
},
|
|
409
445
|
// The one that reaches services/scheduling/ (scheduling-owned, D11).
|
|
410
446
|
memberBookingRoutes: {
|
|
411
447
|
specifier: MEMBER_BOOKINGS,
|
|
@@ -643,24 +679,29 @@ async function tenantPreHandlerForBuild(): Promise<FastifyPluginCallback> {
|
|
|
643
679
|
}
|
|
644
680
|
|
|
645
681
|
/**
|
|
646
|
-
* The commerce boot-time
|
|
682
|
+
* The commerce boot-time key admission — the one other slice entry that is
|
|
647
683
|
* not a route plugin, mirroring `tenantPreHandlerSlicePlugin()` above (D3).
|
|
648
|
-
* `lib/stripe.ts` is commerce-owned (D5) and its `
|
|
684
|
+
* `lib/stripe.ts` is commerce-owned (D5) and its `admitApiStripeKey` is the
|
|
649
685
|
* sole `apps/api` reach into the Stripe SDK wrapper package, so it can no
|
|
650
686
|
* longer be a static import (spec D1) once the module is stripped. The
|
|
651
687
|
* widened `const specifier: string` annotation is what defeats `tsc`
|
|
652
688
|
* resolution, exactly as it does above.
|
|
689
|
+
*
|
|
690
|
+
* #8450 D4: called with NO argument, so the admission covers the same
|
|
691
|
+
* `process.env` key `getStripeClient()` later reads. `STRIPE_MODE` unset is
|
|
692
|
+
* today's sandbox-only check; only a restricted live key beside
|
|
693
|
+
* `STRIPE_MODE=live` on a production build comes back `"live"`.
|
|
653
694
|
*/
|
|
654
|
-
async function
|
|
695
|
+
async function admitCommerceStripeKey(): Promise<"test" | "live"> {
|
|
655
696
|
const specifier: string = "./lib/stripe.js";
|
|
656
697
|
const mod = (await loadSlice(specifier, () => import(specifier))) as {
|
|
657
|
-
|
|
698
|
+
admitApiStripeKey?: () => "test" | "live";
|
|
658
699
|
};
|
|
659
|
-
const
|
|
660
|
-
if (!
|
|
661
|
-
throw new Error("slice export
|
|
700
|
+
const admitApiStripeKeyFn = mod.admitApiStripeKey;
|
|
701
|
+
if (!admitApiStripeKeyFn) {
|
|
702
|
+
throw new Error("slice export admitApiStripeKey missing from " + specifier);
|
|
662
703
|
}
|
|
663
|
-
|
|
704
|
+
return admitApiStripeKeyFn();
|
|
664
705
|
}
|
|
665
706
|
|
|
666
707
|
/**
|
|
@@ -676,6 +717,18 @@ async function assertCommerceSandboxKey(key: string): Promise<void> {
|
|
|
676
717
|
* carries a localhost default.
|
|
677
718
|
*/
|
|
678
719
|
function warnOnUnsetProductionProtections(): void {
|
|
720
|
+
// #8438 D9 — AILK_CONSENT_LINK_SECRET is optional, but a value shorter than
|
|
721
|
+
// 32 bytes is a misconfiguration worth an error at boot: it is treated as
|
|
722
|
+
// unset, so no unsubscribe link can be minted or honored. Never the value.
|
|
723
|
+
if (
|
|
724
|
+
process.env["AILK_CONSENT_LINK_SECRET"] !== undefined &&
|
|
725
|
+
process.env["AILK_CONSENT_LINK_SECRET"] !== "" &&
|
|
726
|
+
!consentLinkSecretConfigured()
|
|
727
|
+
) {
|
|
728
|
+
logger.error(
|
|
729
|
+
"[startup] AILK_CONSENT_LINK_SECRET is set but shorter than 32 bytes — treated as unset; no unsubscribe link can be minted or honored",
|
|
730
|
+
);
|
|
731
|
+
}
|
|
679
732
|
if (config.NODE_ENV !== "production") return;
|
|
680
733
|
if (!process.env["FRONTEND_URL"]) {
|
|
681
734
|
logger.warn(
|
|
@@ -713,14 +766,17 @@ export async function build() {
|
|
|
713
766
|
// site renewed. Never throws; without DATABASE_URL the file store stays.
|
|
714
767
|
await registerLicenseKeyStore();
|
|
715
768
|
|
|
716
|
-
// Runtime
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
// since D-64 (#5796): the
|
|
720
|
-
// tree now, so the key's
|
|
721
|
-
// question. A build with no
|
|
769
|
+
// Runtime key admission (#8450 D4): refuse, before any webhook or checkout
|
|
770
|
+
// call can exercise it, every live Stripe key this process cannot vouch
|
|
771
|
+
// for — all of them unless STRIPE_MODE=live sits beside a restricted live
|
|
772
|
+
// key on a production build. UNCONDITIONAL since D-64 (#5796): the
|
|
773
|
+
// commerce primitives and lib/stripe.ts are base tree now, so the key's
|
|
774
|
+
// presence — not a module flag — is the whole question. A build with no
|
|
775
|
+
// STRIPE_SECRET_KEY has nothing to admit.
|
|
776
|
+
let stripeMode: "test" | "live" = "test";
|
|
722
777
|
if (config.STRIPE_SECRET_KEY) {
|
|
723
|
-
await
|
|
778
|
+
stripeMode = await admitCommerceStripeKey();
|
|
779
|
+
logger.info({ stripeMode }, "[startup] stripe key admitted");
|
|
724
780
|
}
|
|
725
781
|
|
|
726
782
|
// trustProxy: true (#4730) — the Vercel serverless handler
|
|
@@ -933,6 +989,10 @@ export async function build() {
|
|
|
933
989
|
await authScope.register(
|
|
934
990
|
await sliceRoutePlugin("workspaceContextRoutes"),
|
|
935
991
|
);
|
|
992
|
+
// #8438 D8 — the Admin owner's consents to Working Theory. Auth only,
|
|
993
|
+
// NOT tenant-guarded (the owner role is checked inside the route), and
|
|
994
|
+
// deliberately NOT in `HUB_SESSION_ROUTES`: a cookie session only.
|
|
995
|
+
await authScope.register(await sliceRoutePlugin("consentPlatformRoutes"));
|
|
936
996
|
}
|
|
937
997
|
|
|
938
998
|
// The leads INBOX read (#5678) — auth only, NOT tenant-guarded, for the
|
|
@@ -1013,9 +1073,13 @@ export async function build() {
|
|
|
1013
1073
|
await authScope.register(await sliceRoutePlugin("memberBillingRoutes"));
|
|
1014
1074
|
// #8170 D11 — the member writes, under the same predicate.
|
|
1015
1075
|
await authScope.register(await sliceRoutePlugin("memberRequestRoutes"));
|
|
1076
|
+
// #8515 D13 — the onboarding pair, under the same predicate.
|
|
1077
|
+
await authScope.register(await sliceRoutePlugin("memberOnboardingRoutes"));
|
|
1016
1078
|
await authScope.register(await sliceRoutePlugin("memberArtifactReviewRoutes"));
|
|
1017
1079
|
await authScope.register(await sliceRoutePlugin("memberProfileRoutes"));
|
|
1018
1080
|
await authScope.register(await sliceRoutePlugin("memberDeletionRequestRoutes"));
|
|
1081
|
+
// #8438 D6 — the member's own consents, under the same predicate.
|
|
1082
|
+
await authScope.register(await sliceRoutePlugin("memberConsentRoutes"));
|
|
1019
1083
|
}
|
|
1020
1084
|
// #8169 D9 — the two member reads that reach services/scheduling/, which
|
|
1021
1085
|
// only the scheduling module delivers: a scaffold with a tenancy module
|
|
@@ -1325,7 +1389,25 @@ export async function build() {
|
|
|
1325
1389
|
// `GET /v1/checkout/sessions/:id` — the GET in its own nested,
|
|
1326
1390
|
// rate-limited sub-scope (D16), so this preHandler applies to both while
|
|
1327
1391
|
// the GET's own limiter applies to it alone.
|
|
1392
|
+
//
|
|
1393
|
+
// #8450 § 9 SEC-14 — a LIVE process gives both routes their own per-IP
|
|
1394
|
+
// `checkout` bucket on top of the global limiter, because live, the public
|
|
1395
|
+
// GET spends one read of the shared live Stripe account per request. It
|
|
1396
|
+
// lowers the per-IP ceiling per instance (the POST's from the global 20 to
|
|
1397
|
+
// 10; the POST and GET now also share one count); like every limiter here
|
|
1398
|
+
// it is in-memory, so it does not bound the total across serverless
|
|
1399
|
+
// instances. `onRequest`, so it counts before the claim gate. Accepted
|
|
1400
|
+
// residual: a server-side caller — apps/web's server actions, an MCP host's
|
|
1401
|
+
// `purchase` tool — puts all its buyers on its own egress IP, so they share
|
|
1402
|
+
// one bucket; `AILK_RATE_LIMIT_CHECKOUT_PER_MINUTE` raises the ceiling. A
|
|
1403
|
+
// test-mode process registers nothing new.
|
|
1328
1404
|
await server.register(async (checkoutScope) => {
|
|
1405
|
+
if (stripeMode === "live") {
|
|
1406
|
+
await checkoutScope.register(rateLimitMiddleware, {
|
|
1407
|
+
scope: "checkout",
|
|
1408
|
+
perIpPerMinute: DEFAULT_PER_IP_PER_MINUTE_STRICT,
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1329
1411
|
checkoutScope.addHook("preHandler", requireCommerceClaim);
|
|
1330
1412
|
await checkoutScope.register(await sliceRoutePlugin("checkoutRoutes"));
|
|
1331
1413
|
});
|
|
@@ -1464,6 +1546,31 @@ export async function build() {
|
|
|
1464
1546
|
// 10/min on form submissions. The global unauth ceiling above applies.
|
|
1465
1547
|
await server.register(leadCaptureStatusRoute);
|
|
1466
1548
|
|
|
1549
|
+
// The consent floor (#8438 D5, D9) — the cookie banner's log and the
|
|
1550
|
+
// unsubscribe landing. Top level, outside every module gate: consent
|
|
1551
|
+
// logging is part of the never-gated privacy floor. Each is in its OWN
|
|
1552
|
+
// rate-limited scope (`consent`, `unsubscribe`) so filling one never blocks
|
|
1553
|
+
// the other; with a forward key set, a spoke's visitors are counted one by
|
|
1554
|
+
// one (both routes are on FORWARDED_VISITOR_ROUTES). Without one, the
|
|
1555
|
+
// server's own IP is the bucket — the posture of the lead routes. Neither
|
|
1556
|
+
// is behind `authScope`: the caller is a visitor, or a person in their mail.
|
|
1557
|
+
await server.register(async (consentScope) => {
|
|
1558
|
+
consentScope.register(rateLimitMiddleware, {
|
|
1559
|
+
perIpPerMinute: DEFAULT_PER_IP_PER_MINUTE_STRICT,
|
|
1560
|
+
scope: "consent",
|
|
1561
|
+
trustedKeyDigests: visitorForwardKeyDigests,
|
|
1562
|
+
});
|
|
1563
|
+
await consentScope.register(consentCookieRoute);
|
|
1564
|
+
});
|
|
1565
|
+
await server.register(async (unsubscribeScope) => {
|
|
1566
|
+
unsubscribeScope.register(rateLimitMiddleware, {
|
|
1567
|
+
perIpPerMinute: DEFAULT_PER_IP_PER_MINUTE_STRICT,
|
|
1568
|
+
scope: "unsubscribe",
|
|
1569
|
+
trustedKeyDigests: visitorForwardKeyDigests,
|
|
1570
|
+
});
|
|
1571
|
+
await unsubscribeScope.register(consentUnsubscribeRoute);
|
|
1572
|
+
});
|
|
1573
|
+
|
|
1467
1574
|
// Leads routes — rate-limit middleware scoped to this sub-router. Own
|
|
1468
1575
|
// default (10/min, pre-#4730) rather than inheriting the wider
|
|
1469
1576
|
// flows-sized default (#5111, DEF-004 — #4730's shared-default bump was
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
freshAuditIdempotencyKey,
|
|
52
52
|
recordAgentWrite,
|
|
53
53
|
recordAuditAction,
|
|
54
|
+
recordWorkspaceAuditAction,
|
|
54
55
|
redactPersonalData,
|
|
55
56
|
type AuditActor,
|
|
56
57
|
type AuditRowDigestInput,
|
|
@@ -935,3 +936,93 @@ describe("recordAgentWrite given the request (#3591 PR-B)", () => {
|
|
|
935
936
|
expect(mockLoggerError.mock.calls[0]![1]).toBe("[audit-log] agent write audit refused");
|
|
936
937
|
});
|
|
937
938
|
});
|
|
939
|
+
|
|
940
|
+
// ─── #8516 D14 — recordWorkspaceAuditAction, a row without a tenant ────────────
|
|
941
|
+
|
|
942
|
+
describe("#8516 AC-7 — recordWorkspaceAuditAction", () => {
|
|
943
|
+
const PIECE_ID = "piece_0190f3a2-7c1d-7e4a-9b2c-000000000004";
|
|
944
|
+
const ROW = {
|
|
945
|
+
domain: "people",
|
|
946
|
+
tool: "proposal.client_review",
|
|
947
|
+
outcome: "succeeded" as const,
|
|
948
|
+
idempotencyKey: "req_proposal_review",
|
|
949
|
+
targetType: "piece",
|
|
950
|
+
targetId: PIECE_ID,
|
|
951
|
+
summary: '{"by":"client","review":"approved","priced":true}',
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
it("writes the row recordAuditAction would for the same workspace and user actor", async () => {
|
|
955
|
+
const tenantWrite = stubDb();
|
|
956
|
+
const tenantResult = await recordAuditAction(
|
|
957
|
+
{ ...ROW, tenant: TENANT, actor: userActor(USER_ID) },
|
|
958
|
+
tenantWrite.db,
|
|
959
|
+
);
|
|
960
|
+
const workspaceWrite = stubDb();
|
|
961
|
+
const workspaceResult = await recordWorkspaceAuditAction(
|
|
962
|
+
{ ...ROW, workspaceId: WORKSPACE_ID, actor: { type: "user", userId: USER_ID as Id<"user"> } },
|
|
963
|
+
workspaceWrite.db,
|
|
964
|
+
);
|
|
965
|
+
|
|
966
|
+
expect(workspaceWrite.create).toHaveBeenCalledTimes(1);
|
|
967
|
+
expect(firstWrittenRow(workspaceWrite.create)).toEqual(firstWrittenRow(tenantWrite.create));
|
|
968
|
+
expect(workspaceResult).toEqual(tenantResult);
|
|
969
|
+
expect(workspaceResult).toMatchObject({ ok: true, outcome: "recorded" });
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
it("the system actor stores actor_type system and actor_id stripe", async () => {
|
|
973
|
+
const { db, create } = stubDb();
|
|
974
|
+
const result = await recordWorkspaceAuditAction(
|
|
975
|
+
{
|
|
976
|
+
workspaceId: WORKSPACE_ID,
|
|
977
|
+
actor: { type: "system", systemId: "stripe" },
|
|
978
|
+
domain: "people",
|
|
979
|
+
tool: "proposal.paid",
|
|
980
|
+
outcome: "failed",
|
|
981
|
+
failureReason: "already_paid",
|
|
982
|
+
idempotencyKey: "evt_test_proposal",
|
|
983
|
+
targetType: "piece",
|
|
984
|
+
targetId: PIECE_ID,
|
|
985
|
+
},
|
|
986
|
+
db,
|
|
987
|
+
);
|
|
988
|
+
|
|
989
|
+
expect(result).toMatchObject({ ok: true, outcome: "recorded" });
|
|
990
|
+
expect(create).toHaveBeenCalledTimes(1);
|
|
991
|
+
const row = firstWrittenRow(create);
|
|
992
|
+
expect(row).toMatchObject({
|
|
993
|
+
workspaceId: WORKSPACE_ID,
|
|
994
|
+
actorType: "system",
|
|
995
|
+
actorId: "stripe",
|
|
996
|
+
action: "people.proposal.paid",
|
|
997
|
+
targetType: "piece",
|
|
998
|
+
targetId: PIECE_ID,
|
|
999
|
+
});
|
|
1000
|
+
expect(row.metadata).toEqual({
|
|
1001
|
+
v: 2,
|
|
1002
|
+
outcome: "failed",
|
|
1003
|
+
tool: "proposal.paid",
|
|
1004
|
+
idempotencyKey: "evt_test_proposal",
|
|
1005
|
+
failureReason: "already_paid",
|
|
1006
|
+
});
|
|
1007
|
+
expect(row.metadata).not.toHaveProperty("onBehalfOf");
|
|
1008
|
+
expect(zAuditLogId.safeParse(row.id).success).toBe(true);
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
it("an invalid workspaceId is invalid_input and writes nothing", async () => {
|
|
1012
|
+
const { db, create } = stubDb();
|
|
1013
|
+
const result = await recordWorkspaceAuditAction(
|
|
1014
|
+
{ ...ROW, workspaceId: "not-a-workspace", actor: { type: "system", systemId: "stripe" } },
|
|
1015
|
+
db,
|
|
1016
|
+
);
|
|
1017
|
+
|
|
1018
|
+
expect(result).toEqual({
|
|
1019
|
+
ok: false,
|
|
1020
|
+
error: {
|
|
1021
|
+
kind: "invalid_input",
|
|
1022
|
+
message: "workspaceId is not a valid workspace id",
|
|
1023
|
+
persisted: false,
|
|
1024
|
+
},
|
|
1025
|
+
});
|
|
1026
|
+
expect(create).not.toHaveBeenCalled();
|
|
1027
|
+
});
|
|
1028
|
+
});
|