@greatwait/contracts 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/dist/authority.d.ts +217 -0
- package/dist/authority.js +261 -0
- package/dist/callback-ownership.d.ts +58 -0
- package/dist/callback-ownership.js +71 -0
- package/dist/callback-safety.d.ts +38 -0
- package/dist/callback-safety.js +403 -0
- package/dist/compatibility.d.ts +82 -0
- package/dist/compatibility.js +115 -0
- package/dist/delivery.d.ts +401 -0
- package/dist/delivery.js +311 -0
- package/dist/endpoints.d.ts +305 -0
- package/dist/endpoints.js +179 -0
- package/dist/events.d.ts +62 -0
- package/dist/events.js +38 -0
- package/dist/first-value-journey.d.ts +305 -0
- package/dist/first-value-journey.js +466 -0
- package/dist/forecast.d.ts +443 -0
- package/dist/forecast.js +729 -0
- package/dist/identifiers.d.ts +109 -0
- package/dist/identifiers.js +131 -0
- package/dist/identity.d.ts +196 -0
- package/dist/identity.js +182 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +27 -0
- package/dist/mcp.d.ts +51 -0
- package/dist/mcp.js +56 -0
- package/dist/oauth.d.ts +796 -0
- package/dist/oauth.js +708 -0
- package/dist/operations.d.ts +3985 -0
- package/dist/operations.js +1837 -0
- package/dist/partition.d.ts +771 -0
- package/dist/partition.js +206 -0
- package/dist/policy.d.ts +93 -0
- package/dist/policy.js +180 -0
- package/dist/problems.d.ts +158 -0
- package/dist/problems.js +104 -0
- package/dist/protocol.d.ts +308 -0
- package/dist/protocol.js +515 -0
- package/dist/scheduling-records.d.ts +395 -0
- package/dist/scheduling-records.js +284 -0
- package/dist/scheduling.d.ts +1098 -0
- package/dist/scheduling.js +1116 -0
- package/dist/secrets.d.ts +383 -0
- package/dist/secrets.js +272 -0
- package/dist/signatures.d.ts +704 -0
- package/dist/signatures.js +867 -0
- package/dist/tenancy.d.ts +178 -0
- package/dist/tenancy.js +145 -0
- package/dist/test-sink.d.ts +986 -0
- package/dist/test-sink.js +755 -0
- package/dist/time.d.ts +45 -0
- package/dist/time.js +128 -0
- package/dist/usage.d.ts +121 -0
- package/dist/usage.js +200 -0
- package/dist/webhooks.d.ts +116 -0
- package/dist/webhooks.js +161 -0
- package/dist/workspace.d.ts +92 -0
- package/dist/workspace.js +59 -0
- package/generated/v1/callback-delivery-signature.json +467 -0
- package/generated/v1/callback-endpoint-verification.json +59 -0
- package/generated/v1/callback-receiver.json +241 -0
- package/generated/v1/examples/attempts.list.response.json +24 -0
- package/generated/v1/examples/attempts.read.response.json +20 -0
- package/generated/v1/examples/credentials.create.request.json +5 -0
- package/generated/v1/examples/credentials.create.response.json +10 -0
- package/generated/v1/examples/credentials.list.response.json +17 -0
- package/generated/v1/examples/credentials.revoke.response.json +11 -0
- package/generated/v1/examples/credentials.rotate.request.json +3 -0
- package/generated/v1/examples/credentials.rotate.response.json +22 -0
- package/generated/v1/examples/endpoints.create.request.json +3 -0
- package/generated/v1/examples/endpoints.create.response.json +16 -0
- package/generated/v1/examples/endpoints.disable.response.json +15 -0
- package/generated/v1/examples/endpoints.list.response.json +21 -0
- package/generated/v1/examples/endpoints.read.response.json +14 -0
- package/generated/v1/examples/endpoints.rotateSigningKey.request.json +3 -0
- package/generated/v1/examples/endpoints.rotateSigningKey.response.json +23 -0
- package/generated/v1/examples/endpoints.update.request.json +3 -0
- package/generated/v1/examples/endpoints.update.response.json +13 -0
- package/generated/v1/examples/endpoints.verify.response.json +15 -0
- package/generated/v1/examples/forecast.read.response.json +28 -0
- package/generated/v1/examples/forecast.windows.response.json +21 -0
- package/generated/v1/examples/grants.list.response.json +17 -0
- package/generated/v1/examples/grants.revoke.response.json +16 -0
- package/generated/v1/examples/meta.scaffold.response.json +5 -0
- package/generated/v1/examples/meta.version.response.json +5 -0
- package/generated/v1/examples/occurrences.list.response.json +17 -0
- package/generated/v1/examples/occurrences.read.response.json +15 -0
- package/generated/v1/examples/occurrences.redeliver.response.json +16 -0
- package/generated/v1/examples/projects.create.request.json +3 -0
- package/generated/v1/examples/projects.create.response.json +5 -0
- package/generated/v1/examples/projects.list.response.json +9 -0
- package/generated/v1/examples/schedules.archive.response.json +25 -0
- package/generated/v1/examples/schedules.cancel.response.json +24 -0
- package/generated/v1/examples/schedules.create.request.json +9 -0
- package/generated/v1/examples/schedules.create.response.json +25 -0
- package/generated/v1/examples/schedules.list.response.json +23 -0
- package/generated/v1/examples/schedules.pause.response.json +23 -0
- package/generated/v1/examples/schedules.read.response.json +25 -0
- package/generated/v1/examples/schedules.replace.request.json +8 -0
- package/generated/v1/examples/schedules.replace.response.json +25 -0
- package/generated/v1/examples/schedules.resume.response.json +23 -0
- package/generated/v1/examples/sinkdeliveries.list.response.json +19 -0
- package/generated/v1/examples/sinkdeliveries.read.response.json +16 -0
- package/generated/v1/examples/testsinks.configure.request.json +3 -0
- package/generated/v1/examples/testsinks.configure.response.json +13 -0
- package/generated/v1/examples/testsinks.create.request.json +8 -0
- package/generated/v1/examples/testsinks.create.response.json +13 -0
- package/generated/v1/examples/testsinks.delete.response.json +13 -0
- package/generated/v1/examples/testsinks.list.response.json +20 -0
- package/generated/v1/examples/testsinks.read.response.json +14 -0
- package/generated/v1/examples/usage.read.response.json +20 -0
- package/generated/v1/first-value-journey.json +436 -0
- package/generated/v1/manifest.json +1037 -0
- package/generated/v1/openapi.json +23665 -0
- package/generated/v1/policy-matrix.json +305 -0
- package/generated/v1/schemas/attempts.list.query.json +16 -0
- package/generated/v1/schemas/attempts.list.response.json +175 -0
- package/generated/v1/schemas/attempts.read.response.json +168 -0
- package/generated/v1/schemas/credentials.create.request.json +39 -0
- package/generated/v1/schemas/credentials.create.response.json +88 -0
- package/generated/v1/schemas/credentials.list.query.json +16 -0
- package/generated/v1/schemas/credentials.list.response.json +124 -0
- package/generated/v1/schemas/credentials.revoke.response.json +84 -0
- package/generated/v1/schemas/credentials.rotate.request.json +11 -0
- package/generated/v1/schemas/credentials.rotate.response.json +171 -0
- package/generated/v1/schemas/endpoints.create.request.json +12 -0
- package/generated/v1/schemas/endpoints.create.response.json +115 -0
- package/generated/v1/schemas/endpoints.disable.response-headers.json +11 -0
- package/generated/v1/schemas/endpoints.disable.response.json +100 -0
- package/generated/v1/schemas/endpoints.list.query.json +16 -0
- package/generated/v1/schemas/endpoints.list.response.json +146 -0
- package/generated/v1/schemas/endpoints.read.response-headers.json +11 -0
- package/generated/v1/schemas/endpoints.read.response.json +100 -0
- package/generated/v1/schemas/endpoints.rotateSigningKey.request-headers.json +10 -0
- package/generated/v1/schemas/endpoints.rotateSigningKey.request.json +11 -0
- package/generated/v1/schemas/endpoints.rotateSigningKey.response-headers.json +11 -0
- package/generated/v1/schemas/endpoints.rotateSigningKey.response.json +118 -0
- package/generated/v1/schemas/endpoints.update.request-headers.json +10 -0
- package/generated/v1/schemas/endpoints.update.request.json +12 -0
- package/generated/v1/schemas/endpoints.update.response-headers.json +11 -0
- package/generated/v1/schemas/endpoints.update.response.json +100 -0
- package/generated/v1/schemas/endpoints.verify.request-headers.json +10 -0
- package/generated/v1/schemas/endpoints.verify.response-headers.json +11 -0
- package/generated/v1/schemas/endpoints.verify.response.json +100 -0
- package/generated/v1/schemas/forecast.read.query.json +26 -0
- package/generated/v1/schemas/forecast.read.response.json +100 -0
- package/generated/v1/schemas/forecast.windows.query.json +26 -0
- package/generated/v1/schemas/forecast.windows.response.json +94 -0
- package/generated/v1/schemas/grants.list.response.json +98 -0
- package/generated/v1/schemas/grants.revoke.response.json +95 -0
- package/generated/v1/schemas/meta.scaffold.response.json +36 -0
- package/generated/v1/schemas/meta.version.response.json +19 -0
- package/generated/v1/schemas/occurrences.list.query.json +16 -0
- package/generated/v1/schemas/occurrences.list.response.json +83 -0
- package/generated/v1/schemas/occurrences.read.response.json +76 -0
- package/generated/v1/schemas/occurrences.redeliver.request-headers.json +13 -0
- package/generated/v1/schemas/occurrences.redeliver.response-headers.json +10 -0
- package/generated/v1/schemas/occurrences.redeliver.response.json +168 -0
- package/generated/v1/schemas/projects.create.request.json +12 -0
- package/generated/v1/schemas/projects.create.response.json +25 -0
- package/generated/v1/schemas/projects.list.response.json +35 -0
- package/generated/v1/schemas/schedules.archive.request-headers.json +10 -0
- package/generated/v1/schemas/schedules.archive.response-headers.json +11 -0
- package/generated/v1/schemas/schedules.archive.response.json +189 -0
- package/generated/v1/schemas/schedules.cancel.request-headers.json +10 -0
- package/generated/v1/schemas/schedules.cancel.response-headers.json +11 -0
- package/generated/v1/schemas/schedules.cancel.response.json +189 -0
- package/generated/v1/schemas/schedules.create.request-headers.json +12 -0
- package/generated/v1/schemas/schedules.create.request.json +99 -0
- package/generated/v1/schemas/schedules.create.response-headers.json +15 -0
- package/generated/v1/schemas/schedules.create.response.json +189 -0
- package/generated/v1/schemas/schedules.list.query.json +16 -0
- package/generated/v1/schemas/schedules.list.response.json +170 -0
- package/generated/v1/schemas/schedules.pause.request-headers.json +10 -0
- package/generated/v1/schemas/schedules.pause.response-headers.json +11 -0
- package/generated/v1/schemas/schedules.pause.response.json +189 -0
- package/generated/v1/schemas/schedules.read.response-headers.json +11 -0
- package/generated/v1/schemas/schedules.read.response.json +189 -0
- package/generated/v1/schemas/schedules.replace.request-headers.json +10 -0
- package/generated/v1/schemas/schedules.replace.request.json +99 -0
- package/generated/v1/schemas/schedules.replace.response-headers.json +11 -0
- package/generated/v1/schemas/schedules.replace.response.json +189 -0
- package/generated/v1/schemas/schedules.resume.request-headers.json +10 -0
- package/generated/v1/schemas/schedules.resume.response-headers.json +11 -0
- package/generated/v1/schemas/schedules.resume.response.json +189 -0
- package/generated/v1/schemas/sinkdeliveries.list.query.json +16 -0
- package/generated/v1/schemas/sinkdeliveries.list.response.json +126 -0
- package/generated/v1/schemas/sinkdeliveries.read.response.json +98 -0
- package/generated/v1/schemas/testsinks.configure.request.json +66 -0
- package/generated/v1/schemas/testsinks.configure.response.json +115 -0
- package/generated/v1/schemas/testsinks.create.request.json +76 -0
- package/generated/v1/schemas/testsinks.create.response.json +115 -0
- package/generated/v1/schemas/testsinks.delete.response.json +115 -0
- package/generated/v1/schemas/testsinks.list.query.json +16 -0
- package/generated/v1/schemas/testsinks.list.response.json +161 -0
- package/generated/v1/schemas/testsinks.read.response.json +115 -0
- package/generated/v1/schemas/usage.read.query.json +15 -0
- package/generated/v1/schemas/usage.read.response.json +71 -0
- package/generated/v1/snippets/attempts.list.json +5 -0
- package/generated/v1/snippets/attempts.read.json +5 -0
- package/generated/v1/snippets/credentials.create.json +5 -0
- package/generated/v1/snippets/credentials.list.json +5 -0
- package/generated/v1/snippets/credentials.revoke.json +5 -0
- package/generated/v1/snippets/credentials.rotate.json +5 -0
- package/generated/v1/snippets/endpoints.create.json +5 -0
- package/generated/v1/snippets/endpoints.disable.json +5 -0
- package/generated/v1/snippets/endpoints.list.json +5 -0
- package/generated/v1/snippets/endpoints.read.json +5 -0
- package/generated/v1/snippets/endpoints.rotateSigningKey.json +5 -0
- package/generated/v1/snippets/endpoints.update.json +5 -0
- package/generated/v1/snippets/endpoints.verify.json +5 -0
- package/generated/v1/snippets/forecast.read.json +5 -0
- package/generated/v1/snippets/forecast.windows.json +5 -0
- package/generated/v1/snippets/grants.list.json +5 -0
- package/generated/v1/snippets/grants.revoke.json +5 -0
- package/generated/v1/snippets/meta.scaffold.json +5 -0
- package/generated/v1/snippets/meta.version.json +5 -0
- package/generated/v1/snippets/occurrences.list.json +5 -0
- package/generated/v1/snippets/occurrences.read.json +5 -0
- package/generated/v1/snippets/occurrences.redeliver.json +5 -0
- package/generated/v1/snippets/projects.create.json +5 -0
- package/generated/v1/snippets/projects.list.json +5 -0
- package/generated/v1/snippets/schedules.archive.json +5 -0
- package/generated/v1/snippets/schedules.cancel.json +5 -0
- package/generated/v1/snippets/schedules.create.json +5 -0
- package/generated/v1/snippets/schedules.list.json +5 -0
- package/generated/v1/snippets/schedules.pause.json +5 -0
- package/generated/v1/snippets/schedules.read.json +5 -0
- package/generated/v1/snippets/schedules.replace.json +5 -0
- package/generated/v1/snippets/schedules.resume.json +5 -0
- package/generated/v1/snippets/sinkdeliveries.list.json +5 -0
- package/generated/v1/snippets/sinkdeliveries.read.json +5 -0
- package/generated/v1/snippets/testsinks.configure.json +5 -0
- package/generated/v1/snippets/testsinks.create.json +5 -0
- package/generated/v1/snippets/testsinks.delete.json +5 -0
- package/generated/v1/snippets/testsinks.list.json +5 -0
- package/generated/v1/snippets/testsinks.read.json +5 -0
- package/generated/v1/snippets/usage.read.json +5 -0
- package/package.json +153 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Public identifiers.
|
|
4
|
+
*
|
|
5
|
+
* Every identifier a client sees is opaque, prefixed, and server-assigned.
|
|
6
|
+
* Clients must treat the part after the prefix as meaningless and compare
|
|
7
|
+
* identifiers only for equality.
|
|
8
|
+
*
|
|
9
|
+
* This is a deliberate break from the legacy scheduler, where the caller
|
|
10
|
+
* supplied `id` and tenancy travelled in the path as `:owner/:partition/:id`.
|
|
11
|
+
* Caller-chosen identifiers leak the caller's namespace into ours, make
|
|
12
|
+
* collision handling a client concern, and make it impossible to change
|
|
13
|
+
* internal representation later. See `docs/architecture/legacy-provenance.md`.
|
|
14
|
+
*/
|
|
15
|
+
/** The resource a prefix names. Extending this list is an additive change. */
|
|
16
|
+
export declare const ID_PREFIXES: {
|
|
17
|
+
readonly apiCredential: "ak";
|
|
18
|
+
readonly apiCredentialEvent: "ake";
|
|
19
|
+
readonly attempt: "att";
|
|
20
|
+
readonly authorizationRequest: "oar";
|
|
21
|
+
readonly callbackSigningKey: "csk";
|
|
22
|
+
readonly delivery: "dlv";
|
|
23
|
+
readonly endpoint: "ep";
|
|
24
|
+
readonly environment: "env";
|
|
25
|
+
readonly event: "evt";
|
|
26
|
+
readonly oauthClient: "oc";
|
|
27
|
+
readonly oauthGrant: "og";
|
|
28
|
+
readonly oauthGrantEvent: "oge";
|
|
29
|
+
readonly occurrence: "occ";
|
|
30
|
+
readonly organization: "org";
|
|
31
|
+
readonly project: "proj";
|
|
32
|
+
readonly schedule: "sch";
|
|
33
|
+
readonly testSink: "tsk";
|
|
34
|
+
readonly testSinkDelivery: "tsd";
|
|
35
|
+
};
|
|
36
|
+
export type IdResource = keyof typeof ID_PREFIXES;
|
|
37
|
+
export declare function idPattern(resource: IdResource): RegExp;
|
|
38
|
+
/** Any well-formed public identifier, whatever it names. */
|
|
39
|
+
export declare const ANY_ID_PATTERN: RegExp;
|
|
40
|
+
export declare function idSchema(resource: IdResource): z.ZodString;
|
|
41
|
+
export declare const apiCredentialIdSchema: z.ZodString;
|
|
42
|
+
export declare const apiCredentialEventIdSchema: z.ZodString;
|
|
43
|
+
export declare const authorizationRequestIdSchema: z.ZodString;
|
|
44
|
+
export declare const callbackSigningKeyIdSchema: z.ZodString;
|
|
45
|
+
export declare const oauthClientIdSchema: z.ZodString;
|
|
46
|
+
export declare const oauthGrantIdSchema: z.ZodString;
|
|
47
|
+
export declare const oauthGrantEventIdSchema: z.ZodString;
|
|
48
|
+
export declare const scheduleIdSchema: z.ZodString;
|
|
49
|
+
export declare const occurrenceIdSchema: z.ZodString;
|
|
50
|
+
export declare const attemptIdSchema: z.ZodString;
|
|
51
|
+
export declare const deliveryIdSchema: z.ZodString;
|
|
52
|
+
export declare const endpointIdSchema: z.ZodString;
|
|
53
|
+
export declare const organizationIdSchema: z.ZodString;
|
|
54
|
+
export declare const projectIdSchema: z.ZodString;
|
|
55
|
+
export declare const environmentIdSchema: z.ZodString;
|
|
56
|
+
export declare const eventIdSchema: z.ZodString;
|
|
57
|
+
export declare const testSinkIdSchema: z.ZodString;
|
|
58
|
+
export declare const testSinkDeliveryIdSchema: z.ZodString;
|
|
59
|
+
export type ApiCredentialId = z.infer<typeof apiCredentialIdSchema>;
|
|
60
|
+
export type ApiCredentialEventId = z.infer<typeof apiCredentialEventIdSchema>;
|
|
61
|
+
export type AuthorizationRequestId = z.infer<typeof authorizationRequestIdSchema>;
|
|
62
|
+
export type CallbackSigningKeyId = z.infer<typeof callbackSigningKeyIdSchema>;
|
|
63
|
+
export type OAuthClientId = z.infer<typeof oauthClientIdSchema>;
|
|
64
|
+
export type OAuthGrantId = z.infer<typeof oauthGrantIdSchema>;
|
|
65
|
+
export type OAuthGrantEventId = z.infer<typeof oauthGrantEventIdSchema>;
|
|
66
|
+
export type ScheduleId = z.infer<typeof scheduleIdSchema>;
|
|
67
|
+
export type OccurrenceId = z.infer<typeof occurrenceIdSchema>;
|
|
68
|
+
export type AttemptId = z.infer<typeof attemptIdSchema>;
|
|
69
|
+
export type DeliveryId = z.infer<typeof deliveryIdSchema>;
|
|
70
|
+
export type EndpointId = z.infer<typeof endpointIdSchema>;
|
|
71
|
+
export type OrganizationId = z.infer<typeof organizationIdSchema>;
|
|
72
|
+
export type ProjectId = z.infer<typeof projectIdSchema>;
|
|
73
|
+
export type EnvironmentId = z.infer<typeof environmentIdSchema>;
|
|
74
|
+
export type TestSinkId = z.infer<typeof testSinkIdSchema>;
|
|
75
|
+
export type TestSinkDeliveryId = z.infer<typeof testSinkDeliveryIdSchema>;
|
|
76
|
+
/**
|
|
77
|
+
* Random bytes, as the generator needs them.
|
|
78
|
+
*
|
|
79
|
+
* Injectable so a test can pin an exact identifier, and defaulted so no ordinary
|
|
80
|
+
* caller has to think about it.
|
|
81
|
+
*
|
|
82
|
+
* The default is `crypto.getRandomValues`, which is Web Crypto — a standard, not
|
|
83
|
+
* a Cloudflare extension. It is present in Workers, in Node 18 and later, and in
|
|
84
|
+
* every browser, so one implementation serves every runtime this package is used
|
|
85
|
+
* from. `identifiers.worker.test.ts` proves it in the Workers runtime rather than
|
|
86
|
+
* assuming it.
|
|
87
|
+
*
|
|
88
|
+
* Typing it needs `lib: ["ES2023", "WebWorker"]` in this package's tsconfig.
|
|
89
|
+
* That adds no dependency — `lib` selects TypeScript's built-in typings — and
|
|
90
|
+
* `types` stays empty, so the contract layer still has none.
|
|
91
|
+
*/
|
|
92
|
+
export type RandomBytes = (length: number) => Uint8Array;
|
|
93
|
+
/**
|
|
94
|
+
* Mint a public identifier.
|
|
95
|
+
*
|
|
96
|
+
* 26 characters of a 32-symbol alphabet is 130 bits, which is not a compromise
|
|
97
|
+
* between size and safety — identifiers appear in URLs and logs, and one that can
|
|
98
|
+
* be guessed is an enumeration oracle regardless of how well authorization works.
|
|
99
|
+
*
|
|
100
|
+
* `byte & 31` is unbiased because 256 is an exact multiple of 32. Using `%` on a
|
|
101
|
+
* non-power-of-two alphabet would skew the distribution toward its start, which is
|
|
102
|
+
* the classic way a generator quietly loses entropy; keeping the alphabet a power
|
|
103
|
+
* of two is what makes the cheap masking correct.
|
|
104
|
+
*/
|
|
105
|
+
export declare function newId(resource: IdResource, randomBytes?: RandomBytes): string;
|
|
106
|
+
/** The generator's alphabet, exported so tests can pin it against `SUFFIX`. */
|
|
107
|
+
export declare const ID_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
|
|
108
|
+
/** Which resource an identifier names, or undefined if it is not one of ours. */
|
|
109
|
+
export declare function resourceOf(id: string): IdResource | undefined;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Public identifiers.
|
|
4
|
+
*
|
|
5
|
+
* Every identifier a client sees is opaque, prefixed, and server-assigned.
|
|
6
|
+
* Clients must treat the part after the prefix as meaningless and compare
|
|
7
|
+
* identifiers only for equality.
|
|
8
|
+
*
|
|
9
|
+
* This is a deliberate break from the legacy scheduler, where the caller
|
|
10
|
+
* supplied `id` and tenancy travelled in the path as `:owner/:partition/:id`.
|
|
11
|
+
* Caller-chosen identifiers leak the caller's namespace into ours, make
|
|
12
|
+
* collision handling a client concern, and make it impossible to change
|
|
13
|
+
* internal representation later. See `docs/architecture/legacy-provenance.md`.
|
|
14
|
+
*/
|
|
15
|
+
/** The resource a prefix names. Extending this list is an additive change. */
|
|
16
|
+
export const ID_PREFIXES = {
|
|
17
|
+
apiCredential: "ak",
|
|
18
|
+
// One entry in a credential's audit trail. Its own prefix rather than
|
|
19
|
+
// `event`, which names the domain events in `events.ts` that consumers
|
|
20
|
+
// subscribe to — two different things called "event" sharing one namespace
|
|
21
|
+
// would make an identifier ambiguous about which stream it belongs to.
|
|
22
|
+
apiCredentialEvent: "ake",
|
|
23
|
+
attempt: "att",
|
|
24
|
+
// A pending OAuth authorization, alive only between the client's redirect and
|
|
25
|
+
// the member's answer. It is an identifier rather than an opaque blob because
|
|
26
|
+
// the consent screen has to fetch it by name, and a blob in a URL is a blob
|
|
27
|
+
// in a browser history entry.
|
|
28
|
+
authorizationRequest: "oar",
|
|
29
|
+
// One version of a callback endpoint's signing material. Public and
|
|
30
|
+
// non-secret: receivers use it to select a secret during bounded rotation.
|
|
31
|
+
callbackSigningKey: "csk",
|
|
32
|
+
delivery: "dlv",
|
|
33
|
+
endpoint: "ep",
|
|
34
|
+
environment: "env",
|
|
35
|
+
event: "evt",
|
|
36
|
+
oauthClient: "oc",
|
|
37
|
+
oauthGrant: "og",
|
|
38
|
+
// One entry in a grant's audit trail, named apart from `oauthGrant` for the
|
|
39
|
+
// reason `apiCredentialEvent` is named apart from `apiCredential`.
|
|
40
|
+
oauthGrantEvent: "oge",
|
|
41
|
+
occurrence: "occ",
|
|
42
|
+
organization: "org",
|
|
43
|
+
project: "proj",
|
|
44
|
+
schedule: "sch",
|
|
45
|
+
// A deterministic test-only callback receiver, and one delivery it recorded.
|
|
46
|
+
// The receipt has its own prefix rather than reusing `delivery`, which names
|
|
47
|
+
// the *sender's* identity for an attempt: one delivery id can produce a
|
|
48
|
+
// receipt at more than one sink, and a receipt exists even for a request that
|
|
49
|
+
// never authenticated and therefore has no trustworthy delivery id at all.
|
|
50
|
+
testSink: "tsk",
|
|
51
|
+
testSinkDelivery: "tsd",
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Crockford base32 without padding, lowercase only: unambiguous when read
|
|
55
|
+
* aloud or transcribed, because it excludes I, L, O and U. Input is not
|
|
56
|
+
* case-folded — an uppercase identifier is refused rather than accepted, so
|
|
57
|
+
* there is exactly one spelling of any identifier.
|
|
58
|
+
*/
|
|
59
|
+
const SUFFIX = "[0-9abcdefghjkmnpqrstvwxyz]{26}";
|
|
60
|
+
export function idPattern(resource) {
|
|
61
|
+
return new RegExp(`^${ID_PREFIXES[resource]}_${SUFFIX}$`, "u");
|
|
62
|
+
}
|
|
63
|
+
/** Any well-formed public identifier, whatever it names. */
|
|
64
|
+
export const ANY_ID_PATTERN = new RegExp(`^(?:${Object.values(ID_PREFIXES).join("|")})_${SUFFIX}$`, "u");
|
|
65
|
+
export function idSchema(resource) {
|
|
66
|
+
return z
|
|
67
|
+
.string()
|
|
68
|
+
.regex(idPattern(resource), `must be a ${resource} identifier: "${ID_PREFIXES[resource]}_" followed by 26 Crockford base32 characters`);
|
|
69
|
+
}
|
|
70
|
+
export const apiCredentialIdSchema = idSchema("apiCredential");
|
|
71
|
+
export const apiCredentialEventIdSchema = idSchema("apiCredentialEvent");
|
|
72
|
+
export const authorizationRequestIdSchema = idSchema("authorizationRequest");
|
|
73
|
+
export const callbackSigningKeyIdSchema = idSchema("callbackSigningKey");
|
|
74
|
+
export const oauthClientIdSchema = idSchema("oauthClient");
|
|
75
|
+
export const oauthGrantIdSchema = idSchema("oauthGrant");
|
|
76
|
+
export const oauthGrantEventIdSchema = idSchema("oauthGrantEvent");
|
|
77
|
+
export const scheduleIdSchema = idSchema("schedule");
|
|
78
|
+
export const occurrenceIdSchema = idSchema("occurrence");
|
|
79
|
+
export const attemptIdSchema = idSchema("attempt");
|
|
80
|
+
export const deliveryIdSchema = idSchema("delivery");
|
|
81
|
+
export const endpointIdSchema = idSchema("endpoint");
|
|
82
|
+
export const organizationIdSchema = idSchema("organization");
|
|
83
|
+
export const projectIdSchema = idSchema("project");
|
|
84
|
+
export const environmentIdSchema = idSchema("environment");
|
|
85
|
+
export const eventIdSchema = idSchema("event");
|
|
86
|
+
export const testSinkIdSchema = idSchema("testSink");
|
|
87
|
+
export const testSinkDeliveryIdSchema = idSchema("testSinkDelivery");
|
|
88
|
+
/**
|
|
89
|
+
* The alphabet the pattern above accepts, in order.
|
|
90
|
+
*
|
|
91
|
+
* Derived from nothing — written out — but `identifiers.test.ts` asserts every
|
|
92
|
+
* character satisfies `SUFFIX` and that the length is exactly 32. A generator and
|
|
93
|
+
* a validator that disagree would mint identifiers the product then refuses.
|
|
94
|
+
*/
|
|
95
|
+
const ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
|
|
96
|
+
/** How many characters follow the prefix. Matches `SUFFIX`. */
|
|
97
|
+
const SUFFIX_LENGTH = 26;
|
|
98
|
+
const defaultRandomBytes = (length) => crypto.getRandomValues(new Uint8Array(length));
|
|
99
|
+
/**
|
|
100
|
+
* Mint a public identifier.
|
|
101
|
+
*
|
|
102
|
+
* 26 characters of a 32-symbol alphabet is 130 bits, which is not a compromise
|
|
103
|
+
* between size and safety — identifiers appear in URLs and logs, and one that can
|
|
104
|
+
* be guessed is an enumeration oracle regardless of how well authorization works.
|
|
105
|
+
*
|
|
106
|
+
* `byte & 31` is unbiased because 256 is an exact multiple of 32. Using `%` on a
|
|
107
|
+
* non-power-of-two alphabet would skew the distribution toward its start, which is
|
|
108
|
+
* the classic way a generator quietly loses entropy; keeping the alphabet a power
|
|
109
|
+
* of two is what makes the cheap masking correct.
|
|
110
|
+
*/
|
|
111
|
+
export function newId(resource, randomBytes = defaultRandomBytes) {
|
|
112
|
+
const bytes = randomBytes(SUFFIX_LENGTH);
|
|
113
|
+
if (bytes.length < SUFFIX_LENGTH) {
|
|
114
|
+
// A short read would silently shorten the identifier, and a short identifier
|
|
115
|
+
// still matches nothing else in the system — it would fail validation far
|
|
116
|
+
// from here, with no clue why.
|
|
117
|
+
throw new Error(`random source returned ${String(bytes.length)} bytes, needed ${String(SUFFIX_LENGTH)}`);
|
|
118
|
+
}
|
|
119
|
+
let suffix = "";
|
|
120
|
+
for (let index = 0; index < SUFFIX_LENGTH; index += 1) {
|
|
121
|
+
suffix += ALPHABET[bytes[index] & 31];
|
|
122
|
+
}
|
|
123
|
+
return `${ID_PREFIXES[resource]}_${suffix}`;
|
|
124
|
+
}
|
|
125
|
+
/** The generator's alphabet, exported so tests can pin it against `SUFFIX`. */
|
|
126
|
+
export const ID_ALPHABET = ALPHABET;
|
|
127
|
+
/** Which resource an identifier names, or undefined if it is not one of ours. */
|
|
128
|
+
export function resourceOf(id) {
|
|
129
|
+
const prefix = id.slice(0, id.indexOf("_"));
|
|
130
|
+
return Object.keys(ID_PREFIXES).find((resource) => ID_PREFIXES[resource] === prefix && idPattern(resource).test(id));
|
|
131
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* External identity, and what it projects into.
|
|
4
|
+
*
|
|
5
|
+
* Clerk is the authority for who a human is and which organizations they belong
|
|
6
|
+
* to. Greatwait keeps a projection of that so it can list and join, but the
|
|
7
|
+
* projection never authorizes on its own — a request carries current session
|
|
8
|
+
* claims, and a stale row must not outlive a removed membership. See
|
|
9
|
+
* `packages/domain/src/membership.ts` for the reconciliation rules.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* A Clerk organization identifier.
|
|
13
|
+
*
|
|
14
|
+
* **This is not one of our identifiers, despite sharing the `org_` prefix.**
|
|
15
|
+
* A real Clerk id — `org_2DKoiPJ56JpKrgPoKXTyo49GvE8` — fails
|
|
16
|
+
* `organizationIdSchema`, which requires exactly 26 characters of lowercase
|
|
17
|
+
* Crockford base32. That direction is pinned by test and is the one that
|
|
18
|
+
* matters in practice: it means a Clerk id can never be mistaken for, or stored
|
|
19
|
+
* as, a Greatwait identifier.
|
|
20
|
+
*
|
|
21
|
+
* The converse does **not** hold, and it would be dishonest to imply otherwise:
|
|
22
|
+
* our identifiers are alphanumeric and 26 characters long, so every Greatwait
|
|
23
|
+
* `org_…` also satisfies the pattern below. Tightening this schema to exclude
|
|
24
|
+
* them would trade a theoretical confusion for a real failure mode — a Clerk id
|
|
25
|
+
* that happened to collide would be refused, dropping a genuine membership
|
|
26
|
+
* event and leaving the projection stale.
|
|
27
|
+
*
|
|
28
|
+
* What actually keeps the two apart is structural, not lexical: external ids
|
|
29
|
+
* live in their own columns, are typed distinctly here, and are mapped to a
|
|
30
|
+
* server-assigned `org_…` of ours at the projection boundary. An external id
|
|
31
|
+
* never appears in a public response, because leaking one would make our public
|
|
32
|
+
* surface depend on our identity vendor and hand callers a second spelling of
|
|
33
|
+
* the same tenant.
|
|
34
|
+
*/
|
|
35
|
+
export declare const externalOrganizationIdSchema: z.ZodString;
|
|
36
|
+
export declare const externalUserIdSchema: z.ZodString;
|
|
37
|
+
/**
|
|
38
|
+
* A Clerk organization-membership identifier, and the projection's primary key.
|
|
39
|
+
*
|
|
40
|
+
* `(organization, user)` would be the obvious key and is the wrong one: it is
|
|
41
|
+
* not unique over time. Leaving and rejoining an organization mints a new
|
|
42
|
+
* membership, so keying on the pair would make a rejoin overwrite the record of
|
|
43
|
+
* the departure. Keyed on the id, the old membership stays as a removed row and
|
|
44
|
+
* the new one is a separate row, which is also what makes the two orderable
|
|
45
|
+
* independently.
|
|
46
|
+
*/
|
|
47
|
+
export declare const externalMembershipIdSchema: z.ZodString;
|
|
48
|
+
export type ExternalOrganizationId = z.infer<typeof externalOrganizationIdSchema>;
|
|
49
|
+
export type ExternalUserId = z.infer<typeof externalUserIdSchema>;
|
|
50
|
+
export type ExternalMembershipId = z.infer<typeof externalMembershipIdSchema>;
|
|
51
|
+
/**
|
|
52
|
+
* Product roles, projected from Clerk's `org:admin` and `org:member`.
|
|
53
|
+
*
|
|
54
|
+
* Named without the `org:` prefix because these are ours: W1-03 builds the
|
|
55
|
+
* role-to-scope matrix on them, and binding that matrix to a vendor's string
|
|
56
|
+
* format would make changing identity providers a contract change.
|
|
57
|
+
*/
|
|
58
|
+
export declare const MEMBERSHIP_ROLES: readonly ["admin", "member"];
|
|
59
|
+
export declare const membershipRoleSchema: z.ZodEnum<{
|
|
60
|
+
admin: "admin";
|
|
61
|
+
member: "member";
|
|
62
|
+
}>;
|
|
63
|
+
export type MembershipRole = z.infer<typeof membershipRoleSchema>;
|
|
64
|
+
/**
|
|
65
|
+
* Whether a membership currently grants anything.
|
|
66
|
+
*
|
|
67
|
+
* `suspended` is distinct from `removed` because they recover differently: a
|
|
68
|
+
* suspension is lifted and the same membership resumes, whereas a removal ends
|
|
69
|
+
* it and rejoining creates a new one. Both refuse access.
|
|
70
|
+
*/
|
|
71
|
+
export declare const MEMBERSHIP_STATUSES: readonly ["active", "suspended", "removed"];
|
|
72
|
+
export declare const membershipStatusSchema: z.ZodEnum<{
|
|
73
|
+
removed: "removed";
|
|
74
|
+
active: "active";
|
|
75
|
+
suspended: "suspended";
|
|
76
|
+
}>;
|
|
77
|
+
export type MembershipStatus = z.infer<typeof membershipStatusSchema>;
|
|
78
|
+
/** Whether a status grants any access at all. */
|
|
79
|
+
export declare function grantsAccess(status: MembershipStatus): boolean;
|
|
80
|
+
/** The role granting no more access than either argument. */
|
|
81
|
+
export declare function leastRole(left: MembershipRole, right: MembershipRole): MembershipRole;
|
|
82
|
+
/** The status granting no more access than either argument. */
|
|
83
|
+
export declare function leastStatus(left: MembershipStatus, right: MembershipStatus): MembershipStatus;
|
|
84
|
+
/**
|
|
85
|
+
* A membership as currently projected.
|
|
86
|
+
*
|
|
87
|
+
* `version` is Clerk's `updated_at` in milliseconds — the source's own notion of
|
|
88
|
+
* when the change happened, carried on every membership event. It is never a
|
|
89
|
+
* counter we increment on arrival and never the Svix delivery time: either
|
|
90
|
+
* would make whichever event arrived last win, which is precisely the bug
|
|
91
|
+
* acceptance criterion 2 is about.
|
|
92
|
+
*
|
|
93
|
+
* Every field here arrives together on every membership event, including
|
|
94
|
+
* deletion. That is worth stating because it is what makes the projection fully
|
|
95
|
+
* convergent: there is no event that updates the status while leaving the role
|
|
96
|
+
* behind at an older version, so one version orders the whole record.
|
|
97
|
+
*/
|
|
98
|
+
export declare const membershipSchema: z.ZodObject<{
|
|
99
|
+
id: z.ZodString;
|
|
100
|
+
organization: z.ZodString;
|
|
101
|
+
role: z.ZodEnum<{
|
|
102
|
+
admin: "admin";
|
|
103
|
+
member: "member";
|
|
104
|
+
}>;
|
|
105
|
+
status: z.ZodEnum<{
|
|
106
|
+
removed: "removed";
|
|
107
|
+
active: "active";
|
|
108
|
+
suspended: "suspended";
|
|
109
|
+
}>;
|
|
110
|
+
user: z.ZodString;
|
|
111
|
+
version: z.ZodNumber;
|
|
112
|
+
}, z.core.$strip>;
|
|
113
|
+
export type Membership = z.infer<typeof membershipSchema>;
|
|
114
|
+
/**
|
|
115
|
+
* One change, normalized by the adapter from a Clerk webhook payload.
|
|
116
|
+
*
|
|
117
|
+
* Identical in shape to a projected membership, because Clerk's
|
|
118
|
+
* `organizationMembership.created`, `.updated`, and `.deleted` all deliver the
|
|
119
|
+
* same full `OrganizationMembershipJSON` — deletion included. Verified against
|
|
120
|
+
* `@clerk/backend`'s own types rather than assumed; an earlier design here
|
|
121
|
+
* carried a second id-only variant for deletions, which no event actually
|
|
122
|
+
* produces.
|
|
123
|
+
*
|
|
124
|
+
* Normalizing at the adapter edge keeps every Clerk-shaped concern — the `org:`
|
|
125
|
+
* role prefix, `public_user_data` nesting, which event type implies which
|
|
126
|
+
* status — out of the domain, which sees only a membership asserted to hold at
|
|
127
|
+
* a version.
|
|
128
|
+
*
|
|
129
|
+
* `organization.deleted` is **not** a membership change and is modelled
|
|
130
|
+
* separately, as `organizationTombstoneSchema`: it carries a `DeletedObjectJSON`
|
|
131
|
+
* with no timestamp, and it withdraws every membership an organization held
|
|
132
|
+
* rather than one. See FXO-1377.
|
|
133
|
+
*
|
|
134
|
+
* `user.deleted` needs nothing. Measured against staging: deleting a user *does*
|
|
135
|
+
* emit `organizationMembership.deleted`, which arrives here like any other
|
|
136
|
+
* change. Deleting an organization does not. The asymmetry is real and is why
|
|
137
|
+
* only one of the two has a tombstone.
|
|
138
|
+
*/
|
|
139
|
+
export declare const membershipChangeSchema: z.ZodObject<{
|
|
140
|
+
id: z.ZodString;
|
|
141
|
+
organization: z.ZodString;
|
|
142
|
+
role: z.ZodEnum<{
|
|
143
|
+
admin: "admin";
|
|
144
|
+
member: "member";
|
|
145
|
+
}>;
|
|
146
|
+
status: z.ZodEnum<{
|
|
147
|
+
removed: "removed";
|
|
148
|
+
active: "active";
|
|
149
|
+
suspended: "suspended";
|
|
150
|
+
}>;
|
|
151
|
+
user: z.ZodString;
|
|
152
|
+
version: z.ZodNumber;
|
|
153
|
+
}, z.core.$strip>;
|
|
154
|
+
export type MembershipChange = Membership;
|
|
155
|
+
/**
|
|
156
|
+
* The membership a verified session asserts.
|
|
157
|
+
*
|
|
158
|
+
* Clerk is the identity authority, so this comes from signed session claims and
|
|
159
|
+
* never from the projection. `assertedAt` is the token's issued-at in
|
|
160
|
+
* milliseconds — when Clerk vouched for this membership — which is what makes it
|
|
161
|
+
* comparable against a projected `version` from the same clock.
|
|
162
|
+
*/
|
|
163
|
+
export declare const sessionMembershipSchema: z.ZodObject<{
|
|
164
|
+
assertedAt: z.ZodNumber;
|
|
165
|
+
organization: z.ZodString;
|
|
166
|
+
role: z.ZodEnum<{
|
|
167
|
+
admin: "admin";
|
|
168
|
+
member: "member";
|
|
169
|
+
}>;
|
|
170
|
+
user: z.ZodString;
|
|
171
|
+
}, z.core.$strip>;
|
|
172
|
+
export type SessionMembership = z.infer<typeof sessionMembershipSchema>;
|
|
173
|
+
/**
|
|
174
|
+
* A record that an organization was deleted.
|
|
175
|
+
*
|
|
176
|
+
* Kept rather than deleting its memberships, for two reasons. A membership event
|
|
177
|
+
* can arrive *after* the deletion — retries are unordered — and without a durable
|
|
178
|
+
* marker there would be nothing to stop it reinstating access. And a tombstone
|
|
179
|
+
* preserves who had access when the organization ended, which a cascade delete
|
|
180
|
+
* would destroy.
|
|
181
|
+
*
|
|
182
|
+
* `version` comes from the event envelope's `timestamp`, not from the payload:
|
|
183
|
+
* Clerk's `organization.deleted` carries a `DeletedObjectJSON` with no timestamp
|
|
184
|
+
* at all. The envelope value is still Clerk's own clock in milliseconds, so it is
|
|
185
|
+
* comparable with a membership's `updated_at`.
|
|
186
|
+
*
|
|
187
|
+
* Measured on 2026-07-29: deleting an organization emits **no** per-membership
|
|
188
|
+
* deletion, so without this the projection keeps its memberships reading `active`
|
|
189
|
+
* for an organization that no longer exists. Deleting a *user* does emit a
|
|
190
|
+
* membership deletion. That asymmetry is why this exists — see FXO-1377.
|
|
191
|
+
*/
|
|
192
|
+
export declare const organizationTombstoneSchema: z.ZodObject<{
|
|
193
|
+
organization: z.ZodString;
|
|
194
|
+
version: z.ZodNumber;
|
|
195
|
+
}, z.core.$strip>;
|
|
196
|
+
export type OrganizationTombstone = z.infer<typeof organizationTombstoneSchema>;
|
package/dist/identity.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* External identity, and what it projects into.
|
|
4
|
+
*
|
|
5
|
+
* Clerk is the authority for who a human is and which organizations they belong
|
|
6
|
+
* to. Greatwait keeps a projection of that so it can list and join, but the
|
|
7
|
+
* projection never authorizes on its own — a request carries current session
|
|
8
|
+
* claims, and a stale row must not outlive a removed membership. See
|
|
9
|
+
* `packages/domain/src/membership.ts` for the reconciliation rules.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* A Clerk organization identifier.
|
|
13
|
+
*
|
|
14
|
+
* **This is not one of our identifiers, despite sharing the `org_` prefix.**
|
|
15
|
+
* A real Clerk id — `org_2DKoiPJ56JpKrgPoKXTyo49GvE8` — fails
|
|
16
|
+
* `organizationIdSchema`, which requires exactly 26 characters of lowercase
|
|
17
|
+
* Crockford base32. That direction is pinned by test and is the one that
|
|
18
|
+
* matters in practice: it means a Clerk id can never be mistaken for, or stored
|
|
19
|
+
* as, a Greatwait identifier.
|
|
20
|
+
*
|
|
21
|
+
* The converse does **not** hold, and it would be dishonest to imply otherwise:
|
|
22
|
+
* our identifiers are alphanumeric and 26 characters long, so every Greatwait
|
|
23
|
+
* `org_…` also satisfies the pattern below. Tightening this schema to exclude
|
|
24
|
+
* them would trade a theoretical confusion for a real failure mode — a Clerk id
|
|
25
|
+
* that happened to collide would be refused, dropping a genuine membership
|
|
26
|
+
* event and leaving the projection stale.
|
|
27
|
+
*
|
|
28
|
+
* What actually keeps the two apart is structural, not lexical: external ids
|
|
29
|
+
* live in their own columns, are typed distinctly here, and are mapped to a
|
|
30
|
+
* server-assigned `org_…` of ours at the projection boundary. An external id
|
|
31
|
+
* never appears in a public response, because leaking one would make our public
|
|
32
|
+
* surface depend on our identity vendor and hand callers a second spelling of
|
|
33
|
+
* the same tenant.
|
|
34
|
+
*/
|
|
35
|
+
export const externalOrganizationIdSchema = z
|
|
36
|
+
.string()
|
|
37
|
+
.regex(/^org_[A-Za-z0-9]{1,64}$/u, 'must be a Clerk organization identifier: "org_" followed by alphanumerics');
|
|
38
|
+
export const externalUserIdSchema = z
|
|
39
|
+
.string()
|
|
40
|
+
.regex(/^user_[A-Za-z0-9]{1,64}$/u, 'must be a Clerk user identifier: "user_" followed by alphanumerics');
|
|
41
|
+
/**
|
|
42
|
+
* A Clerk organization-membership identifier, and the projection's primary key.
|
|
43
|
+
*
|
|
44
|
+
* `(organization, user)` would be the obvious key and is the wrong one: it is
|
|
45
|
+
* not unique over time. Leaving and rejoining an organization mints a new
|
|
46
|
+
* membership, so keying on the pair would make a rejoin overwrite the record of
|
|
47
|
+
* the departure. Keyed on the id, the old membership stays as a removed row and
|
|
48
|
+
* the new one is a separate row, which is also what makes the two orderable
|
|
49
|
+
* independently.
|
|
50
|
+
*/
|
|
51
|
+
export const externalMembershipIdSchema = z
|
|
52
|
+
.string()
|
|
53
|
+
.regex(/^orgmem_[A-Za-z0-9]{1,64}$/u, 'must be a Clerk membership identifier: "orgmem_" followed by alphanumerics');
|
|
54
|
+
/**
|
|
55
|
+
* Product roles, projected from Clerk's `org:admin` and `org:member`.
|
|
56
|
+
*
|
|
57
|
+
* Named without the `org:` prefix because these are ours: W1-03 builds the
|
|
58
|
+
* role-to-scope matrix on them, and binding that matrix to a vendor's string
|
|
59
|
+
* format would make changing identity providers a contract change.
|
|
60
|
+
*/
|
|
61
|
+
export const MEMBERSHIP_ROLES = ["admin", "member"];
|
|
62
|
+
export const membershipRoleSchema = z.enum(MEMBERSHIP_ROLES);
|
|
63
|
+
/**
|
|
64
|
+
* Whether a membership currently grants anything.
|
|
65
|
+
*
|
|
66
|
+
* `suspended` is distinct from `removed` because they recover differently: a
|
|
67
|
+
* suspension is lifted and the same membership resumes, whereas a removal ends
|
|
68
|
+
* it and rejoining creates a new one. Both refuse access.
|
|
69
|
+
*/
|
|
70
|
+
export const MEMBERSHIP_STATUSES = ["active", "suspended", "removed"];
|
|
71
|
+
export const membershipStatusSchema = z.enum(MEMBERSHIP_STATUSES);
|
|
72
|
+
/** Whether a status grants any access at all. */
|
|
73
|
+
export function grantsAccess(status) {
|
|
74
|
+
return status === "active";
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* How much access each role carries, most restrictive first. Used only to break
|
|
78
|
+
* ties between changes that claim the same instant — see
|
|
79
|
+
* `reconcileMembership`.
|
|
80
|
+
*/
|
|
81
|
+
const ROLE_RANK = {
|
|
82
|
+
admin: 1,
|
|
83
|
+
member: 0,
|
|
84
|
+
};
|
|
85
|
+
const STATUS_RANK = {
|
|
86
|
+
active: 2,
|
|
87
|
+
removed: 0,
|
|
88
|
+
suspended: 1,
|
|
89
|
+
};
|
|
90
|
+
/** The role granting no more access than either argument. */
|
|
91
|
+
export function leastRole(left, right) {
|
|
92
|
+
return ROLE_RANK[left] <= ROLE_RANK[right] ? left : right;
|
|
93
|
+
}
|
|
94
|
+
/** The status granting no more access than either argument. */
|
|
95
|
+
export function leastStatus(left, right) {
|
|
96
|
+
return STATUS_RANK[left] <= STATUS_RANK[right] ? left : right;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* A membership as currently projected.
|
|
100
|
+
*
|
|
101
|
+
* `version` is Clerk's `updated_at` in milliseconds — the source's own notion of
|
|
102
|
+
* when the change happened, carried on every membership event. It is never a
|
|
103
|
+
* counter we increment on arrival and never the Svix delivery time: either
|
|
104
|
+
* would make whichever event arrived last win, which is precisely the bug
|
|
105
|
+
* acceptance criterion 2 is about.
|
|
106
|
+
*
|
|
107
|
+
* Every field here arrives together on every membership event, including
|
|
108
|
+
* deletion. That is worth stating because it is what makes the projection fully
|
|
109
|
+
* convergent: there is no event that updates the status while leaving the role
|
|
110
|
+
* behind at an older version, so one version orders the whole record.
|
|
111
|
+
*/
|
|
112
|
+
export const membershipSchema = z.object({
|
|
113
|
+
id: externalMembershipIdSchema,
|
|
114
|
+
organization: externalOrganizationIdSchema,
|
|
115
|
+
role: membershipRoleSchema,
|
|
116
|
+
status: membershipStatusSchema,
|
|
117
|
+
user: externalUserIdSchema,
|
|
118
|
+
version: z.number().int().nonnegative(),
|
|
119
|
+
});
|
|
120
|
+
/**
|
|
121
|
+
* One change, normalized by the adapter from a Clerk webhook payload.
|
|
122
|
+
*
|
|
123
|
+
* Identical in shape to a projected membership, because Clerk's
|
|
124
|
+
* `organizationMembership.created`, `.updated`, and `.deleted` all deliver the
|
|
125
|
+
* same full `OrganizationMembershipJSON` — deletion included. Verified against
|
|
126
|
+
* `@clerk/backend`'s own types rather than assumed; an earlier design here
|
|
127
|
+
* carried a second id-only variant for deletions, which no event actually
|
|
128
|
+
* produces.
|
|
129
|
+
*
|
|
130
|
+
* Normalizing at the adapter edge keeps every Clerk-shaped concern — the `org:`
|
|
131
|
+
* role prefix, `public_user_data` nesting, which event type implies which
|
|
132
|
+
* status — out of the domain, which sees only a membership asserted to hold at
|
|
133
|
+
* a version.
|
|
134
|
+
*
|
|
135
|
+
* `organization.deleted` is **not** a membership change and is modelled
|
|
136
|
+
* separately, as `organizationTombstoneSchema`: it carries a `DeletedObjectJSON`
|
|
137
|
+
* with no timestamp, and it withdraws every membership an organization held
|
|
138
|
+
* rather than one. See FXO-1377.
|
|
139
|
+
*
|
|
140
|
+
* `user.deleted` needs nothing. Measured against staging: deleting a user *does*
|
|
141
|
+
* emit `organizationMembership.deleted`, which arrives here like any other
|
|
142
|
+
* change. Deleting an organization does not. The asymmetry is real and is why
|
|
143
|
+
* only one of the two has a tombstone.
|
|
144
|
+
*/
|
|
145
|
+
export const membershipChangeSchema = membershipSchema;
|
|
146
|
+
/**
|
|
147
|
+
* The membership a verified session asserts.
|
|
148
|
+
*
|
|
149
|
+
* Clerk is the identity authority, so this comes from signed session claims and
|
|
150
|
+
* never from the projection. `assertedAt` is the token's issued-at in
|
|
151
|
+
* milliseconds — when Clerk vouched for this membership — which is what makes it
|
|
152
|
+
* comparable against a projected `version` from the same clock.
|
|
153
|
+
*/
|
|
154
|
+
export const sessionMembershipSchema = z.object({
|
|
155
|
+
assertedAt: z.number().int().nonnegative(),
|
|
156
|
+
organization: externalOrganizationIdSchema,
|
|
157
|
+
role: membershipRoleSchema,
|
|
158
|
+
user: externalUserIdSchema,
|
|
159
|
+
});
|
|
160
|
+
/**
|
|
161
|
+
* A record that an organization was deleted.
|
|
162
|
+
*
|
|
163
|
+
* Kept rather than deleting its memberships, for two reasons. A membership event
|
|
164
|
+
* can arrive *after* the deletion — retries are unordered — and without a durable
|
|
165
|
+
* marker there would be nothing to stop it reinstating access. And a tombstone
|
|
166
|
+
* preserves who had access when the organization ended, which a cascade delete
|
|
167
|
+
* would destroy.
|
|
168
|
+
*
|
|
169
|
+
* `version` comes from the event envelope's `timestamp`, not from the payload:
|
|
170
|
+
* Clerk's `organization.deleted` carries a `DeletedObjectJSON` with no timestamp
|
|
171
|
+
* at all. The envelope value is still Clerk's own clock in milliseconds, so it is
|
|
172
|
+
* comparable with a membership's `updated_at`.
|
|
173
|
+
*
|
|
174
|
+
* Measured on 2026-07-29: deleting an organization emits **no** per-membership
|
|
175
|
+
* deletion, so without this the projection keeps its memberships reading `active`
|
|
176
|
+
* for an organization that no longer exists. Deleting a *user* does emit a
|
|
177
|
+
* membership deletion. That asymmetry is why this exists — see FXO-1377.
|
|
178
|
+
*/
|
|
179
|
+
export const organizationTombstoneSchema = z.object({
|
|
180
|
+
organization: externalOrganizationIdSchema,
|
|
181
|
+
version: z.number().int().nonnegative(),
|
|
182
|
+
});
|