create-ailk 0.14.0 → 0.16.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 +124 -0
- package/component-catalog.json +3 -3
- package/dist/add-module.d.ts +7 -0
- package/dist/add-module.js +80 -3
- package/dist/add-route.js +26 -2
- package/dist/adopt.d.ts +74 -0
- package/dist/adopt.js +314 -0
- package/dist/apply-design.d.ts +12 -1
- package/dist/apply-design.js +36 -4
- package/dist/cli.js +103 -3
- package/dist/derive-architecture.d.ts +37 -2
- package/dist/derive-architecture.js +92 -7
- package/dist/design-package.js +3 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/chrome-readers.d.ts +21 -0
- package/dist/lib/chrome-readers.js +100 -0
- package/dist/lib/declared-env.d.ts +142 -0
- package/dist/lib/declared-env.js +985 -0
- package/dist/lib/env-catalog.d.ts +17 -0
- package/dist/lib/env-catalog.js +234 -0
- package/dist/lib/extract-module-bundle.d.ts +20 -0
- package/dist/lib/extract-module-bundle.js +53 -6
- package/dist/lib/license-key-files.d.ts +6 -1
- package/dist/lib/license-key-files.js +9 -4
- package/dist/lib/module-tier.d.ts +2 -0
- package/dist/lib/module-tier.js +4 -0
- package/dist/module-architecture.d.ts +9 -0
- package/dist/module-architecture.js +82 -0
- package/dist/parse-args.d.ts +27 -0
- package/dist/parse-args.js +89 -0
- package/dist/programmatic.d.ts +28 -0
- package/dist/programmatic.js +181 -11
- package/dist/seed-flows.d.ts +65 -0
- package/dist/seed-flows.js +350 -0
- package/dist/site-chrome.d.ts +4 -0
- package/dist/site-chrome.js +34 -3
- package/dist/site-manifest.d.ts +55 -5
- package/dist/site-manifest.js +121 -12
- package/dist/site-roster.d.ts +13 -0
- package/dist/site-roster.js +22 -8
- package/dist/spoke-checkout.d.ts +42 -0
- package/dist/spoke-checkout.js +359 -0
- package/dist/surfaces.js +7 -0
- package/dist/sync-routes.js +29 -5
- package/dist/topology.d.ts +246 -0
- package/dist/topology.js +942 -0
- package/package.json +3 -3
- package/site-templates/designs/minimalism/DESIGN.md +3 -3
- package/templates/.env.example +10 -0
- package/templates/apps/api/CLAUDE.md +21 -4
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
- package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
- package/templates/apps/api/src/bin/spokes.ts +62 -1
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
- package/templates/apps/api/src/config/index.ts +35 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
- package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
- package/templates/apps/api/src/lib/agent-store.ts +95 -3
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
- package/templates/apps/api/src/lib/site.ts +37 -0
- package/templates/apps/api/src/lib/spoke-store.ts +41 -3
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
- package/templates/apps/api/src/middleware/auth.ts +129 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
- package/templates/apps/api/src/openapi/spec.ts +38 -2
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
- package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
- package/templates/apps/api/src/routes/leads/index.ts +69 -6
- package/templates/apps/api/src/routes/webhooks/README.md +39 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
- package/templates/apps/api/src/server.ts +105 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
- package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
- package/templates/apps/api/src/services/default-tenant.ts +41 -7
- package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
- package/templates/apps/api/src/services/lead-routing.ts +278 -30
- package/templates/apps/api/src/services/person.ts +541 -0
- package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
- package/templates/apps/mcp/__tests__/index.test.ts +5 -5
- package/templates/apps/mcp/src/index.ts +6 -4
- package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
- package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
- package/templates/apps/web/.env.example +58 -5
- package/templates/apps/web/CLAUDE.md +1 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
- package/templates/apps/web/app/[locale]/layout.tsx +83 -18
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
- package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
- package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
- package/templates/apps/web/app/openapi.json/route.ts +72 -7
- package/templates/apps/web/auth.ts +29 -2
- package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
- package/templates/apps/web/components/signed-in-nav.tsx +17 -3
- package/templates/apps/web/instrumentation.ts +7 -4
- package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
- package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
- package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
- package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
- package/templates/apps/web/lib/account-config.ts +130 -1
- package/templates/apps/web/lib/api-origin.generated.ts +11 -0
- package/templates/apps/web/lib/closing-footer-path.ts +50 -0
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
- package/templates/apps/web/lib/hub-session/config.ts +104 -0
- package/templates/apps/web/lib/hub-session/index.ts +23 -0
- package/templates/apps/web/lib/hub-session/seal.ts +82 -0
- package/templates/apps/web/lib/hub-session/session.ts +180 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
- package/templates/apps/web/lib/listings-config.ts +31 -0
- package/templates/apps/web/lib/listings.tsx +7 -3
- package/templates/apps/web/lib/meeting-slugs.ts +11 -1
- package/templates/apps/web/lib/next-path.ts +35 -0
- package/templates/apps/web/lib/relay.ts +209 -3
- package/templates/apps/web/lib/server-api.ts +11 -1
- package/templates/apps/web/lib/site-brand.tsx +22 -1
- package/templates/apps/web/lib/site-chrome.ts +3 -0
- package/templates/apps/web/lib/topology.generated.ts +33 -0
- package/templates/apps/web/messages/en.json +32 -3
- package/templates/apps/web/package.json +1 -1
- package/templates/database/CHANGELOG.md +51 -0
- package/templates/database/__tests__/agents.db.test.ts +85 -0
- package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
- package/templates/database/__tests__/member-area.db.test.ts +245 -0
- package/templates/database/__tests__/person.db.test.ts +584 -0
- package/templates/database/auth/schema.prisma +24 -0
- package/templates/database/config/schema.prisma +13 -0
- package/templates/database/inbox/schema.prisma +111 -0
- package/templates/database/jest.config.cjs +7 -11
- package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
- package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
- package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
- package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
- package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
- package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
- package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
- package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/person-backfill.sql +132 -0
- package/templates/database/tenancy.ts +53 -0
- package/templates/tools/check-declared-env.ts +37 -0
- package/templates/tools/declared-env.ts +1163 -0
- package/versions.json +35 -34
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file auth.hub-session-path.test.ts
|
|
3
|
+
*
|
|
4
|
+
* The hub-credential branch of the auth preHandler (#7894, spec
|
|
5
|
+
* docs/specs/7894-the-hub-signs-in-its-spec.md D6; AC-4, AC-5, AC-7).
|
|
6
|
+
*
|
|
7
|
+
* A bearer starting `ailk_hst_` is admitted only beside the SAME spoke's key,
|
|
8
|
+
* only while its grant is live and its person verified, and only on the four
|
|
9
|
+
* routes of `HUB_SESSION_ROUTES`. Any other route answers 404 with no handler
|
|
10
|
+
* run (the status `lib/account-config.ts`'s `read()` already loads as "nothing
|
|
11
|
+
* for this caller"), never 403.
|
|
12
|
+
*
|
|
13
|
+
* Stub routes stand in for #7877's three member reads, which are unmerged; each
|
|
14
|
+
* one records that its handler ran, so "404 with no handler run" is asserted,
|
|
15
|
+
* not assumed.
|
|
16
|
+
*
|
|
17
|
+
* External dependency boundary mocked:
|
|
18
|
+
* - @working-theory/database (via the prisma singleton; __mocks__/prisma.ts)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
|
|
22
|
+
jest.mock("../../lib/prisma.js");
|
|
23
|
+
|
|
24
|
+
import Fastify from "fastify";
|
|
25
|
+
import type { FastifyInstance } from "fastify";
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
installHubSignInFakeDb,
|
|
29
|
+
seedSpoke,
|
|
30
|
+
seedUser,
|
|
31
|
+
type FakeHubDb,
|
|
32
|
+
type FakeUser,
|
|
33
|
+
type SeededSpoke,
|
|
34
|
+
} from "../../__tests__/support/hub-sign-in-fake-db.js";
|
|
35
|
+
import { mintCredential, sha256Hex } from "../../lib/hub-sign-in-store.js";
|
|
36
|
+
import { prisma } from "../../lib/prisma.js";
|
|
37
|
+
import { authPreHandlerPlugin, HUB_SESSION_ROUTES } from "../auth.js";
|
|
38
|
+
|
|
39
|
+
const db = jest.mocked(prisma);
|
|
40
|
+
|
|
41
|
+
const FRONTEND = "https://app.example.com";
|
|
42
|
+
|
|
43
|
+
let state: FakeHubDb;
|
|
44
|
+
let app: FastifyInstance;
|
|
45
|
+
let user: FakeUser;
|
|
46
|
+
let spokeA: SeededSpoke;
|
|
47
|
+
let spokeB: SeededSpoke;
|
|
48
|
+
let handlerRuns: string[];
|
|
49
|
+
|
|
50
|
+
/** A live credential for (user, spoke), stored the way the token route stores it. */
|
|
51
|
+
function grant(
|
|
52
|
+
s: SeededSpoke,
|
|
53
|
+
overrides: {
|
|
54
|
+
expiresAt?: Date;
|
|
55
|
+
revokedAt?: Date | null;
|
|
56
|
+
userId?: string;
|
|
57
|
+
} = {},
|
|
58
|
+
): string {
|
|
59
|
+
const credential = mintCredential();
|
|
60
|
+
state.authorizations.push({
|
|
61
|
+
id: `oautha_${state.authorizations.length + 1}`,
|
|
62
|
+
userId: overrides.userId ?? user.id,
|
|
63
|
+
clientId: s.client.id,
|
|
64
|
+
scopes: ["account"],
|
|
65
|
+
accessToken: sha256Hex(credential),
|
|
66
|
+
expiresAt: overrides.expiresAt ?? new Date(Date.now() + 60 * 60 * 1000),
|
|
67
|
+
revokedAt: overrides.revokedAt ?? null,
|
|
68
|
+
});
|
|
69
|
+
return credential;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const headers = (credential: string, s: SeededSpoke = spokeA) => ({
|
|
73
|
+
authorization: `Bearer ${credential}`,
|
|
74
|
+
"x-ailk-spoke-key": s.key,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
function buildApp(): FastifyInstance {
|
|
78
|
+
const server = Fastify();
|
|
79
|
+
server.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
|
|
80
|
+
|
|
81
|
+
const echo =
|
|
82
|
+
(name: string) => async (req: import("fastify").FastifyRequest) => {
|
|
83
|
+
handlerRuns.push(name);
|
|
84
|
+
return {
|
|
85
|
+
userId: req.userId ?? null,
|
|
86
|
+
hubSession: req.hubSession ?? null,
|
|
87
|
+
sessionTenant: req.sessionTenant ?? null,
|
|
88
|
+
actor: req.actor ?? null,
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// The four admitted routes (the member reads are stubs).
|
|
93
|
+
server.get("/v1/hub-sign-in/me", echo("me"));
|
|
94
|
+
server.get("/v1/member/pieces", echo("pieces"));
|
|
95
|
+
server.get("/v1/member/messages", echo("messages"));
|
|
96
|
+
server.get("/v1/member/messages/:id/file", echo("file"));
|
|
97
|
+
|
|
98
|
+
// Everything else the credential must NOT reach.
|
|
99
|
+
server.get("/v1/member/other", echo("other"));
|
|
100
|
+
server.get("/v1/member/messages/:id", echo("message-item"));
|
|
101
|
+
server.get("/v1/waitlist-signups/me", echo("waitlist-signups"));
|
|
102
|
+
server.get("/v1/flow-checkouts/me", echo("flow-checkouts"));
|
|
103
|
+
server.get("/v1/subscription/me", echo("subscription"));
|
|
104
|
+
server.post("/v1/subscription/portal-session", echo("portal"));
|
|
105
|
+
server.get("/v1/auth/tokens", echo("tokens"));
|
|
106
|
+
server.patch("/v1/user/profile", echo("profile"));
|
|
107
|
+
server.post("/v1/hub-sign-in/codes", echo("codes"));
|
|
108
|
+
return server;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
beforeEach(async () => {
|
|
112
|
+
jest.resetAllMocks();
|
|
113
|
+
state = installHubSignInFakeDb(db);
|
|
114
|
+
handlerRuns = [];
|
|
115
|
+
user = seedUser(state);
|
|
116
|
+
spokeA = seedSpoke(state, "a.example.com");
|
|
117
|
+
spokeB = seedSpoke(state, "b.example.com");
|
|
118
|
+
app = buildApp();
|
|
119
|
+
await app.ready();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
afterEach(async () => {
|
|
123
|
+
await app.close();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("HUB_SESSION_ROUTES", () => {
|
|
127
|
+
it("is exactly the four reads, by method and registered URL — never a prefix", () => {
|
|
128
|
+
expect([...HUB_SESSION_ROUTES]).toEqual([
|
|
129
|
+
"GET /v1/hub-sign-in/me",
|
|
130
|
+
"GET /v1/member/pieces",
|
|
131
|
+
"GET /v1/member/messages",
|
|
132
|
+
"GET /v1/member/messages/:id/file",
|
|
133
|
+
]);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("hub credential — admitted routes", () => {
|
|
138
|
+
it.each([
|
|
139
|
+
["GET", "/v1/hub-sign-in/me", "me"],
|
|
140
|
+
["GET", "/v1/member/pieces", "pieces"],
|
|
141
|
+
["GET", "/v1/member/messages", "messages"],
|
|
142
|
+
["GET", "/v1/member/messages/lead_1/file", "file"],
|
|
143
|
+
])(
|
|
144
|
+
"%s %s is admitted as the credential's person, bound to the spoke",
|
|
145
|
+
async (method, url, name) => {
|
|
146
|
+
const credential = grant(spokeA);
|
|
147
|
+
|
|
148
|
+
const res = await app.inject({
|
|
149
|
+
method: method as "GET",
|
|
150
|
+
url,
|
|
151
|
+
headers: headers(credential),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(res.statusCode).toBe(200);
|
|
155
|
+
expect(handlerRuns).toEqual([name]);
|
|
156
|
+
const body = res.json();
|
|
157
|
+
expect(body.userId).toBe(user.id);
|
|
158
|
+
expect(body.hubSession).toEqual({
|
|
159
|
+
spokeId: spokeA.spoke.id,
|
|
160
|
+
workspaceId: spokeA.spoke.workspaceId,
|
|
161
|
+
});
|
|
162
|
+
// Acts as its person; never carries a cookie session's tenant.
|
|
163
|
+
expect(body.actor).toEqual({ type: "user", userId: user.id });
|
|
164
|
+
expect(body.sessionTenant).toBeNull();
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("hub credential — every other route is 404 with no handler run (AC-7)", () => {
|
|
170
|
+
it.each([
|
|
171
|
+
["GET", "/v1/member/other"],
|
|
172
|
+
["GET", "/v1/member/messages/lead_1"],
|
|
173
|
+
["GET", "/v1/waitlist-signups/me"],
|
|
174
|
+
["GET", "/v1/flow-checkouts/me"],
|
|
175
|
+
["GET", "/v1/subscription/me"],
|
|
176
|
+
["POST", "/v1/subscription/portal-session"],
|
|
177
|
+
["GET", "/v1/auth/tokens"],
|
|
178
|
+
["PATCH", "/v1/user/profile"],
|
|
179
|
+
["POST", "/v1/hub-sign-in/codes"],
|
|
180
|
+
])("%s %s", async (method, url) => {
|
|
181
|
+
const credential = grant(spokeA);
|
|
182
|
+
|
|
183
|
+
const res = await app.inject({
|
|
184
|
+
method: method as "GET",
|
|
185
|
+
url,
|
|
186
|
+
headers: headers(credential),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(res.statusCode).toBe(404);
|
|
190
|
+
expect(res.json()).toEqual({
|
|
191
|
+
ok: false,
|
|
192
|
+
error: { code: "not_found", message: "Not found" },
|
|
193
|
+
});
|
|
194
|
+
expect(handlerRuns).toEqual([]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("the list is method-exact: HEAD on an admitted URL is not admitted", async () => {
|
|
198
|
+
const credential = grant(spokeA);
|
|
199
|
+
const res = await app.inject({
|
|
200
|
+
method: "HEAD",
|
|
201
|
+
url: "/v1/hub-sign-in/me",
|
|
202
|
+
headers: headers(credential),
|
|
203
|
+
});
|
|
204
|
+
expect(res.statusCode).toBe(404);
|
|
205
|
+
expect(handlerRuns).toEqual([]);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe("hub credential — refused with 401 before any route check", () => {
|
|
210
|
+
const me = (h: Record<string, string>) =>
|
|
211
|
+
app.inject({ method: "GET", url: "/v1/hub-sign-in/me", headers: h });
|
|
212
|
+
|
|
213
|
+
it("AC-4: spoke A's credential sent with spoke B's key", async () => {
|
|
214
|
+
const credential = grant(spokeA);
|
|
215
|
+
expect((await me(headers(credential, spokeB))).statusCode).toBe(401);
|
|
216
|
+
expect(handlerRuns).toEqual([]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("the credential with no spoke key at all", async () => {
|
|
220
|
+
const credential = grant(spokeA);
|
|
221
|
+
expect(
|
|
222
|
+
(await me({ authorization: `Bearer ${credential}` })).statusCode,
|
|
223
|
+
).toBe(401);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("an unknown spoke key, and a revoked spoke", async () => {
|
|
227
|
+
const credential = grant(spokeA);
|
|
228
|
+
expect(
|
|
229
|
+
(
|
|
230
|
+
await me({
|
|
231
|
+
authorization: `Bearer ${credential}`,
|
|
232
|
+
"x-ailk-spoke-key": "ailk_spk_nope",
|
|
233
|
+
})
|
|
234
|
+
).statusCode,
|
|
235
|
+
).toBe(401);
|
|
236
|
+
spokeA.spoke.revokedAt = new Date();
|
|
237
|
+
expect((await me(headers(credential))).statusCode).toBe(401);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("an unknown credential", async () => {
|
|
241
|
+
expect((await me(headers(mintCredential()))).statusCode).toBe(401);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("a revoked credential", async () => {
|
|
245
|
+
const credential = grant(spokeA, { revokedAt: new Date() });
|
|
246
|
+
expect((await me(headers(credential))).statusCode).toBe(401);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("an expired credential", async () => {
|
|
250
|
+
const credential = grant(spokeA, {
|
|
251
|
+
expiresAt: new Date(Date.now() - 1_000),
|
|
252
|
+
});
|
|
253
|
+
expect((await me(headers(credential))).statusCode).toBe(401);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("a credential whose client was disabled", async () => {
|
|
257
|
+
const credential = grant(spokeA);
|
|
258
|
+
spokeA.client.enabled = false;
|
|
259
|
+
expect((await me(headers(credential))).statusCode).toBe(401);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("AC-5: a credential whose person's email is no longer verified", async () => {
|
|
263
|
+
const credential = grant(spokeA);
|
|
264
|
+
user.emailVerified = false;
|
|
265
|
+
expect((await me(headers(credential))).statusCode).toBe(401);
|
|
266
|
+
expect(handlerRuns).toEqual([]);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("answers the preHandler's existing 401 body", async () => {
|
|
270
|
+
const res = await me(headers(mintCredential()));
|
|
271
|
+
expect(res.json()).toEqual({ error: "unauthorized" });
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe("hub credential — it is a bearer and nothing else", () => {
|
|
276
|
+
it("AC-5: sent as the better-auth session cookie it is 401 (no Session row matches)", async () => {
|
|
277
|
+
const credential = grant(spokeA);
|
|
278
|
+
const res = await app.inject({
|
|
279
|
+
method: "GET",
|
|
280
|
+
url: "/v1/hub-sign-in/me",
|
|
281
|
+
headers: {
|
|
282
|
+
cookie: `better-auth.session_token=${credential}`,
|
|
283
|
+
"x-ailk-spoke-key": spokeA.key,
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
expect(res.statusCode).toBe(401);
|
|
287
|
+
expect(handlerRuns).toEqual([]);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("is not looked up as a personal ApiToken, and takes no part in the agent branch", async () => {
|
|
291
|
+
const credential = grant(spokeA);
|
|
292
|
+
await app.inject({
|
|
293
|
+
method: "GET",
|
|
294
|
+
url: "/v1/hub-sign-in/me",
|
|
295
|
+
headers: headers(credential),
|
|
296
|
+
});
|
|
297
|
+
expect(db.apiToken.findUnique).not.toHaveBeenCalled();
|
|
298
|
+
expect(db.agent.findUnique).not.toHaveBeenCalled();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("is never logged: a rejected request leaves the credential out of the body", async () => {
|
|
302
|
+
const credential = grant(spokeA, { revokedAt: new Date() });
|
|
303
|
+
const res = await app.inject({
|
|
304
|
+
method: "GET",
|
|
305
|
+
url: "/v1/hub-sign-in/me",
|
|
306
|
+
headers: headers(credential),
|
|
307
|
+
});
|
|
308
|
+
expect(res.body).not.toContain(credential);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
@@ -530,6 +530,7 @@ describe("defaultTenantPreHandler — agents (#3591 D5, D7)", () => {
|
|
|
530
530
|
workspaceId: string;
|
|
531
531
|
organizationId: string;
|
|
532
532
|
responsibleMembershipId: string;
|
|
533
|
+
grants: string[];
|
|
533
534
|
}> = {},
|
|
534
535
|
) {
|
|
535
536
|
return {
|
|
@@ -539,6 +540,7 @@ describe("defaultTenantPreHandler — agents (#3591 D5, D7)", () => {
|
|
|
539
540
|
responsibleMembershipId: (overrides.responsibleMembershipId ??
|
|
540
541
|
OPERATOR_MEMBERSHIP_ID) as Id<"membership">,
|
|
541
542
|
permissionLevel: "member" as const,
|
|
543
|
+
grants: overrides.grants ?? [],
|
|
542
544
|
};
|
|
543
545
|
}
|
|
544
546
|
|
|
@@ -582,6 +584,26 @@ describe("defaultTenantPreHandler — agents (#3591 D5, D7)", () => {
|
|
|
582
584
|
await app.close();
|
|
583
585
|
});
|
|
584
586
|
|
|
587
|
+
it("a granted agent of the owner-operator carries its grants on the tenant; a forged one is dropped (#7864 D3)", async () => {
|
|
588
|
+
mockSingleTenantWorkspace();
|
|
589
|
+
mockOperatorMembership();
|
|
590
|
+
const app = buildAgentApp(
|
|
591
|
+
agentBinding({ grants: ["magnets-manage", "agents-manage", "content-edit"] }),
|
|
592
|
+
);
|
|
593
|
+
|
|
594
|
+
const res = await app.inject({ method: "GET", url: "/scoped" });
|
|
595
|
+
|
|
596
|
+
expect(res.statusCode).toBe(200);
|
|
597
|
+
expect(res.json().tenant).toEqual({
|
|
598
|
+
userId: USER_ID,
|
|
599
|
+
organizationId: DEFAULT_ORG_ID,
|
|
600
|
+
workspaceId: DEFAULT_WORKSPACE_ID,
|
|
601
|
+
role: "member",
|
|
602
|
+
grants: ["content-edit", "magnets-manage"],
|
|
603
|
+
});
|
|
604
|
+
await app.close();
|
|
605
|
+
});
|
|
606
|
+
|
|
585
607
|
it("reaches the grant through the operator's own reads: the same membership compound key", async () => {
|
|
586
608
|
mockSingleTenantWorkspace();
|
|
587
609
|
mockOperatorMembership();
|
|
@@ -28,6 +28,16 @@
|
|
|
28
28
|
* limited to 60 requests a minute, at most 10 of them writes (any
|
|
29
29
|
* method but GET and HEAD).
|
|
30
30
|
*
|
|
31
|
+
* 4. Hub-credential path (#7894) — a bearer starting `ailk_hst_` is a
|
|
32
|
+
* credential a spoke holds for a person the hub signed in on its behalf
|
|
33
|
+
* (`lib/hub-sign-in-store.ts`). It is admitted only beside the SAME
|
|
34
|
+
* spoke's key (`x-ailk-spoke-key`), only while its grant is unrevoked and
|
|
35
|
+
* unexpired, its client enabled and its person's email verified, and only
|
|
36
|
+
* on `HUB_SESSION_ROUTES` — an exact list of four reads; any other route
|
|
37
|
+
* is 404 (never 403, so the account area's `read()` loads "nothing for
|
|
38
|
+
* this caller"). It acts as its person (`request.userId`) and carries
|
|
39
|
+
* `request.hubSession` — never `request.sessionTenant`.
|
|
40
|
+
*
|
|
31
41
|
* Every authenticated request carries `request.actor` — the person, or the
|
|
32
42
|
* agent and the person it acts for — and its request/reply loggers are
|
|
33
43
|
* rebound to name it, so Fastify's "request completed" line says who acted.
|
|
@@ -61,7 +71,12 @@ import {
|
|
|
61
71
|
touchAgentLastActive,
|
|
62
72
|
type AgentGrant,
|
|
63
73
|
} from "../lib/agent-store.js";
|
|
74
|
+
import {
|
|
75
|
+
findAuthorizationByCredentialHash,
|
|
76
|
+
HUB_SESSION_PREFIX,
|
|
77
|
+
} from "../lib/hub-sign-in-store.js";
|
|
64
78
|
import { prisma } from "../lib/prisma.js";
|
|
79
|
+
import { findSpokeByKeyHash, hashSpokeKey } from "../lib/spoke-store.js";
|
|
65
80
|
|
|
66
81
|
/**
|
|
67
82
|
* Better Auth's default cookie names. Both are tried in order so a deployment
|
|
@@ -74,6 +89,11 @@ const COOKIE_NAMES = [
|
|
|
74
89
|
|
|
75
90
|
const UNAUTHORIZED = { error: "unauthorized" } as const;
|
|
76
91
|
const DATABASE_NOT_CONFIGURED = { error: "database_not_configured" } as const;
|
|
92
|
+
const NOT_FOUND = {
|
|
93
|
+
ok: false,
|
|
94
|
+
error: { code: "not_found", message: "Not found" },
|
|
95
|
+
} as const;
|
|
96
|
+
const SPOKE_KEY_HEADER = "x-ailk-spoke-key";
|
|
77
97
|
const FORBIDDEN = {
|
|
78
98
|
ok: false,
|
|
79
99
|
error: { code: "forbidden", message: "Forbidden" },
|
|
@@ -98,6 +118,11 @@ declare module "fastify" {
|
|
|
98
118
|
agent?: AgentGrant;
|
|
99
119
|
/** Who made the request — set on every authenticated request (#3591 D9). */
|
|
100
120
|
actor?: RequestActor;
|
|
121
|
+
/**
|
|
122
|
+
* Set ONLY on the hub-credential path (#7894 D6): the spoke the credential
|
|
123
|
+
* and the request's spoke key both name, and that spoke's workspace.
|
|
124
|
+
*/
|
|
125
|
+
hubSession?: HubSession;
|
|
101
126
|
}
|
|
102
127
|
interface FastifyContextConfig {
|
|
103
128
|
/**
|
|
@@ -109,6 +134,29 @@ declare module "fastify" {
|
|
|
109
134
|
}
|
|
110
135
|
}
|
|
111
136
|
|
|
137
|
+
/** The spoke a hub-issued credential was admitted for (#7894 D6). */
|
|
138
|
+
export type HubSession = {
|
|
139
|
+
spokeId: Id<"spoke">;
|
|
140
|
+
workspaceId: Id<"workspace">;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The only routes a hub-issued credential reaches (#7894 D6): an EXACT list of
|
|
145
|
+
* method + registered URL, never a prefix, so a route added later joins only
|
|
146
|
+
* by a reviewed edit of this list (SEC-7894-3). The first is the credential's
|
|
147
|
+
* own `me`; the other three are #7877's member reads. Not admitted,
|
|
148
|
+
* deliberately: `GET /v1/waitlist-signups/me`, `GET /v1/flow-checkouts/me`,
|
|
149
|
+
* `GET /v1/subscription/me` and `POST /v1/subscription/portal-session` (they
|
|
150
|
+
* answer for the HUB's site and the person's platform billing, SEC-7894-1,
|
|
151
|
+
* -2), and the token and profile routes.
|
|
152
|
+
*/
|
|
153
|
+
export const HUB_SESSION_ROUTES: readonly string[] = [
|
|
154
|
+
"GET /v1/hub-sign-in/me",
|
|
155
|
+
"GET /v1/member/pieces",
|
|
156
|
+
"GET /v1/member/messages",
|
|
157
|
+
"GET /v1/member/messages/:id/file",
|
|
158
|
+
];
|
|
159
|
+
|
|
112
160
|
/**
|
|
113
161
|
* Who made a request (#3591 D9). A cookie session and a personal `ApiToken`
|
|
114
162
|
* act as their person; an agent acts on behalf of its responsible person.
|
|
@@ -354,6 +402,9 @@ async function authenticateAgent(
|
|
|
354
402
|
organizationId: row.organizationId as Id<"organization">,
|
|
355
403
|
responsibleMembershipId: row.responsible.id as Id<"membership">,
|
|
356
404
|
permissionLevel: row.permissionLevel,
|
|
405
|
+
// #7864 D3 — the one place a missing value becomes none; the tenant
|
|
406
|
+
// guards keep only the grants its person's role also holds.
|
|
407
|
+
grants: row.grants ?? [],
|
|
357
408
|
};
|
|
358
409
|
bindActor(request, reply, {
|
|
359
410
|
type: "agent",
|
|
@@ -386,6 +437,61 @@ async function authenticateAgent(
|
|
|
386
437
|
}
|
|
387
438
|
}
|
|
388
439
|
|
|
440
|
+
/**
|
|
441
|
+
* The hub-credential path (#7894 D6). The credential is never logged and
|
|
442
|
+
* never echoed: every refusal is a fixed body. (1)-(3) failing is the one 401;
|
|
443
|
+
* (4), the route not being on `HUB_SESSION_ROUTES`, is 404 with no handler run.
|
|
444
|
+
*/
|
|
445
|
+
async function authenticateHubSession(
|
|
446
|
+
request: FastifyRequest,
|
|
447
|
+
reply: FastifyReply,
|
|
448
|
+
credential: string,
|
|
449
|
+
now: number,
|
|
450
|
+
): Promise<void> {
|
|
451
|
+
// (1) The spoke key resolves to an unrevoked spoke.
|
|
452
|
+
const rawKey = request.headers[SPOKE_KEY_HEADER];
|
|
453
|
+
const spokeKey = typeof rawKey === "string" ? rawKey.trim() : "";
|
|
454
|
+
const spoke = spokeKey
|
|
455
|
+
? await findSpokeByKeyHash(hashSpokeKey(spokeKey)).catch(() => null)
|
|
456
|
+
: null;
|
|
457
|
+
if (!spoke || spoke.revokedAt) {
|
|
458
|
+
reply.code(401).send(UNAUTHORIZED);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// (2) The credential's grant is live, and its client IS this spoke's.
|
|
463
|
+
// (3) Its person's email is verified (read in the same query).
|
|
464
|
+
const grant = await findAuthorizationByCredentialHash(
|
|
465
|
+
hashBearer(credential),
|
|
466
|
+
).catch(() => null);
|
|
467
|
+
if (
|
|
468
|
+
!grant ||
|
|
469
|
+
grant.revokedAt ||
|
|
470
|
+
grant.expiresAt.getTime() <= now ||
|
|
471
|
+
!grant.client.enabled ||
|
|
472
|
+
grant.client.clientId !== spoke.id ||
|
|
473
|
+
grant.user.emailVerified !== true
|
|
474
|
+
) {
|
|
475
|
+
reply.code(401).send(UNAUTHORIZED);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// (4) Method and registered URL are on the list.
|
|
480
|
+
const route = `${request.method.toUpperCase()} ${request.routeOptions.url ?? ""}`;
|
|
481
|
+
if (!HUB_SESSION_ROUTES.includes(route)) {
|
|
482
|
+
reply.code(404).send(NOT_FOUND);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Brand assertion at the Prisma read edge (ADR-0031 rule 5), as above.
|
|
487
|
+
request.userId = grant.userId as Id<"user">;
|
|
488
|
+
request.hubSession = {
|
|
489
|
+
spokeId: spoke.id as Id<"spoke">,
|
|
490
|
+
workspaceId: spoke.workspaceId as Id<"workspace">,
|
|
491
|
+
};
|
|
492
|
+
bindActor(request, reply, { type: "user", userId: request.userId });
|
|
493
|
+
}
|
|
494
|
+
|
|
389
495
|
export type AuthPreHandlerOptions = {
|
|
390
496
|
/**
|
|
391
497
|
* The browser origins a cookie-authenticated write may come from — exact
|
|
@@ -448,6 +554,24 @@ export async function resolveOptionalCookieUser(
|
|
|
448
554
|
return { kind: "user", userId: session.userId as Id<"user"> };
|
|
449
555
|
}
|
|
450
556
|
|
|
557
|
+
/**
|
|
558
|
+
* The person a member read answers for (#7877 D5): the hub's own cookie
|
|
559
|
+
* session or a hub-issued credential (#7894 D6) — never a personal ApiToken,
|
|
560
|
+
* never an agent. `null` → the route answers 401 with `UNAUTHORIZED`.
|
|
561
|
+
*
|
|
562
|
+
* This is the ONE place the member reads (`services/member-area.ts`) learn
|
|
563
|
+
* who is calling: a later credential kind joins them by an edit of this
|
|
564
|
+
* function and nothing in `routes/member/`. An agent never gets here — the
|
|
565
|
+
* member routes are not tenant-guarded, so this preHandler answers it 403.
|
|
566
|
+
*/
|
|
567
|
+
export function memberCaller(request: FastifyRequest): Id<"user"> | null {
|
|
568
|
+
if (request.userId === undefined) return null;
|
|
569
|
+
if (request.sessionTenant !== undefined || request.hubSession !== undefined) {
|
|
570
|
+
return request.userId;
|
|
571
|
+
}
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
|
|
451
575
|
/**
|
|
452
576
|
* Fastify preHandler implementing the dual-mode contract.
|
|
453
577
|
*
|
|
@@ -497,6 +621,10 @@ export function makeAuthPreHandler(opts: AuthPreHandlerOptions) {
|
|
|
497
621
|
}
|
|
498
622
|
|
|
499
623
|
const bearer = readBearer(request);
|
|
624
|
+
if (bearer?.startsWith(HUB_SESSION_PREFIX)) {
|
|
625
|
+
await authenticateHubSession(request, reply, bearer, now);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
500
628
|
if (bearer?.startsWith(AGENT_KEY_PREFIX)) {
|
|
501
629
|
await authenticateAgent(request, reply, bearer, now);
|
|
502
630
|
return;
|
|
@@ -547,6 +675,7 @@ export const authPreHandlerPlugin: FastifyPluginCallback<
|
|
|
547
675
|
fastify.decorateRequest("sessionTenant", undefined);
|
|
548
676
|
fastify.decorateRequest("agent", undefined);
|
|
549
677
|
fastify.decorateRequest("actor", undefined);
|
|
678
|
+
fastify.decorateRequest("hubSession", undefined);
|
|
550
679
|
fastify.addHook("preHandler", handler);
|
|
551
680
|
done();
|
|
552
681
|
};
|
|
@@ -222,6 +222,29 @@ describe("GET /api/openapi.json", () => {
|
|
|
222
222
|
},
|
|
223
223
|
);
|
|
224
224
|
|
|
225
|
+
it("documents /v1/leads/submit's optional Idempotency-Key header and its 422 (#7869)", async () => {
|
|
226
|
+
const res = await app.inject({
|
|
227
|
+
method: "GET",
|
|
228
|
+
url: "/v1/openapi.json",
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const spec = JSON.parse(res.body);
|
|
232
|
+
const post = spec.paths["/v1/leads/submit"].post;
|
|
233
|
+
expect(post.parameters).toEqual(
|
|
234
|
+
expect.arrayContaining([
|
|
235
|
+
expect.objectContaining({
|
|
236
|
+
name: "Idempotency-Key",
|
|
237
|
+
in: "header",
|
|
238
|
+
required: false,
|
|
239
|
+
}),
|
|
240
|
+
]),
|
|
241
|
+
);
|
|
242
|
+
expect(
|
|
243
|
+
post.responses["422"].content["application/json"].schema.properties.error
|
|
244
|
+
.enum,
|
|
245
|
+
).toEqual(["idempotency_key_reused"]);
|
|
246
|
+
});
|
|
247
|
+
|
|
225
248
|
it("no longer describes the API as read-only", async () => {
|
|
226
249
|
const res = await app.inject({
|
|
227
250
|
method: "GET",
|
|
@@ -751,7 +751,25 @@ export function buildOpenApiSpec(
|
|
|
751
751
|
"only), or, on a deployment with no database, the site operator's inbox " +
|
|
752
752
|
"by email (`persisted: \"email\"`); with a database, the response's " +
|
|
753
753
|
"`email` field reports, best-effort, whether the operator was notified. " +
|
|
754
|
-
"A lead is never reported captured when nothing stored or delivered it."
|
|
754
|
+
"A lead is never reported captured when nothing stored or delivered it. " +
|
|
755
|
+
"An optional `Idempotency-Key` header makes a retry safe: with a " +
|
|
756
|
+
"database, a repeat of a stored key in the same workspace with the same " +
|
|
757
|
+
"`email`, `source` and `payload` writes nothing and answers the first " +
|
|
758
|
+
"lead's id with a 201; with anything different it is a 422. Keys are " +
|
|
759
|
+
"scoped per workspace, are first-write-wins, and should be unguessable " +
|
|
760
|
+
"(a UUID, a Stripe Checkout Session id). Without a database there is no " +
|
|
761
|
+
"stored lead to match, so the key has no effect.",
|
|
762
|
+
parameters: [
|
|
763
|
+
{
|
|
764
|
+
name: "Idempotency-Key",
|
|
765
|
+
in: "header",
|
|
766
|
+
required: false,
|
|
767
|
+
description:
|
|
768
|
+
"An unguessable value identifying this lead write, so a retry " +
|
|
769
|
+
"answers the first lead instead of writing a second (#7869).",
|
|
770
|
+
schema: { type: "string", pattern: "^[\\x21-\\x7E]{16,255}$" },
|
|
771
|
+
},
|
|
772
|
+
],
|
|
755
773
|
requestBody: {
|
|
756
774
|
required: true,
|
|
757
775
|
content: {
|
|
@@ -770,13 +788,31 @@ export function buildOpenApiSpec(
|
|
|
770
788
|
},
|
|
771
789
|
},
|
|
772
790
|
"400": {
|
|
773
|
-
description:
|
|
791
|
+
description:
|
|
792
|
+
"Validation error — invalid request body, or a malformed Idempotency-Key",
|
|
774
793
|
content: {
|
|
775
794
|
"application/json": {
|
|
776
795
|
schema: { $ref: "#/components/schemas/InvalidInputResponse" },
|
|
777
796
|
},
|
|
778
797
|
},
|
|
779
798
|
},
|
|
799
|
+
"422": {
|
|
800
|
+
description:
|
|
801
|
+
"The Idempotency-Key was already used in this workspace for a lead " +
|
|
802
|
+
"with a different email, source or payload; nothing was written",
|
|
803
|
+
content: {
|
|
804
|
+
"application/json": {
|
|
805
|
+
schema: {
|
|
806
|
+
type: "object",
|
|
807
|
+
required: ["error", "message"],
|
|
808
|
+
properties: {
|
|
809
|
+
error: { type: "string", enum: ["idempotency_key_reused"] },
|
|
810
|
+
message: { type: "string" },
|
|
811
|
+
},
|
|
812
|
+
},
|
|
813
|
+
},
|
|
814
|
+
},
|
|
815
|
+
},
|
|
780
816
|
"503": {
|
|
781
817
|
description:
|
|
782
818
|
"Lead capture unavailable — persistence failed, delivery failed, or " +
|