@godxjp/server-sdk 0.1.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/dist/org.d.ts ADDED
@@ -0,0 +1,126 @@
1
+ import { GodxHttpError } from './http.js';
2
+ import 'jose';
3
+
4
+ /**
5
+ * ./org — typed client for the Delegated Organization API (`/api/org/v1/*`),
6
+ * the platform's write-back surface: the ONLY sanctioned way a service changes
7
+ * platform-owned directory data. The identity feed then carries the change to
8
+ * every consumer (the feed itself is one-way; see ./sync).
9
+ *
10
+ * Auth model, exactly as the platform enforces it (routes/api/org.php):
11
+ * - Bearer access token minted FOR the `organization-api` recipient resource
12
+ * (`aud` contains `{issuer}/api/org/v1`) by a `user`-lane principal.
13
+ * - Tenancy is the token's organization, re-derived server-side per request —
14
+ * this client never sends an organization id.
15
+ * - Each route needs its `org.{resource}:{read|write}` scope; a missing scope
16
+ * answers 403 `insufficient_scope` with a WWW-Authenticate header (RFC 6750),
17
+ * which this client surfaces verbatim.
18
+ *
19
+ * NOTE (2026-09): the platform grants that audience on the MINI-APP lane only
20
+ * (`TokenIssuer::miniAppResourceAudiences`). A downstream service-launch token
21
+ * does not carry it yet — godx-jp/id has the proposal to widen that lane.
22
+ *
23
+ * International-standard behaviours built in:
24
+ * - `Idempotency-Key` (IETF draft-ietf-httpapi-idempotency-key-header) is sent
25
+ * on every write — generated when not supplied. The org/v1 surface does not
26
+ * *honor* it yet (the admin surface does); sending it is forward-compatible
27
+ * and costs nothing.
28
+ * - 401/403 surface the `WWW-Authenticate` challenge; 429 surfaces
29
+ * `Retry-After`; validation errors (422) surface the per-field messages.
30
+ * - Page-based pagination is wrapped in an async iterator so callers cannot
31
+ * forget page 2.
32
+ */
33
+
34
+ interface OrgApiConfig {
35
+ issuer: string;
36
+ /** Bearer access token whose aud includes {issuer}/api/org/v1. */
37
+ accessToken: string;
38
+ }
39
+ interface PageMeta {
40
+ current_page: number;
41
+ last_page: number;
42
+ per_page: number;
43
+ total: number;
44
+ }
45
+ interface Page<T> {
46
+ data: T[];
47
+ meta: PageMeta;
48
+ }
49
+ interface OrgEmployee {
50
+ id: string;
51
+ employee_code: string;
52
+ full_name: string;
53
+ email?: string | null;
54
+ department_id?: string | null;
55
+ branch_id?: string | null;
56
+ user_id?: string | null;
57
+ [key: string]: unknown;
58
+ }
59
+ interface OrgDepartment {
60
+ id: string;
61
+ code?: string | null;
62
+ name: string;
63
+ [key: string]: unknown;
64
+ }
65
+ interface OrgBranch {
66
+ id: string;
67
+ code?: string | null;
68
+ name: string;
69
+ [key: string]: unknown;
70
+ }
71
+ interface OrgTeam {
72
+ id: string;
73
+ name: string;
74
+ [key: string]: unknown;
75
+ }
76
+ interface OrgMe {
77
+ [key: string]: unknown;
78
+ }
79
+ declare class OrgApiError extends GodxHttpError {
80
+ /** RFC 6750 challenge, when the platform sent one (401/403). */
81
+ readonly wwwAuthenticate: string | null;
82
+ /** Seconds to wait, when the platform answered 429 with Retry-After. */
83
+ readonly retryAfterSeconds: number | null;
84
+ /** Laravel validation messages per field, when the answer was 422. */
85
+ readonly validationErrors: Record<string, string[]> | null;
86
+ constructor(status: number, body: string, message: string, headers: Headers);
87
+ }
88
+ declare function createOrgApi(config: OrgApiConfig): {
89
+ /** GET /me — scope org:profile. */
90
+ me: () => Promise<OrgMe>;
91
+ employees: {
92
+ list: (page?: number) => Promise<Page<OrgEmployee>>;
93
+ all: () => AsyncGenerator<OrgEmployee, void, void>;
94
+ /** scope org.employees:write */
95
+ create: (payload: Record<string, unknown>, idempotencyKey?: string) => Promise<{
96
+ data: OrgEmployee;
97
+ }>;
98
+ /** Who this user reports to — rides org.employees:read. */
99
+ managerOf: (consoleUserId: string) => Promise<{
100
+ data: unknown;
101
+ }>;
102
+ };
103
+ departments: {
104
+ list: (page?: number) => Promise<Page<OrgDepartment>>;
105
+ all: () => AsyncGenerator<OrgDepartment, void, void>;
106
+ create: (payload: Record<string, unknown>, idempotencyKey?: string) => Promise<{
107
+ data: OrgDepartment;
108
+ }>;
109
+ };
110
+ branches: {
111
+ list: (page?: number) => Promise<Page<OrgBranch>>;
112
+ all: () => AsyncGenerator<OrgBranch, void, void>;
113
+ create: (payload: Record<string, unknown>, idempotencyKey?: string) => Promise<{
114
+ data: OrgBranch;
115
+ }>;
116
+ };
117
+ teams: {
118
+ list: (page?: number) => Promise<Page<OrgTeam>>;
119
+ all: () => AsyncGenerator<OrgTeam, void, void>;
120
+ create: (payload: Record<string, unknown>, idempotencyKey?: string) => Promise<{
121
+ data: OrgTeam;
122
+ }>;
123
+ };
124
+ };
125
+
126
+ export { type OrgApiConfig, OrgApiError, type OrgBranch, type OrgDepartment, type OrgEmployee, type OrgMe, type OrgTeam, type Page, type PageMeta, createOrgApi };
package/dist/org.js ADDED
@@ -0,0 +1,101 @@
1
+ import {
2
+ GodxHttpError
3
+ } from "./chunk-DMXYEWYY.js";
4
+
5
+ // src/org.ts
6
+ var OrgApiError = class extends GodxHttpError {
7
+ /** RFC 6750 challenge, when the platform sent one (401/403). */
8
+ wwwAuthenticate;
9
+ /** Seconds to wait, when the platform answered 429 with Retry-After. */
10
+ retryAfterSeconds;
11
+ /** Laravel validation messages per field, when the answer was 422. */
12
+ validationErrors;
13
+ constructor(status, body, message, headers) {
14
+ super(status, body, message);
15
+ this.name = "OrgApiError";
16
+ this.wwwAuthenticate = headers.get("WWW-Authenticate");
17
+ const retryAfter = headers.get("Retry-After");
18
+ this.retryAfterSeconds = retryAfter !== null && /^\d+$/.test(retryAfter) ? Number(retryAfter) : null;
19
+ let validation = null;
20
+ try {
21
+ const parsed = JSON.parse(body);
22
+ if (parsed && typeof parsed === "object" && parsed.errors) validation = parsed.errors;
23
+ } catch {
24
+ }
25
+ this.validationErrors = validation;
26
+ }
27
+ };
28
+ async function unwrap(response, what) {
29
+ if (!response.ok) {
30
+ const body = await response.text();
31
+ const challenge = response.headers.get("WWW-Authenticate");
32
+ const hint = response.status === 401 ? `the token is missing, expired, or was not minted for the organization-api audience${challenge ? ` \u2014 ${challenge}` : ""}` : response.status === 403 ? `the token lacks the route's org.* scope${challenge ? ` \u2014 ${challenge}` : ""}` : response.status === 422 ? "validation refused the payload (see validationErrors)" : response.status === 429 ? "rate limited \u2014 honor retryAfterSeconds" : "see the response body";
33
+ throw new OrgApiError(response.status, body, `${what} answered ${response.status}: ${hint}`, response.headers);
34
+ }
35
+ return await response.json();
36
+ }
37
+ function createOrgApi(config) {
38
+ const base = config.issuer.replace(/\/$/, "") + "/api/org/v1";
39
+ const get = async (path, query) => {
40
+ const url = new URL(base + path);
41
+ if (query) url.search = new URLSearchParams(query).toString();
42
+ const response = await fetch(url, {
43
+ headers: { Authorization: `Bearer ${config.accessToken}`, Accept: "application/json" }
44
+ });
45
+ return unwrap(response, `GET ${path}`);
46
+ };
47
+ const post = async (path, payload, idempotencyKey) => {
48
+ const response = await fetch(base + path, {
49
+ method: "POST",
50
+ headers: {
51
+ Authorization: `Bearer ${config.accessToken}`,
52
+ Accept: "application/json",
53
+ "Content-Type": "application/json",
54
+ "Idempotency-Key": idempotencyKey ?? crypto.randomUUID()
55
+ },
56
+ body: JSON.stringify(payload)
57
+ });
58
+ return unwrap(response, `POST ${path}`);
59
+ };
60
+ async function* paginate(path) {
61
+ let page = 1;
62
+ for (; ; ) {
63
+ const result = await get(path, { page: String(page) });
64
+ for (const row of result.data) yield row;
65
+ if (result.meta.current_page >= result.meta.last_page) return;
66
+ page = result.meta.current_page + 1;
67
+ }
68
+ }
69
+ return {
70
+ /** GET /me — scope org:profile. */
71
+ me: () => get("/me"),
72
+ employees: {
73
+ list: (page) => get("/employees", page ? { page: String(page) } : void 0),
74
+ all: () => paginate("/employees"),
75
+ /** scope org.employees:write */
76
+ create: (payload, idempotencyKey) => post("/employees", payload, idempotencyKey),
77
+ /** Who this user reports to — rides org.employees:read. */
78
+ managerOf: (consoleUserId) => get(`/users/${encodeURIComponent(consoleUserId)}/manager`)
79
+ },
80
+ departments: {
81
+ list: (page) => get("/departments", page ? { page: String(page) } : void 0),
82
+ all: () => paginate("/departments"),
83
+ create: (payload, idempotencyKey) => post("/departments", payload, idempotencyKey)
84
+ },
85
+ branches: {
86
+ list: (page) => get("/branches", page ? { page: String(page) } : void 0),
87
+ all: () => paginate("/branches"),
88
+ create: (payload, idempotencyKey) => post("/branches", payload, idempotencyKey)
89
+ },
90
+ teams: {
91
+ list: (page) => get("/teams", page ? { page: String(page) } : void 0),
92
+ all: () => paginate("/teams"),
93
+ create: (payload, idempotencyKey) => post("/teams", payload, idempotencyKey)
94
+ }
95
+ };
96
+ }
97
+ export {
98
+ OrgApiError,
99
+ createOrgApi
100
+ };
101
+ //# sourceMappingURL=org.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/org.ts"],"sourcesContent":["/**\n * ./org — typed client for the Delegated Organization API (`/api/org/v1/*`),\n * the platform's write-back surface: the ONLY sanctioned way a service changes\n * platform-owned directory data. The identity feed then carries the change to\n * every consumer (the feed itself is one-way; see ./sync).\n *\n * Auth model, exactly as the platform enforces it (routes/api/org.php):\n * - Bearer access token minted FOR the `organization-api` recipient resource\n * (`aud` contains `{issuer}/api/org/v1`) by a `user`-lane principal.\n * - Tenancy is the token's organization, re-derived server-side per request —\n * this client never sends an organization id.\n * - Each route needs its `org.{resource}:{read|write}` scope; a missing scope\n * answers 403 `insufficient_scope` with a WWW-Authenticate header (RFC 6750),\n * which this client surfaces verbatim.\n *\n * NOTE (2026-09): the platform grants that audience on the MINI-APP lane only\n * (`TokenIssuer::miniAppResourceAudiences`). A downstream service-launch token\n * does not carry it yet — godx-jp/id has the proposal to widen that lane.\n *\n * International-standard behaviours built in:\n * - `Idempotency-Key` (IETF draft-ietf-httpapi-idempotency-key-header) is sent\n * on every write — generated when not supplied. The org/v1 surface does not\n * *honor* it yet (the admin surface does); sending it is forward-compatible\n * and costs nothing.\n * - 401/403 surface the `WWW-Authenticate` challenge; 429 surfaces\n * `Retry-After`; validation errors (422) surface the per-field messages.\n * - Page-based pagination is wrapped in an async iterator so callers cannot\n * forget page 2.\n */\nimport { GodxHttpError } from \"./http\";\n\nexport interface OrgApiConfig {\n issuer: string;\n /** Bearer access token whose aud includes {issuer}/api/org/v1. */\n accessToken: string;\n}\n\nexport interface PageMeta {\n current_page: number;\n last_page: number;\n per_page: number;\n total: number;\n}\n\nexport interface Page<T> {\n data: T[];\n meta: PageMeta;\n}\n\nexport interface OrgEmployee {\n id: string;\n employee_code: string;\n full_name: string;\n email?: string | null;\n department_id?: string | null;\n branch_id?: string | null;\n user_id?: string | null;\n [key: string]: unknown;\n}\n\nexport interface OrgDepartment {\n id: string;\n code?: string | null;\n name: string;\n [key: string]: unknown;\n}\n\nexport interface OrgBranch {\n id: string;\n code?: string | null;\n name: string;\n [key: string]: unknown;\n}\n\nexport interface OrgTeam {\n id: string;\n name: string;\n [key: string]: unknown;\n}\n\nexport interface OrgMe {\n [key: string]: unknown;\n}\n\nexport class OrgApiError extends GodxHttpError {\n /** RFC 6750 challenge, when the platform sent one (401/403). */\n readonly wwwAuthenticate: string | null;\n /** Seconds to wait, when the platform answered 429 with Retry-After. */\n readonly retryAfterSeconds: number | null;\n /** Laravel validation messages per field, when the answer was 422. */\n readonly validationErrors: Record<string, string[]> | null;\n\n constructor(status: number, body: string, message: string, headers: Headers) {\n super(status, body, message);\n this.name = \"OrgApiError\";\n this.wwwAuthenticate = headers.get(\"WWW-Authenticate\");\n const retryAfter = headers.get(\"Retry-After\");\n this.retryAfterSeconds = retryAfter !== null && /^\\d+$/.test(retryAfter) ? Number(retryAfter) : null;\n let validation: Record<string, string[]> | null = null;\n try {\n const parsed = JSON.parse(body) as { errors?: Record<string, string[]> };\n if (parsed && typeof parsed === \"object\" && parsed.errors) validation = parsed.errors;\n } catch {\n /* body was not JSON — nothing more to surface */\n }\n this.validationErrors = validation;\n }\n}\n\nasync function unwrap<T>(response: Response, what: string): Promise<T> {\n if (!response.ok) {\n const body = await response.text();\n const challenge = response.headers.get(\"WWW-Authenticate\");\n const hint =\n response.status === 401\n ? `the token is missing, expired, or was not minted for the organization-api audience${challenge ? ` — ${challenge}` : \"\"}`\n : response.status === 403\n ? `the token lacks the route's org.* scope${challenge ? ` — ${challenge}` : \"\"}`\n : response.status === 422\n ? \"validation refused the payload (see validationErrors)\"\n : response.status === 429\n ? \"rate limited — honor retryAfterSeconds\"\n : \"see the response body\";\n throw new OrgApiError(response.status, body, `${what} answered ${response.status}: ${hint}`, response.headers);\n }\n return (await response.json()) as T;\n}\n\nexport function createOrgApi(config: OrgApiConfig) {\n const base = config.issuer.replace(/\\/$/, \"\") + \"/api/org/v1\";\n\n const get = async <T>(path: string, query?: Record<string, string>): Promise<T> => {\n const url = new URL(base + path);\n if (query) url.search = new URLSearchParams(query).toString();\n const response = await fetch(url, {\n headers: { Authorization: `Bearer ${config.accessToken}`, Accept: \"application/json\" },\n });\n return unwrap<T>(response, `GET ${path}`);\n };\n\n const post = async <T>(path: string, payload: Record<string, unknown>, idempotencyKey?: string): Promise<T> => {\n const response = await fetch(base + path, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${config.accessToken}`,\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\",\n \"Idempotency-Key\": idempotencyKey ?? crypto.randomUUID(),\n },\n body: JSON.stringify(payload),\n });\n return unwrap<T>(response, `POST ${path}`);\n };\n\n /** Follow page-based pagination to the end, yielding every row once. */\n async function* paginate<T>(path: string): AsyncGenerator<T, void, void> {\n let page = 1;\n for (;;) {\n const result = await get<Page<T>>(path, { page: String(page) });\n for (const row of result.data) yield row;\n if (result.meta.current_page >= result.meta.last_page) return;\n page = result.meta.current_page + 1;\n }\n }\n\n return {\n /** GET /me — scope org:profile. */\n me: () => get<OrgMe>(\"/me\"),\n\n employees: {\n list: (page?: number) => get<Page<OrgEmployee>>(\"/employees\", page ? { page: String(page) } : undefined),\n all: () => paginate<OrgEmployee>(\"/employees\"),\n /** scope org.employees:write */\n create: (payload: Record<string, unknown>, idempotencyKey?: string) =>\n post<{ data: OrgEmployee }>(\"/employees\", payload, idempotencyKey),\n /** Who this user reports to — rides org.employees:read. */\n managerOf: (consoleUserId: string) => get<{ data: unknown }>(`/users/${encodeURIComponent(consoleUserId)}/manager`),\n },\n\n departments: {\n list: (page?: number) => get<Page<OrgDepartment>>(\"/departments\", page ? { page: String(page) } : undefined),\n all: () => paginate<OrgDepartment>(\"/departments\"),\n create: (payload: Record<string, unknown>, idempotencyKey?: string) =>\n post<{ data: OrgDepartment }>(\"/departments\", payload, idempotencyKey),\n },\n\n branches: {\n list: (page?: number) => get<Page<OrgBranch>>(\"/branches\", page ? { page: String(page) } : undefined),\n all: () => paginate<OrgBranch>(\"/branches\"),\n create: (payload: Record<string, unknown>, idempotencyKey?: string) =>\n post<{ data: OrgBranch }>(\"/branches\", payload, idempotencyKey),\n },\n\n teams: {\n list: (page?: number) => get<Page<OrgTeam>>(\"/teams\", page ? { page: String(page) } : undefined),\n all: () => paginate<OrgTeam>(\"/teams\"),\n create: (payload: Record<string, unknown>, idempotencyKey?: string) =>\n post<{ data: OrgTeam }>(\"/teams\", payload, idempotencyKey),\n },\n };\n}\n"],"mappings":"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","names":[]}
package/dist/sync.d.ts ADDED
@@ -0,0 +1,174 @@
1
+ type IdentityFamily = "organization" | "brand" | "branch" | "user" | "employee" | "service_user_access" | "org_service_role" | "role_assignment";
2
+ declare const KNOWN_CONTRACT_VERSIONS: readonly ["v1", "v2"];
3
+ /** CloudEvents 1.0 envelope as the relay publishes it (contract §4). */
4
+ interface IdentityEnvelope {
5
+ specversion: "1.0";
6
+ /** UUIDv4 — THE idempotency key (same across multi-tenant fan-out copies). */
7
+ id: string;
8
+ /** Platform origin — pin per environment and verify. */
9
+ source: string;
10
+ /** jp.godx.identity.{family}.{action} */
11
+ type: string;
12
+ /** {family}/{id-the-consumer-holds} — exactly one "/", both halves filled. */
13
+ subject: string;
14
+ time: string;
15
+ datacontenttype: "application/json";
16
+ /** Globally monotonic ordering fence (identity_outbox.sequence). */
17
+ sequence: number;
18
+ data: Record<string, unknown>;
19
+ }
20
+ interface ParsedSubject {
21
+ family: IdentityFamily | (string & {});
22
+ id: string;
23
+ }
24
+ declare class SyncContractError extends Error {
25
+ constructor(message: string);
26
+ }
27
+ /** Split a CloudEvents/snapshot `subject`. Rejects anything the contract rejects. */
28
+ declare function parseSubject(subject: string): ParsedSubject;
29
+ /** Validate a raw payload into an IdentityEnvelope, with expected-vs-actual errors. */
30
+ declare function parseEnvelope(raw: unknown, opts?: {
31
+ expectedSource?: string;
32
+ }): IdentityEnvelope;
33
+ /**
34
+ * What a consumer implements to hold a projection — one code path for
35
+ * snapshot rows and stream events, exactly as the contract intends
36
+ * ("rows[].subject is byte-identical to the CloudEvents subject").
37
+ */
38
+ interface Projector {
39
+ /** Upsert the full mirrored payload for this subject (data is never a diff). */
40
+ apply(subject: ParsedSubject, data: Record<string, unknown>, context: ProjectorContext): Promise<void>;
41
+ }
42
+ interface ProjectorContext {
43
+ organizationId: string;
44
+ /** The watermark this row is consistent up to. */
45
+ sequence: number;
46
+ }
47
+ /** The stream-leg seam (JetStream durable on a Node host). Not implemented here yet. */
48
+ interface Transport {
49
+ pull(handler: (envelope: IdentityEnvelope) => Promise<void>): Promise<void>;
50
+ }
51
+ /** Dedupe seam keyed on the CloudEvents id — the consumer's job, per contract §4. */
52
+ interface Inbox {
53
+ /** true when this id was already applied — skip it. */
54
+ seen(eventId: string): Promise<boolean>;
55
+ record(eventId: string, sequence: number): Promise<void>;
56
+ }
57
+ /**
58
+ * Which (source, organization) pairs this consumer accepts — php-sdk's
59
+ * TenantPolicy. Deny is a `terminated` outcome, never an exception: an event
60
+ * for a tenant that is not yours is noise, not an incident.
61
+ */
62
+ interface TenantPolicy {
63
+ allows(source: string, organizationId: string | null): boolean;
64
+ }
65
+ declare const acceptTenants: (source: string, organizationIds: readonly string[]) => TenantPolicy;
66
+ /**
67
+ * What happened to one delivery — mirrors php-sdk's Outcome and the contract's
68
+ * nak-vs-term rule. The HTTP mapping (see handleIngestRequest) is:
69
+ * applied/skipped_* → 2xx (ack) · retry → 5xx (nak, redeliver) ·
70
+ * terminated → 200 with the outcome named (term: redelivery cannot help).
71
+ */
72
+ type Outcome = "applied" | "skipped_duplicate" | "skipped_stale" | "terminated_unknown_type" | "terminated_tenant_refused" | "retry";
73
+ interface Delivery {
74
+ outcome: Outcome;
75
+ eventId: string;
76
+ /** Present on terminated/retry — the human-readable why. */
77
+ reason?: string;
78
+ }
79
+ /**
80
+ * The one pipeline both legs feed: validate → dedupe → tenant gate → project
81
+ * → record. A snapshot pass uses the Projector directly; every EVENT goes
82
+ * through here.
83
+ */
84
+ declare function createProcessor(deps: {
85
+ projector: Projector;
86
+ inbox: Inbox;
87
+ tenantPolicy?: TenantPolicy;
88
+ /** Families this consumer mirrors; events for others are terminated (contract §3 nak-vs-term). */
89
+ families?: readonly IdentityFamily[];
90
+ }): (envelope: IdentityEnvelope) => Promise<Delivery>;
91
+ /** Structural subset of D1Database, so this module has no CF type dependency. */
92
+ interface D1Like {
93
+ prepare(sql: string): {
94
+ bind(...values: unknown[]): {
95
+ first<T = unknown>(): Promise<T | null>;
96
+ run(): Promise<unknown>;
97
+ };
98
+ };
99
+ }
100
+ /** DDL for the inbox table — copy into a migration verbatim. */
101
+ declare const D1_INBOX_DDL = "CREATE TABLE IF NOT EXISTS godx_sync_inbox (\n event_id TEXT PRIMARY KEY,\n sequence INTEGER NOT NULL,\n applied_at INTEGER NOT NULL\n);";
102
+ declare function createD1Inbox(db: D1Like, table?: string): Inbox;
103
+ /** Produce Standard Webhooks headers for a payload — the bridge's half. */
104
+ declare function signWebhook(secret: string, body: string, opts?: {
105
+ id?: string;
106
+ timestamp?: number;
107
+ }): Promise<{
108
+ "webhook-id": string;
109
+ "webhook-timestamp": string;
110
+ "webhook-signature": string;
111
+ }>;
112
+ /**
113
+ * Verify Standard Webhooks headers. Returns null when valid, otherwise the
114
+ * human-readable refusal (never throws on bad input).
115
+ */
116
+ declare function verifyWebhook(secret: string, body: string, headers: {
117
+ get(name: string): string | null;
118
+ }): Promise<string | null>;
119
+ interface IngestResult {
120
+ deliveries: Delivery[];
121
+ response: Response;
122
+ }
123
+ /**
124
+ * Handle one CloudEvents-over-HTTP delivery. Pure fetch API — mount it from
125
+ * any framework in one line. HTTP mapping of outcomes:
126
+ * every outcome except retry → 200 (ack; body lists per-event outcomes)
127
+ * any retry → 500 (nak: the sender must redeliver)
128
+ * bad signature → 401 · wrong content type → 415 · bad JSON/envelope → 400 (term)
129
+ */
130
+ declare function handleIngestRequest(request: Request, deps: {
131
+ secret: string;
132
+ process: (envelope: IdentityEnvelope) => Promise<Delivery>;
133
+ expectedSource?: string;
134
+ }): Promise<Response>;
135
+ interface SnapshotRow {
136
+ subject: string;
137
+ data: Record<string, unknown>;
138
+ }
139
+ interface SnapshotPage {
140
+ contractVersion: string;
141
+ family: string;
142
+ organizationId: string;
143
+ /** Watermark: resume the stream from here AFTER the pass completes. */
144
+ sequence: number;
145
+ rows: SnapshotRow[];
146
+ nextCursor: string | null;
147
+ }
148
+ interface ReconcileResult {
149
+ organizationId: string;
150
+ /** The highest watermark of the pass — hand this to the stream leg. */
151
+ sequence: number;
152
+ pages: number;
153
+ rows: number;
154
+ }
155
+ /**
156
+ * GET /api/sso/service/snapshot/{family} — cursor-paged keyset read of current
157
+ * state (NOT a replay of history). Auth is the service credential; tenancy is
158
+ * the credential's, never a parameter.
159
+ */
160
+ declare function createSnapshotClient(config: {
161
+ issuer: string;
162
+ serviceSlug: string;
163
+ serviceSecret: string;
164
+ }): {
165
+ fetchPage: (family: IdentityFamily, opts?: {
166
+ cursor?: string;
167
+ limit?: number;
168
+ }) => Promise<SnapshotPage>;
169
+ reconcile: (family: IdentityFamily, projector: Projector, opts?: {
170
+ limit?: number;
171
+ }) => Promise<ReconcileResult>;
172
+ };
173
+
174
+ export { D1_INBOX_DDL, type Delivery, type IdentityEnvelope, type IdentityFamily, type Inbox, type IngestResult, KNOWN_CONTRACT_VERSIONS, type Outcome, type ParsedSubject, type Projector, type ProjectorContext, type ReconcileResult, type SnapshotPage, type SnapshotRow, SyncContractError, type TenantPolicy, type Transport, acceptTenants, createD1Inbox, createProcessor, createSnapshotClient, handleIngestRequest, parseEnvelope, parseSubject, signWebhook, verifyWebhook };
package/dist/sync.js ADDED
@@ -0,0 +1,241 @@
1
+ import {
2
+ GodxHttpError
3
+ } from "./chunk-DMXYEWYY.js";
4
+
5
+ // src/sync.ts
6
+ var KNOWN_CONTRACT_VERSIONS = ["v1", "v2"];
7
+ var SyncContractError = class extends Error {
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = "SyncContractError";
11
+ }
12
+ };
13
+ function parseSubject(subject) {
14
+ const parts = subject.split("/");
15
+ if (parts.length !== 2 || parts[0] === "" || parts[1] === "") {
16
+ throw new SyncContractError(
17
+ `identity feed subject "${subject}" is not "{family}/{id}" \u2014 exactly one "/" with both halves filled.`
18
+ );
19
+ }
20
+ return { family: parts[0], id: parts[1] };
21
+ }
22
+ function parseEnvelope(raw, opts) {
23
+ const e = raw;
24
+ if (!e || typeof e !== "object") throw new SyncContractError("envelope is not an object");
25
+ if (e.specversion !== "1.0") {
26
+ throw new SyncContractError(`envelope specversion is ${JSON.stringify(e.specversion)}, expected "1.0" \u2014 rejected outright, as the php-sdk does.`);
27
+ }
28
+ if (typeof e.id !== "string" || e.id === "") throw new SyncContractError("envelope has no CloudEvents id (the idempotency key)");
29
+ if (typeof e.type !== "string" || !e.type.startsWith("jp.godx.identity.")) {
30
+ throw new SyncContractError(`envelope type ${JSON.stringify(e.type)} does not start with "jp.godx.identity."`);
31
+ }
32
+ if (typeof e.sequence !== "number") throw new SyncContractError("envelope has no numeric sequence (the ordering fence)");
33
+ if (typeof e.subject !== "string") throw new SyncContractError("envelope has no subject");
34
+ parseSubject(e.subject);
35
+ if (opts?.expectedSource !== void 0 && e.source !== opts.expectedSource) {
36
+ throw new SyncContractError(
37
+ `envelope source is ${JSON.stringify(e.source)}, expected ${JSON.stringify(opts.expectedSource)} \u2014 pin the platform origin per environment.`
38
+ );
39
+ }
40
+ return e;
41
+ }
42
+ var acceptTenants = (source, organizationIds) => ({
43
+ allows: (s, org) => s === source && (org === null || organizationIds.includes(org))
44
+ });
45
+ function createProcessor(deps) {
46
+ return async (envelope) => {
47
+ const { id } = envelope;
48
+ let subject;
49
+ try {
50
+ subject = parseSubject(envelope.subject);
51
+ } catch (e) {
52
+ return { outcome: "terminated_unknown_type", eventId: id, reason: e.message };
53
+ }
54
+ if (deps.families && !deps.families.includes(subject.family)) {
55
+ return {
56
+ outcome: "terminated_unknown_type",
57
+ eventId: id,
58
+ reason: `family "${subject.family}" is not mirrored here (mirrored: ${deps.families.join(", ")})`
59
+ };
60
+ }
61
+ const organizationId = typeof envelope.data["console_organization_id"] === "string" ? envelope.data["console_organization_id"] : null;
62
+ if (deps.tenantPolicy && !deps.tenantPolicy.allows(envelope.source, organizationId)) {
63
+ return {
64
+ outcome: "terminated_tenant_refused",
65
+ eventId: id,
66
+ reason: `source=${envelope.source} organization=${organizationId ?? "(none)"} is not accepted by this consumer's tenant policy`
67
+ };
68
+ }
69
+ if (await deps.inbox.seen(id)) {
70
+ return { outcome: "skipped_duplicate", eventId: id };
71
+ }
72
+ try {
73
+ await deps.projector.apply(subject, envelope.data, {
74
+ organizationId: organizationId ?? "",
75
+ sequence: envelope.sequence
76
+ });
77
+ await deps.inbox.record(id, envelope.sequence);
78
+ return { outcome: "applied", eventId: id };
79
+ } catch (e) {
80
+ return { outcome: "retry", eventId: id, reason: e instanceof Error ? e.message : String(e) };
81
+ }
82
+ };
83
+ }
84
+ var D1_INBOX_DDL = `CREATE TABLE IF NOT EXISTS godx_sync_inbox (
85
+ event_id TEXT PRIMARY KEY,
86
+ sequence INTEGER NOT NULL,
87
+ applied_at INTEGER NOT NULL
88
+ );`;
89
+ function createD1Inbox(db, table = "godx_sync_inbox") {
90
+ return {
91
+ async seen(eventId) {
92
+ const row = await db.prepare(`SELECT 1 AS x FROM ${table} WHERE event_id = ?`).bind(eventId).first();
93
+ return row !== null;
94
+ },
95
+ async record(eventId, sequence) {
96
+ await db.prepare(`INSERT OR IGNORE INTO ${table} (event_id, sequence, applied_at) VALUES (?, ?, ?)`).bind(eventId, sequence, Date.now()).run();
97
+ }
98
+ };
99
+ }
100
+ var WEBHOOK_TOLERANCE_SECONDS = 5 * 60;
101
+ async function hmacKey(secret) {
102
+ const raw = secret.startsWith("whsec_") ? Uint8Array.from(atob(secret.slice(6)), (ch) => ch.charCodeAt(0)) : new TextEncoder().encode(secret);
103
+ return crypto.subtle.importKey("raw", raw, { name: "HMAC", hash: "SHA-256" }, false, [
104
+ "sign",
105
+ "verify"
106
+ ]);
107
+ }
108
+ async function signWebhook(secret, body, opts) {
109
+ const id = opts?.id ?? crypto.randomUUID();
110
+ const timestamp = String(opts?.timestamp ?? Math.floor(Date.now() / 1e3));
111
+ const key = await hmacKey(secret);
112
+ const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${id}.${timestamp}.${body}`));
113
+ const signature = `v1,${btoa(String.fromCharCode(...new Uint8Array(mac)))}`;
114
+ return { "webhook-id": id, "webhook-timestamp": timestamp, "webhook-signature": signature };
115
+ }
116
+ async function verifyWebhook(secret, body, headers) {
117
+ const id = headers.get("webhook-id");
118
+ const timestamp = headers.get("webhook-timestamp");
119
+ const signatureHeader = headers.get("webhook-signature");
120
+ if (!id || !timestamp || !signatureHeader) {
121
+ return "missing webhook-id / webhook-timestamp / webhook-signature header (Standard Webhooks)";
122
+ }
123
+ const age = Math.abs(Math.floor(Date.now() / 1e3) - Number(timestamp));
124
+ if (!Number.isFinite(age) || age > WEBHOOK_TOLERANCE_SECONDS) {
125
+ return `webhook-timestamp is ${timestamp} \u2014 outside the \xB1${WEBHOOK_TOLERANCE_SECONDS}s tolerance (replay guard)`;
126
+ }
127
+ const key = await hmacKey(secret);
128
+ const expected = new TextEncoder().encode(`${id}.${timestamp}.${body}`);
129
+ for (const part of signatureHeader.split(" ")) {
130
+ const [version, value] = part.split(",", 2);
131
+ if (version !== "v1" || !value) continue;
132
+ let mac;
133
+ try {
134
+ mac = Uint8Array.from(atob(value), (ch) => ch.charCodeAt(0));
135
+ } catch {
136
+ continue;
137
+ }
138
+ if (await crypto.subtle.verify("HMAC", key, mac, expected)) return null;
139
+ }
140
+ return "no webhook-signature entry matched (wrong secret, or the body was altered in transit)";
141
+ }
142
+ async function handleIngestRequest(request, deps) {
143
+ const json = (status, body2) => new Response(JSON.stringify(body2), { status, headers: { "Content-Type": "application/json" } });
144
+ if (request.method !== "POST") return json(405, { error: "POST only" });
145
+ const contentType = (request.headers.get("Content-Type") ?? "").split(";")[0].trim();
146
+ const batch = contentType === "application/cloudevents-batch+json";
147
+ if (!batch && contentType !== "application/cloudevents+json") {
148
+ return json(415, {
149
+ error: `Content-Type "${contentType}" is not a CloudEvents structured mode \u2014 send application/cloudevents+json (one event) or application/cloudevents-batch+json (array).`
150
+ });
151
+ }
152
+ const body = await request.text();
153
+ const refusal = await verifyWebhook(deps.secret, body, request.headers);
154
+ if (refusal !== null) return json(401, { error: refusal });
155
+ let envelopes;
156
+ try {
157
+ const parsed = JSON.parse(body);
158
+ const raw = batch ? parsed : [parsed];
159
+ if (!Array.isArray(raw)) throw new SyncContractError("batch content-type but the body is not an array");
160
+ envelopes = raw.map((e) => parseEnvelope(e, { expectedSource: deps.expectedSource }));
161
+ } catch (e) {
162
+ return json(400, { error: e instanceof Error ? e.message : String(e) });
163
+ }
164
+ const deliveries = [];
165
+ for (const envelope of envelopes) {
166
+ deliveries.push(await deps.process(envelope));
167
+ }
168
+ const mustRetry = deliveries.some((d) => d.outcome === "retry");
169
+ return json(mustRetry ? 500 : 200, { deliveries });
170
+ }
171
+ function createSnapshotClient(config) {
172
+ const fetchPage = async (family, opts) => {
173
+ const url = new URL(`/api/sso/service/snapshot/${family}`, config.issuer);
174
+ if (opts?.cursor) url.searchParams.set("cursor", opts.cursor);
175
+ if (opts?.limit) url.searchParams.set("limit", String(opts.limit));
176
+ const res = await fetch(url, {
177
+ headers: {
178
+ "X-Service-Slug": config.serviceSlug,
179
+ "X-Service-Secret": config.serviceSecret,
180
+ Accept: "application/json"
181
+ }
182
+ });
183
+ if (!res.ok) {
184
+ const body = await res.text();
185
+ const hint = res.status === 410 ? "the cursor's watermark aged out of the stream \u2014 restart the pass from the beginning (no cursor)." : res.status === 400 ? "the cursor is malformed or belongs to another family \u2014 cursors are opaque, echo next_cursor verbatim." : res.status === 403 ? "the credential lacks service_api_enabled, or the family is not snapshot-able for it." : "check X-Service-Slug/X-Service-Secret and the platform logs (X-Request-Id).";
186
+ throw new GodxHttpError(res.status, body, `identity snapshot ${family} answered ${res.status} \u2014 ${hint}`);
187
+ }
188
+ const b = await res.json();
189
+ if (!KNOWN_CONTRACT_VERSIONS.includes(b.contract_version)) {
190
+ throw new SyncContractError(
191
+ `snapshot answered contract_version ${JSON.stringify(b.contract_version)}; this SDK knows ${KNOWN_CONTRACT_VERSIONS.join(", ")} \u2014 a consumer that does not recognise it must stop, not guess.`
192
+ );
193
+ }
194
+ return {
195
+ contractVersion: b.contract_version,
196
+ family: b.family,
197
+ organizationId: b.organization_id,
198
+ sequence: b.sequence,
199
+ rows: b.rows ?? [],
200
+ nextCursor: b.next_cursor ?? null
201
+ };
202
+ };
203
+ const reconcile = async (family, projector, opts) => {
204
+ let cursor;
205
+ let pages = 0;
206
+ let rows = 0;
207
+ let sequence = 0;
208
+ let organizationId = "";
209
+ do {
210
+ const page = await fetchPage(family, { cursor, limit: opts?.limit });
211
+ pages++;
212
+ sequence = Math.max(sequence, page.sequence);
213
+ organizationId = page.organizationId;
214
+ for (const row of page.rows) {
215
+ await projector.apply(parseSubject(row.subject), row.data, {
216
+ organizationId: page.organizationId,
217
+ sequence: page.sequence
218
+ });
219
+ rows++;
220
+ }
221
+ cursor = page.nextCursor ?? void 0;
222
+ } while (cursor !== void 0);
223
+ return { organizationId, sequence, pages, rows };
224
+ };
225
+ return { fetchPage, reconcile };
226
+ }
227
+ export {
228
+ D1_INBOX_DDL,
229
+ KNOWN_CONTRACT_VERSIONS,
230
+ SyncContractError,
231
+ acceptTenants,
232
+ createD1Inbox,
233
+ createProcessor,
234
+ createSnapshotClient,
235
+ handleIngestRequest,
236
+ parseEnvelope,
237
+ parseSubject,
238
+ signWebhook,
239
+ verifyWebhook
240
+ };
241
+ //# sourceMappingURL=sync.js.map