create-ailk 0.22.0 → 0.23.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/component-catalog.json +149 -5
- package/component-catalog.md +7 -6
- package/dist/add-module.js +11 -9
- package/dist/add-route.d.ts +55 -1
- package/dist/add-route.js +294 -12
- package/dist/cli.js +27 -10
- package/dist/derive-architecture.d.ts +7 -5
- package/dist/derive-architecture.js +9 -32
- package/dist/lib/design-checks.js +4 -2
- package/dist/lib/env-catalog.js +0 -4
- package/dist/lib/npm-auth.d.ts +9 -0
- package/dist/lib/npm-auth.js +48 -0
- package/dist/lib/scaffold-render-sweep.d.ts +3 -2
- package/dist/lib/scaffold-render-sweep.js +5 -8
- package/dist/module-architecture.js +98 -10
- package/dist/paid-sections.d.ts +21 -2
- package/dist/paid-sections.js +69 -7
- package/dist/seed-flows.d.ts +4 -1
- package/dist/seed-flows.js +50 -4
- package/dist/spoke-checkout.d.ts +38 -35
- package/dist/spoke-checkout.js +66 -229
- package/dist/starter-grid.d.ts +0 -3
- package/dist/starter-grid.js +24 -14
- package/dist/surfaces.js +33 -12
- package/dist/sync-routes.d.ts +38 -1
- package/dist/sync-routes.js +184 -11
- package/dist/template-record.d.ts +1 -1
- package/dist/template-record.js +3 -2
- package/dist/update.js +10 -0
- package/package.json +3 -3
- package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
- package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
- package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/templates/.env.example +9 -7
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/README.md +1 -0
- package/templates/_gitignore +4 -0
- package/templates/apps/api/.env.example +27 -0
- package/templates/apps/api/CLAUDE.md +77 -16
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
- package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
- package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
- package/templates/apps/api/src/config/index.ts +109 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
- package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
- package/templates/apps/api/src/lib/error-serializer.ts +74 -7
- package/templates/apps/api/src/lib/stripe.ts +74 -4
- package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
- package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
- package/templates/apps/api/src/openapi/spec.ts +10 -7
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +325 -0
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1300 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
- package/templates/apps/api/src/routes/checkout/session.ts +57 -25
- package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
- package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
- package/templates/apps/api/src/routes/consent/account.ts +4 -2
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
- package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
- package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
- package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
- package/templates/apps/api/src/routes/content/index.ts +16 -2
- package/templates/apps/api/src/routes/content/offerings.ts +184 -0
- package/templates/apps/api/src/routes/content/pricing.ts +176 -0
- package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
- package/templates/apps/api/src/routes/leads/index.ts +17 -14
- package/templates/apps/api/src/routes/webhooks/README.md +66 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +5 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
- package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
- package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +91 -17
- package/templates/apps/api/src/server.ts +140 -51
- package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +51 -2
- package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
- package/templates/apps/api/src/services/__tests__/person.test.ts +232 -1
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +444 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +13 -5
- package/templates/apps/api/src/services/entitlements.ts +157 -1
- package/templates/apps/api/src/services/lead-routing.ts +113 -58
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +295 -0
- package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +868 -0
- package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +396 -0
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +681 -0
- package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
- package/templates/apps/api/src/services/offerings/catalog.ts +308 -0
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +385 -0
- package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
- package/templates/apps/api/src/services/offerings/connection.ts +1003 -0
- package/templates/apps/api/src/services/offerings/index.ts +614 -0
- package/templates/apps/api/src/services/offerings/source.ts +140 -0
- package/templates/apps/api/src/services/offerings/store.ts +150 -0
- package/templates/apps/api/src/services/person.ts +124 -9
- package/templates/apps/api/src/services/plan-offerings.ts +96 -30
- package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
- package/templates/apps/mcp/CLAUDE.md +22 -14
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
- package/templates/apps/mcp/__tests__/http-transport.test.ts +4 -4
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
- package/templates/apps/mcp/__tests__/server.test.ts +13 -11
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
- package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
- package/templates/apps/mcp/src/server.ts +5 -0
- package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
- package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
- package/templates/apps/mcp/src/tools/purchase.ts +30 -2
- package/templates/apps/mcp/src/workspace-server.ts +15 -0
- package/templates/apps/web/.env.example +10 -31
- package/templates/apps/web/CLAUDE.md +315 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
- package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +6 -2
- package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +352 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
- package/templates/apps/web/app/[locale]/layout.tsx +55 -4
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
- package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
- package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
- package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +57 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
- package/templates/apps/web/lib/auth-routes.ts +36 -2
- package/templates/apps/web/lib/checkout-config.ts +9 -20
- package/templates/apps/web/lib/deck-page.ts +1 -0
- package/templates/apps/web/lib/deliverable-grant.ts +18 -0
- package/templates/apps/web/lib/in-page-magnet.ts +416 -0
- package/templates/apps/web/lib/magnet-routes.ts +42 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/newsletter.actions.ts +202 -0
- package/templates/apps/web/lib/offerings-pricing.ts +326 -0
- package/templates/apps/web/lib/posting-title.ts +17 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
- package/templates/apps/web/messages/en.json +49 -15
- package/templates/apps/web/middleware.ts +12 -2
- package/templates/apps/web/security-headers.config.ts +17 -0
- package/templates/content/en/BlogList/blog.mdx +4 -71
- package/templates/content/en/BookCall/book-a-call.mdx +1 -41
- package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
- package/templates/content/en/BookCall/onsite-service.mdx +1 -17
- package/templates/content/en/BookCall/start-project.mdx +1 -17
- package/templates/content/en/ContactPage/contact.mdx +6 -63
- package/templates/content/en/HomePage/home.mdx +3 -21
- package/templates/database/CHANGELOG.md +31 -0
- package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
- package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
- package/templates/database/__tests__/offerings-migration.test.ts +197 -0
- package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
- package/templates/database/__tests__/offerings.db.test.ts +303 -0
- package/templates/database/__tests__/seed.test.ts +31 -1
- package/templates/database/auth/schema.prisma +4 -0
- package/templates/database/config/schema.prisma +91 -0
- package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
- package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
- package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
- package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
- package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +30 -2
- package/versions.json +35 -35
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
- package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
- package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
- package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
- package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
- package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
- package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
- package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
- package/templates/apps/web/lib/checkout.ts +0 -93
- package/templates/apps/web/lib/meeting-slugs.ts +0 -63
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* istanbul ignore file -- CLI bootstrap; rekeyStripeConnections and
|
|
3
|
+
formatRekeyReport are unit-tested in
|
|
4
|
+
services/offerings/__tests__/connection.test.ts. */
|
|
5
|
+
/**
|
|
6
|
+
* Re-encrypt every Organization Stripe connection under the current
|
|
7
|
+
* STRIPE_CONNECTION_KEY (#8907 D13, contract C10).
|
|
8
|
+
*
|
|
9
|
+
* Dev: `pnpm --filter @working-theory/api stripe-connections:rekey` (tsx,
|
|
10
|
+
* this file). Production: `node dist/bin/stripe-connections-rekey.js`.
|
|
11
|
+
*
|
|
12
|
+
* The rotation: set STRIPE_CONNECTION_KEY_PREVIOUS to the old key and the new
|
|
13
|
+
* key as STRIPE_CONNECTION_KEY, redeploy, run this, and unset _PREVIOUS once
|
|
14
|
+
* it prints `remaining=0`. A row counted `unreadable` decrypts under neither
|
|
15
|
+
* key; its owner reconnects or disconnects. A second run rekeys nothing.
|
|
16
|
+
*
|
|
17
|
+
* Run it with the production environment — `VERCEL_ENV=production`, or
|
|
18
|
+
* `NODE_ENV=production` off Vercel — the same predicate the API goes live on;
|
|
19
|
+
* anywhere else it prints `not_production`, reads nothing and exits 1.
|
|
20
|
+
*
|
|
21
|
+
* Prints one line of counts only — never a key, a value or a row id — and
|
|
22
|
+
* exits 1 when the key is not configured, off production, or the walk fails.
|
|
23
|
+
*/
|
|
24
|
+
import {
|
|
25
|
+
formatRekeyReport,
|
|
26
|
+
rekeyStripeConnections,
|
|
27
|
+
} from "../services/offerings/connection.js";
|
|
28
|
+
|
|
29
|
+
async function main(): Promise<void> {
|
|
30
|
+
const result = await rekeyStripeConnections();
|
|
31
|
+
if (!result.ok) {
|
|
32
|
+
console.error(formatRekeyReport(result));
|
|
33
|
+
process.exitCode = 1;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
console.log(formatRekeyReport(result));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
main().catch((err: unknown) => {
|
|
40
|
+
// The class name only: a store error's message is fixed, but nothing else
|
|
41
|
+
// a failure carries is printed.
|
|
42
|
+
const name = err instanceof Error ? err.name : "unknown";
|
|
43
|
+
console.error(`[stripe-connections-rekey] failed: ${name}`);
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file stripe-connection-key.test.ts
|
|
3
|
+
* @description STRIPE_CONNECTION_KEY and STRIPE_CONNECTION_KEY_PREVIOUS — the
|
|
4
|
+
* keys an Organization's Stripe connection is encrypted under (#8907 D3,
|
|
5
|
+
* AC-9; contract C3, C10).
|
|
6
|
+
*
|
|
7
|
+
* `resolveStripeConnectionKeys` reads both with the scheduling key's shape
|
|
8
|
+
* rule (canonical standard base64 of exactly 32 bytes), refuses a previous
|
|
9
|
+
* key with no current one or equal to it, and refuses either when it is the
|
|
10
|
+
* scheduling key — so one key never guards calendars and money. A refused
|
|
11
|
+
* key stops the API at startup; a refusal names the variable and the reason
|
|
12
|
+
* kind, never any part of the value. Unset is a supported state.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// Mock dotenv so a developer's local .env never leaks into the config module.
|
|
16
|
+
jest.mock("dotenv", () => ({
|
|
17
|
+
config: jest.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const mockLoggerError = jest.fn();
|
|
21
|
+
jest.mock("@working-theory/observability", () => ({
|
|
22
|
+
logger: {
|
|
23
|
+
error: (...args: unknown[]) => mockLoggerError(...args),
|
|
24
|
+
warn: jest.fn(),
|
|
25
|
+
info: jest.fn(),
|
|
26
|
+
debug: jest.fn(),
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
import { randomBytes } from "node:crypto";
|
|
31
|
+
import { readFileSync } from "node:fs";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
|
|
34
|
+
import { resolveStripeConnectionKeys } from "../index";
|
|
35
|
+
|
|
36
|
+
const A = randomBytes(32);
|
|
37
|
+
const B = randomBytes(32);
|
|
38
|
+
const S = randomBytes(32);
|
|
39
|
+
const b64 = (bytes: Buffer) => bytes.toString("base64");
|
|
40
|
+
|
|
41
|
+
/** 32 bytes whose base64 differs from its base64url, so the url form is refused. */
|
|
42
|
+
function urlOnlyKey(): string {
|
|
43
|
+
let bytes = randomBytes(32);
|
|
44
|
+
while (!/[+/]/.test(bytes.toString("base64"))) bytes = randomBytes(32);
|
|
45
|
+
return bytes.toString("base64url");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("resolveStripeConnectionKeys (#8907 D3)", () => {
|
|
49
|
+
it("answers both buffers for two distinct canonical 32-byte keys", () => {
|
|
50
|
+
const result = resolveStripeConnectionKeys({ current: b64(A), previous: b64(B), scheduling: S });
|
|
51
|
+
expect(result.kind).toBe("ok");
|
|
52
|
+
if (result.kind !== "ok") throw new Error("expected ok");
|
|
53
|
+
expect(result.current?.equals(A)).toBe(true);
|
|
54
|
+
expect(result.previous?.equals(B)).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("answers ok with nothing when both are unset or empty", () => {
|
|
58
|
+
expect(resolveStripeConnectionKeys({ current: undefined, previous: "", scheduling: S })).toEqual({
|
|
59
|
+
kind: "ok",
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("answers the current key alone with no previous", () => {
|
|
64
|
+
const result = resolveStripeConnectionKeys({ current: b64(A), previous: undefined, scheduling: undefined });
|
|
65
|
+
expect(result).toEqual({ kind: "ok", current: A });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const urlKey = urlOnlyKey();
|
|
69
|
+
const shortKey = b64(randomBytes(16));
|
|
70
|
+
it.each([
|
|
71
|
+
["a base64url current key", { current: urlKey }, "STRIPE_CONNECTION_KEY", "not-canonical-base64", urlKey],
|
|
72
|
+
["a 16-byte current key", { current: shortKey }, "STRIPE_CONNECTION_KEY", "length", shortKey],
|
|
73
|
+
[
|
|
74
|
+
"a 16-byte previous key",
|
|
75
|
+
{ current: b64(A), previous: shortKey },
|
|
76
|
+
"STRIPE_CONNECTION_KEY_PREVIOUS",
|
|
77
|
+
"length",
|
|
78
|
+
shortKey,
|
|
79
|
+
],
|
|
80
|
+
[
|
|
81
|
+
"a previous key with no current",
|
|
82
|
+
{ previous: b64(B) },
|
|
83
|
+
"STRIPE_CONNECTION_KEY_PREVIOUS",
|
|
84
|
+
"previous_without_current",
|
|
85
|
+
b64(B),
|
|
86
|
+
],
|
|
87
|
+
[
|
|
88
|
+
"equal current and previous",
|
|
89
|
+
{ current: b64(A), previous: b64(A) },
|
|
90
|
+
"STRIPE_CONNECTION_KEY_PREVIOUS",
|
|
91
|
+
"previous_equals_current",
|
|
92
|
+
b64(A),
|
|
93
|
+
],
|
|
94
|
+
[
|
|
95
|
+
"a current key equal to the scheduling key",
|
|
96
|
+
{ current: b64(S) },
|
|
97
|
+
"STRIPE_CONNECTION_KEY",
|
|
98
|
+
"shared_with_scheduling",
|
|
99
|
+
b64(S),
|
|
100
|
+
],
|
|
101
|
+
[
|
|
102
|
+
"a previous key equal to the scheduling key",
|
|
103
|
+
{ current: b64(A), previous: b64(S) },
|
|
104
|
+
"STRIPE_CONNECTION_KEY_PREVIOUS",
|
|
105
|
+
"shared_with_scheduling",
|
|
106
|
+
b64(S),
|
|
107
|
+
],
|
|
108
|
+
])("refuses %s, naming the variable and the reason only", (_label, raw, variable, reason, value) => {
|
|
109
|
+
const result = resolveStripeConnectionKeys({
|
|
110
|
+
current: (raw as { current?: string }).current,
|
|
111
|
+
previous: (raw as { previous?: string }).previous,
|
|
112
|
+
scheduling: S,
|
|
113
|
+
});
|
|
114
|
+
expect(result).toEqual({ kind: "refused", variable, reason });
|
|
115
|
+
expect(JSON.stringify(result)).not.toContain(value.slice(0, 8));
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("config.STRIPE_CONNECTION_KEY at startup (#8907 D3)", () => {
|
|
120
|
+
const originalEnv = process.env;
|
|
121
|
+
|
|
122
|
+
beforeEach(() => {
|
|
123
|
+
jest.resetModules();
|
|
124
|
+
process.env = { ...originalEnv };
|
|
125
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
126
|
+
delete process.env.AILK_TRUSTED_ORIGINS;
|
|
127
|
+
delete process.env.SCHEDULING_CREDENTIAL_KEY;
|
|
128
|
+
delete process.env.STRIPE_CONNECTION_KEY;
|
|
129
|
+
delete process.env.STRIPE_CONNECTION_KEY_PREVIOUS;
|
|
130
|
+
jest.clearAllMocks();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
afterEach(() => {
|
|
134
|
+
process.env = originalEnv;
|
|
135
|
+
jest.restoreAllMocks();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("exposes the decoded keys when they are valid, and nothing when unset", () => {
|
|
139
|
+
process.env.STRIPE_CONNECTION_KEY = b64(A);
|
|
140
|
+
process.env.STRIPE_CONNECTION_KEY_PREVIOUS = b64(B);
|
|
141
|
+
const { config } = require("../index");
|
|
142
|
+
expect(config.STRIPE_CONNECTION_KEY.equals(A)).toBe(true);
|
|
143
|
+
expect(config.STRIPE_CONNECTION_KEY_PREVIOUS.equals(B)).toBe(true);
|
|
144
|
+
|
|
145
|
+
jest.resetModules();
|
|
146
|
+
delete process.env.STRIPE_CONNECTION_KEY;
|
|
147
|
+
delete process.env.STRIPE_CONNECTION_KEY_PREVIOUS;
|
|
148
|
+
const { config: unset } = require("../index");
|
|
149
|
+
expect(unset.STRIPE_CONNECTION_KEY).toBeUndefined();
|
|
150
|
+
expect(unset.STRIPE_CONNECTION_KEY_PREVIOUS).toBeUndefined();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it.each([
|
|
154
|
+
["a 31-byte key", { STRIPE_CONNECTION_KEY: b64(randomBytes(31)) }],
|
|
155
|
+
["the scheduling key", { STRIPE_CONNECTION_KEY: b64(S), SCHEDULING_CREDENTIAL_KEY: b64(S) }],
|
|
156
|
+
["a previous key alone", { STRIPE_CONNECTION_KEY_PREVIOUS: b64(B) }],
|
|
157
|
+
])("exits on a refused value (%s), logging the variable but never the value", (_label, env) => {
|
|
158
|
+
Object.assign(process.env, env);
|
|
159
|
+
const exitSpy = jest.spyOn(process, "exit").mockImplementation(() => {
|
|
160
|
+
throw new Error("Process exit called");
|
|
161
|
+
});
|
|
162
|
+
const consoleError = jest.spyOn(console, "error").mockImplementation(() => undefined);
|
|
163
|
+
|
|
164
|
+
expect(() => require("../index")).toThrow("Process exit called");
|
|
165
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
166
|
+
expect(mockLoggerError).toHaveBeenCalledTimes(1);
|
|
167
|
+
|
|
168
|
+
const logged = JSON.stringify([mockLoggerError.mock.calls, consoleError.mock.calls]);
|
|
169
|
+
expect(logged).toContain("STRIPE_CONNECTION_KEY");
|
|
170
|
+
for (const value of Object.values(env)) {
|
|
171
|
+
expect(logged).not.toContain(value);
|
|
172
|
+
expect(logged).not.toContain(value.slice(0, 12));
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("the .env.example files (#8907 D3)", () => {
|
|
178
|
+
const REPO_ROOT = join(__dirname, "..", "..", "..", "..", "..");
|
|
179
|
+
|
|
180
|
+
it.each([".env.example", "apps/api/.env.example"])("%s documents both variables, commented out", (file) => {
|
|
181
|
+
const text = readFileSync(join(REPO_ROOT, file), "utf8");
|
|
182
|
+
expect(text).toMatch(/^# STRIPE_CONNECTION_KEY=$/m);
|
|
183
|
+
expect(text).toMatch(/^# STRIPE_CONNECTION_KEY_PREVIOUS=$/m);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -112,6 +112,15 @@ export interface Config {
|
|
|
112
112
|
// env-configured CAL_API_KEY path works exactly as before. Held decoded,
|
|
113
113
|
// never as the raw string, and never logged.
|
|
114
114
|
SCHEDULING_CREDENTIAL_KEY?: Buffer;
|
|
115
|
+
// An Organization's Stripe connection (#8907 D3, contract C3, C10) — the
|
|
116
|
+
// AES-256-GCM key services/offerings/connection.ts encrypts each
|
|
117
|
+
// connection's restricted key and webhook secret under, and the key it
|
|
118
|
+
// replaced during a rotation. The SCHEDULING_CREDENTIAL_KEY shape rule,
|
|
119
|
+
// checked at startup by resolveStripeConnectionKeys() below; a refused
|
|
120
|
+
// value stops the API. Optional: unset (or empty) means a connection can be
|
|
121
|
+
// read and removed but not written. Held decoded, never logged.
|
|
122
|
+
STRIPE_CONNECTION_KEY?: Buffer;
|
|
123
|
+
STRIPE_CONNECTION_KEY_PREVIOUS?: Buffer;
|
|
115
124
|
// What a hub does with a request to a spoke-aware public surface (meeting
|
|
116
125
|
// types, the three lead writes, booking, availability, event meta, the
|
|
117
126
|
// public content reads) that carries no `x-ailk-spoke-key` (#7532 D7,
|
|
@@ -258,6 +267,82 @@ export function resolveSchedulingCredentialKey(
|
|
|
258
267
|
return { kind: 'ok', key };
|
|
259
268
|
}
|
|
260
269
|
|
|
270
|
+
/** The two variables an Organization's Stripe connection key is read from (#8907 D3). */
|
|
271
|
+
export type StripeConnectionKeyVariable =
|
|
272
|
+
| 'STRIPE_CONNECTION_KEY'
|
|
273
|
+
| 'STRIPE_CONNECTION_KEY_PREVIOUS';
|
|
274
|
+
|
|
275
|
+
/** Why a Stripe connection key was refused (#8907 D3). */
|
|
276
|
+
export type StripeConnectionKeyRefusal =
|
|
277
|
+
| SchedulingCredentialKeyRefusal
|
|
278
|
+
| 'previous_without_current'
|
|
279
|
+
| 'previous_equals_current'
|
|
280
|
+
| 'shared_with_scheduling';
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The Stripe connection keys (#8907 D3, contract C3, C10). Pure;
|
|
284
|
+
* `validateConfig()` calls it once.
|
|
285
|
+
*
|
|
286
|
+
* Each raw value is read with `resolveSchedulingCredentialKey`, the one shape
|
|
287
|
+
* rule. A previous key needs a current one, may not equal it, and neither may
|
|
288
|
+
* equal the scheduling key (`Buffer.equals`), so one key never guards both
|
|
289
|
+
* calendars and money. A refusal names the variable and the reason kind only,
|
|
290
|
+
* never any part of a value.
|
|
291
|
+
*/
|
|
292
|
+
export function resolveStripeConnectionKeys(input: {
|
|
293
|
+
current: string | undefined;
|
|
294
|
+
previous: string | undefined;
|
|
295
|
+
scheduling: Buffer | undefined;
|
|
296
|
+
}):
|
|
297
|
+
| { kind: 'ok'; current?: Buffer; previous?: Buffer }
|
|
298
|
+
| {
|
|
299
|
+
kind: 'refused';
|
|
300
|
+
variable: StripeConnectionKeyVariable;
|
|
301
|
+
reason: StripeConnectionKeyRefusal;
|
|
302
|
+
} {
|
|
303
|
+
const current = resolveSchedulingCredentialKey(input.current);
|
|
304
|
+
if (current.kind === 'refused') {
|
|
305
|
+
return { kind: 'refused', variable: 'STRIPE_CONNECTION_KEY', reason: current.reason };
|
|
306
|
+
}
|
|
307
|
+
const previous = resolveSchedulingCredentialKey(input.previous);
|
|
308
|
+
if (previous.kind === 'refused') {
|
|
309
|
+
return { kind: 'refused', variable: 'STRIPE_CONNECTION_KEY_PREVIOUS', reason: previous.reason };
|
|
310
|
+
}
|
|
311
|
+
const currentKey = current.kind === 'ok' ? current.key : undefined;
|
|
312
|
+
const previousKey = previous.kind === 'ok' ? previous.key : undefined;
|
|
313
|
+
if (previousKey && !currentKey) {
|
|
314
|
+
return {
|
|
315
|
+
kind: 'refused',
|
|
316
|
+
variable: 'STRIPE_CONNECTION_KEY_PREVIOUS',
|
|
317
|
+
reason: 'previous_without_current',
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
if (previousKey && currentKey?.equals(previousKey)) {
|
|
321
|
+
return {
|
|
322
|
+
kind: 'refused',
|
|
323
|
+
variable: 'STRIPE_CONNECTION_KEY_PREVIOUS',
|
|
324
|
+
reason: 'previous_equals_current',
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
if (input.scheduling) {
|
|
328
|
+
if (currentKey?.equals(input.scheduling)) {
|
|
329
|
+
return { kind: 'refused', variable: 'STRIPE_CONNECTION_KEY', reason: 'shared_with_scheduling' };
|
|
330
|
+
}
|
|
331
|
+
if (previousKey?.equals(input.scheduling)) {
|
|
332
|
+
return {
|
|
333
|
+
kind: 'refused',
|
|
334
|
+
variable: 'STRIPE_CONNECTION_KEY_PREVIOUS',
|
|
335
|
+
reason: 'shared_with_scheduling',
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
kind: 'ok',
|
|
341
|
+
...(currentKey && { current: currentKey }),
|
|
342
|
+
...(previousKey && { previous: previousKey }),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
261
346
|
/**
|
|
262
347
|
* `AILK_HUB_REQUIRE_SPOKE_KEY` (#7532 D7, #7633 D2): unset, empty or blank →
|
|
263
348
|
* `"auto"`; `true`/`1` → `"on"`; `false`/`0` → `"off"` — case-insensitive,
|
|
@@ -364,6 +449,24 @@ function validateConfig(): Config {
|
|
|
364
449
|
process.exit(1);
|
|
365
450
|
}
|
|
366
451
|
|
|
452
|
+
// #8907 D3 — a set-but-refused Stripe connection key stops the API in every
|
|
453
|
+
// environment, for the scheduling key's reason; so does a key shared with
|
|
454
|
+
// the scheduling key or a previous key that could never be rotated away.
|
|
455
|
+
// The variable and the reason kind only, never a value.
|
|
456
|
+
const stripeConnectionKeys = resolveStripeConnectionKeys({
|
|
457
|
+
current: process.env.STRIPE_CONNECTION_KEY,
|
|
458
|
+
previous: process.env.STRIPE_CONNECTION_KEY_PREVIOUS,
|
|
459
|
+
scheduling: schedulingKey.kind === 'ok' ? schedulingKey.key : undefined,
|
|
460
|
+
});
|
|
461
|
+
if (stripeConnectionKeys.kind === 'refused') {
|
|
462
|
+
const { variable, reason } = stripeConnectionKeys;
|
|
463
|
+
const message =
|
|
464
|
+
'❌ STRIPE_CONNECTION_KEY and STRIPE_CONNECTION_KEY_PREVIOUS must each be canonical standard base64 of exactly 32 bytes (openssl rand -base64 32), distinct from each other and from SCHEDULING_CREDENTIAL_KEY, and a previous key needs a current one';
|
|
465
|
+
logger.error({ variable, reason }, message);
|
|
466
|
+
console.error(message, JSON.stringify({ variable, reason }));
|
|
467
|
+
process.exit(1);
|
|
468
|
+
}
|
|
469
|
+
|
|
367
470
|
// #7532 D7, #7633 D2 — the hub's spoke-key mode. An unparseable value stops
|
|
368
471
|
// the API: a typo read as "off" would silently leave the hub open to
|
|
369
472
|
// header-less callers. The message names the variable, never its value.
|
|
@@ -479,6 +582,12 @@ function validateConfig(): Config {
|
|
|
479
582
|
...(schedulingKey.kind === 'ok' && {
|
|
480
583
|
SCHEDULING_CREDENTIAL_KEY: schedulingKey.key,
|
|
481
584
|
}),
|
|
585
|
+
...(stripeConnectionKeys.current && {
|
|
586
|
+
STRIPE_CONNECTION_KEY: stripeConnectionKeys.current,
|
|
587
|
+
}),
|
|
588
|
+
...(stripeConnectionKeys.previous && {
|
|
589
|
+
STRIPE_CONNECTION_KEY_PREVIOUS: stripeConnectionKeys.previous,
|
|
590
|
+
}),
|
|
482
591
|
AILK_HUB_REQUIRE_SPOKE_KEY: hubRequireSpokeKey,
|
|
483
592
|
AILK_SPOKE_FLOW_SEED: spokeFlowSeed,
|
|
484
593
|
};
|
|
@@ -203,6 +203,9 @@ export const prisma = {
|
|
|
203
203
|
findFirst: jest.fn(),
|
|
204
204
|
findMany: jest.fn(),
|
|
205
205
|
updateMany: jest.fn(),
|
|
206
|
+
// `count` — the flow engine's completion-email caps (#8847 security
|
|
207
|
+
// review). Unarranged it resolves undefined, which caps nothing.
|
|
208
|
+
count: jest.fn(),
|
|
206
209
|
},
|
|
207
210
|
subscriber: {
|
|
208
211
|
create: jest.fn(),
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file flow-rename-race.db.test.ts
|
|
3
|
+
*
|
|
4
|
+
* LIVE-DATABASE proof of #8734 AC-5 (spec
|
|
5
|
+
* docs/specs/8734-magnet-slug-renames-an-anonymous-spec.md D4): two renames
|
|
6
|
+
* racing for one free slug are serialized by `flow_definitions`'
|
|
7
|
+
* `(site_id, slug)` unique key. `flows.rename`'s first write is the
|
|
8
|
+
* definition row, so the second transaction's write waits on the first's
|
|
9
|
+
* uncommitted index entry and, once the first commits, fails with `23505` —
|
|
10
|
+
* which Prisma reports as `P2002` and `rename` answers `conflict`. The pre-
|
|
11
|
+
* checks run at READ COMMITTED and guarantee nothing here; the key does.
|
|
12
|
+
*
|
|
13
|
+
* Runs only when `AILK_TEST_LIVE_DATABASE === "1"` (set beside a real
|
|
14
|
+
* `DATABASE_URL` by `.github/workflows/api-postgres-compat.yml`), and uses the
|
|
15
|
+
* `pg` driver directly, the same reason `rate-limit-cross-isolate.db.test.ts`
|
|
16
|
+
* does: the generated Prisma client is ESM-only and cannot load under this
|
|
17
|
+
* package's CJS ts-jest transform.
|
|
18
|
+
*/
|
|
19
|
+
import { Client } from "pg";
|
|
20
|
+
|
|
21
|
+
const describeDb =
|
|
22
|
+
process.env.AILK_TEST_LIVE_DATABASE === "1" ? describe : describe.skip;
|
|
23
|
+
|
|
24
|
+
describeDb("flow_definitions' (site_id, slug) key serializes two renames to one slug (live, #8734 AC-5)", () => {
|
|
25
|
+
let first: Client;
|
|
26
|
+
let second: Client;
|
|
27
|
+
let workspaceId = "";
|
|
28
|
+
let rowA = "";
|
|
29
|
+
let rowB = "";
|
|
30
|
+
|
|
31
|
+
async function insertRow(slug: string): Promise<string> {
|
|
32
|
+
const res = await first.query<{ id: string }>(
|
|
33
|
+
`INSERT INTO "flow_definitions"
|
|
34
|
+
("site_id", "workspace_id", "slug", "config", "kind", "title", "status", "customers_enabled", "updated_at")
|
|
35
|
+
VALUES ($1, $1, $2, '{}'::jsonb, 'contact', $2, 'draft', false, now())
|
|
36
|
+
RETURNING "id"`,
|
|
37
|
+
[workspaceId, slug],
|
|
38
|
+
);
|
|
39
|
+
return res.rows[0]!.id;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Until `pid`'s statement is blocked on a lock — the wait the key imposes. */
|
|
43
|
+
async function waitUntilBlocked(pid: number): Promise<void> {
|
|
44
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
45
|
+
const res = await first.query<{ wait_event_type: string | null }>(
|
|
46
|
+
`SELECT "wait_event_type" FROM pg_stat_activity WHERE "pid" = $1`,
|
|
47
|
+
[pid],
|
|
48
|
+
);
|
|
49
|
+
if (res.rows[0]?.wait_event_type === "Lock") return;
|
|
50
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
51
|
+
}
|
|
52
|
+
throw new Error("the second rename never waited on the first");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
beforeAll(async () => {
|
|
56
|
+
first = new Client({ connectionString: process.env.DATABASE_URL });
|
|
57
|
+
second = new Client({ connectionString: process.env.DATABASE_URL });
|
|
58
|
+
await first.connect();
|
|
59
|
+
await second.connect();
|
|
60
|
+
const ws = await first.query<{ id: string }>(
|
|
61
|
+
`SELECT 'wksp_' || uuidv7()::text AS "id"`,
|
|
62
|
+
);
|
|
63
|
+
workspaceId = ws.rows[0]!.id;
|
|
64
|
+
rowA = await insertRow("race-a");
|
|
65
|
+
rowB = await insertRow("race-b");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterAll(async () => {
|
|
69
|
+
await first.query(`DELETE FROM "flow_definitions" WHERE "workspace_id" = $1`, [
|
|
70
|
+
workspaceId,
|
|
71
|
+
]);
|
|
72
|
+
await first.end();
|
|
73
|
+
await second.end();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("the second rename to one slug waits on the first, then fails 23505 on flow_definitions_site_id_slug_key; one row holds the slug", async () => {
|
|
77
|
+
const rename = `UPDATE "flow_definitions" SET "slug" = 'race-x' WHERE "id" = $1`;
|
|
78
|
+
const secondPid = (await second.query<{ pid: number }>("SELECT pg_backend_pid() AS pid"))
|
|
79
|
+
.rows[0]!.pid;
|
|
80
|
+
|
|
81
|
+
let error: { code?: string; constraint?: string } | null | undefined;
|
|
82
|
+
try {
|
|
83
|
+
await first.query("BEGIN");
|
|
84
|
+
await first.query(rename, [rowA]);
|
|
85
|
+
|
|
86
|
+
await second.query("BEGIN");
|
|
87
|
+
// Not awaited: it blocks on the first transaction's uncommitted entry.
|
|
88
|
+
const loser = second.query(rename, [rowB]).then(
|
|
89
|
+
() => null,
|
|
90
|
+
(failure: unknown) => failure as { code?: string; constraint?: string },
|
|
91
|
+
);
|
|
92
|
+
await waitUntilBlocked(secondPid);
|
|
93
|
+
|
|
94
|
+
await first.query("COMMIT");
|
|
95
|
+
error = await loser;
|
|
96
|
+
} finally {
|
|
97
|
+
await first.query("ROLLBACK");
|
|
98
|
+
await second.query("ROLLBACK");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
expect(error).toMatchObject({
|
|
102
|
+
code: "23505",
|
|
103
|
+
constraint: "flow_definitions_site_id_slug_key",
|
|
104
|
+
});
|
|
105
|
+
const atSlug = await first.query<{ count: string }>(
|
|
106
|
+
`SELECT count(*) AS "count" FROM "flow_definitions" WHERE "workspace_id" = $1 AND "slug" = 'race-x'`,
|
|
107
|
+
[workspaceId],
|
|
108
|
+
);
|
|
109
|
+
expect(atSlug.rows[0]!.count).toBe("1");
|
|
110
|
+
const b = await first.query<{ slug: string }>(
|
|
111
|
+
`SELECT "slug" FROM "flow_definitions" WHERE "id" = $1`,
|
|
112
|
+
[rowB],
|
|
113
|
+
);
|
|
114
|
+
expect(b.rows[0]!.slug).toBe("race-b");
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* - Leaves replies that handlers already committed (explicit status + body)
|
|
17
17
|
* untouched
|
|
18
18
|
*/
|
|
19
|
+
import { idRegex } from "@working-theory/ids";
|
|
19
20
|
import type { FastifyError, FastifyInstance } from "fastify";
|
|
20
21
|
|
|
21
22
|
const GENERIC_INTERNAL_BODY = { error: "internal_error" } as const;
|
|
@@ -30,16 +31,82 @@ const GENERIC_INTERNAL_BODY = { error: "internal_error" } as const;
|
|
|
30
31
|
* EVERY route. Logging the raw target would write that credential into the
|
|
31
32
|
* API's own log the moment anything went wrong, which is precisely when logs
|
|
32
33
|
* get read, copied into tickets, and shipped to third parties.
|
|
33
|
-
*
|
|
34
|
-
* The path itself is kept: it is what makes the line useful, and a route
|
|
35
|
-
* parameter like `:id` is an identifier rather than a secret. The matching
|
|
36
|
-
* strip lives on the Fastify logger's own `req` serializer in `server.ts`.
|
|
37
34
|
*/
|
|
38
35
|
function pathWithoutQuery(url: string): string {
|
|
39
36
|
const queryAt = url.indexOf("?");
|
|
40
37
|
return queryAt === -1 ? url : url.slice(0, queryAt);
|
|
41
38
|
}
|
|
42
39
|
|
|
40
|
+
/**
|
|
41
|
+
* #6084 D14 — `GET /v1/checkout/sessions/:id` puts its credential in the
|
|
42
|
+
* PATH, so a `cs_…`-shaped segment is truncated to its first 8 characters
|
|
43
|
+
* plus `…` (a path SEGMENT rule, never a substring replace, so it cannot
|
|
44
|
+
* corrupt an unrelated URL). #6216 D11 adds the same arm for `lead_…`
|
|
45
|
+
* segments and #8438 SEC-8438-10 for `person_…` segments, which would
|
|
46
|
+
* otherwise put a full id beside `remoteAddress`.
|
|
47
|
+
*/
|
|
48
|
+
const SESSION_ID_LOG_SEGMENT = /^cs_[A-Za-z0-9_]+$/;
|
|
49
|
+
const LEAD_ID_LOG_SEGMENT = idRegex("lead");
|
|
50
|
+
const PERSON_ID_LOG_SEGMENT = idRegex("person");
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* #8932 D10 (contract W9) — a webhook connection token is the per-connection
|
|
54
|
+
* receiver's routing credential (`/v1/webhooks/<provider>/:connectionToken`).
|
|
55
|
+
* Every segment after the provider is replaced by one literal
|
|
56
|
+
* `:connectionToken`, keeping no prefix — unlike the id arms above — and
|
|
57
|
+
* empty segments are dropped first, so a trailing slash, a double slash or a
|
|
58
|
+
* mis-pasted extra segment cannot carry the token past the rule.
|
|
59
|
+
*/
|
|
60
|
+
const CONNECTION_TOKEN_PLACEHOLDER = ":connectionToken";
|
|
61
|
+
|
|
62
|
+
/** A segment as the router matches it: percent-decoded, or raw when it does not decode. */
|
|
63
|
+
function decodedSegment(segment: string): string {
|
|
64
|
+
try {
|
|
65
|
+
return decodeURIComponent(segment);
|
|
66
|
+
} catch {
|
|
67
|
+
return segment;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function redactConnectionToken(path: string): string | null {
|
|
72
|
+
// Compared DECODED: the router decodes before it matches, so
|
|
73
|
+
// `/v1/%77ebhooks/stripe/<token>` reaches the token route and must be
|
|
74
|
+
// redacted like its plain spelling.
|
|
75
|
+
const segments = path
|
|
76
|
+
.split("/")
|
|
77
|
+
.filter((segment) => segment.length > 0)
|
|
78
|
+
.map(decodedSegment);
|
|
79
|
+
if (segments.length < 4 || segments[0] !== "v1" || segments[1] !== "webhooks") {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return `/${segments.slice(0, 3).join("/")}/${CONNECTION_TOKEN_PLACEHOLDER}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The ONE path redactor for a log line (#8932 D10): the query string dropped,
|
|
87
|
+
* a webhook connection token replaced whole, and every `cs_…`, `lead_…` and
|
|
88
|
+
* `person_…` segment truncated. The Fastify request serializer (`server.ts`)
|
|
89
|
+
* and this handler's three `path:` fields both call it, so a 429, 413, 415 or
|
|
90
|
+
* 5xx on a token route logs the same redacted path its request line does.
|
|
91
|
+
* A route parameter that is an identifier rather than a secret — `:id` — is
|
|
92
|
+
* kept: it is what makes the line useful.
|
|
93
|
+
*/
|
|
94
|
+
export function redactLogPath(url: string): string {
|
|
95
|
+
const path = pathWithoutQuery(url);
|
|
96
|
+
const tokenless = redactConnectionToken(path);
|
|
97
|
+
if (tokenless !== null) return tokenless;
|
|
98
|
+
return path
|
|
99
|
+
.split("/")
|
|
100
|
+
.map((segment) =>
|
|
101
|
+
SESSION_ID_LOG_SEGMENT.test(segment) ||
|
|
102
|
+
LEAD_ID_LOG_SEGMENT.test(segment) ||
|
|
103
|
+
PERSON_ID_LOG_SEGMENT.test(segment)
|
|
104
|
+
? `${segment.slice(0, 8)}…`
|
|
105
|
+
: segment,
|
|
106
|
+
)
|
|
107
|
+
.join("/");
|
|
108
|
+
}
|
|
109
|
+
|
|
43
110
|
export function registerErrorSerializer(server: FastifyInstance): void {
|
|
44
111
|
server.setErrorHandler(async (err: FastifyError, request, reply) => {
|
|
45
112
|
// Validation errors (Fastify's schema validator) — keep structured detail.
|
|
@@ -49,7 +116,7 @@ export function registerErrorSerializer(server: FastifyInstance): void {
|
|
|
49
116
|
request.log.warn(
|
|
50
117
|
{
|
|
51
118
|
method: request.method,
|
|
52
|
-
path:
|
|
119
|
+
path: redactLogPath(request.url),
|
|
53
120
|
validation: err.validation,
|
|
54
121
|
},
|
|
55
122
|
"request validation failed",
|
|
@@ -68,7 +135,7 @@ export function registerErrorSerializer(server: FastifyInstance): void {
|
|
|
68
135
|
request.log.info(
|
|
69
136
|
{
|
|
70
137
|
method: request.method,
|
|
71
|
-
path:
|
|
138
|
+
path: redactLogPath(request.url),
|
|
72
139
|
statusCode: status,
|
|
73
140
|
errName: err.name,
|
|
74
141
|
},
|
|
@@ -83,7 +150,7 @@ export function registerErrorSerializer(server: FastifyInstance): void {
|
|
|
83
150
|
request.log.error(
|
|
84
151
|
{
|
|
85
152
|
method: request.method,
|
|
86
|
-
path:
|
|
153
|
+
path: redactLogPath(request.url),
|
|
87
154
|
errName: err.name,
|
|
88
155
|
errMessage: err.message,
|
|
89
156
|
errStack: err.stack,
|