@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/README.md ADDED
@@ -0,0 +1,486 @@
1
+ # @godxjp/server-sdk
2
+
3
+ GoDX **server-side** SDK for JS runtimes — Cloudflare Workers first, Node-compatible. It holds
4
+ service credentials (client secrets, session keys), so it must never be bundled for the browser:
5
+ every subpath resolves a throwing guard under the `browser` export condition, and `workerd` /
6
+ `worker` are listed before it because Cloudflare Workers also match `browser`. For frontend code
7
+ use `@godxjp/web-sdk`. Counterpart of [`godx-jp/php-sdk`](https://github.com/godx-jp/php-sdk) —
8
+ the two SDKs are kept answering the same way, with mirrored test suites as the guard.
9
+
10
+ Naming is by **side**, not by language (`web-sdk` / `server-sdk` / `php-sdk`) — see
11
+ `docs/explanation/naming-conventions.md` in `godx-jp/id`.
12
+
13
+ ## Modules
14
+
15
+ | Subpath | What it owns |
16
+ |---|---|
17
+ | `./auth` | The verified, immutable `Principal` (one parse gate: `verifyAccessToken`), PKCE, sealed session values (JWE). Verification failures say **expected vs actual** (`iss`/`aud`), because a bare "unexpected claim" costs whoever reads it a debugging session. The token audience is the **ServiceInstance slug**, not the service slug. |
18
+ | `./authorization` | `fetchEffectivePermissions` — the effective-access read model (`/api/sso/me/permissions`), `authoritative` flag passed through, never cached as a positive grant. |
19
+ | `./http` | OIDC discovery (cached per isolate; endpoints are read, never hardcoded), `GodxSsoClient` (authorize URL, code exchange, refresh, revoke, userinfo), the server-to-server service API (`X-Service-Slug`/`X-Service-Secret`). |
20
+ | `./hono` | Thin adapter: `godxSso()` mounts `/auth/{login,callback,logout}`, `requireAuth`, `requireWritable` (read-only organizations may not write), `dockProxy` (the GoDX Dock's `apiBase` lane — no CORS, no token in the page). Every SSO failure lands back on the app as `/?sso_error=…&sso_error_description=…` with a human-readable description. |
21
+ | `./sync` | The identity feed, both consumer legs — see below. |
22
+ | `./org` | Typed client for the **write-back** surface (`/api/org/v1/*`): me, employees, departments, branches, teams (list / paginate-all / create). `Idempotency-Key` on every write (IETF draft), RFC 6750 `WWW-Authenticate` surfaced on 401/403, 422 field errors and 429 `Retry-After` exposed on `OrgApiError`. Tenancy is the token's — the client never sends an organization id. Since godx-jp/id#1960, a downstream **service-launch token carries the `organization-api` audience whenever org.\* scopes are granted** (i.e. survive RBAC narrowing) — see [`./org`](#org--the-write-back-client) below. |
23
+
24
+ Core modules depend on `fetch` + WebCrypto only; `hono` is the only framework-shaped subpath.
25
+
26
+ ### Package exports — condition order is load-bearing
27
+
28
+ `package.json` maps every subpath as:
29
+
30
+ ```json
31
+ { "workerd": "./src/x.ts", "worker": "./src/x.ts", "browser": "./src/browser-guard.ts", "default": "./src/x.ts" }
32
+ ```
33
+
34
+ `workerd` and `worker` **must stay listed before `browser`**: Cloudflare's bundler matches the
35
+ `browser` condition too, and with the order flipped a Worker build resolves the throwing guard
36
+ instead of the real module. The guard (`src/browser-guard.ts`) throws at import time with a
37
+ message pointing at `@godxjp/web-sdk`.
38
+
39
+ ## Quickstart (Hono on Workers)
40
+
41
+ ```ts
42
+ import { godxSso, type GodxVariables } from "@godxjp/server-sdk/hono";
43
+
44
+ type AppEnv = { Bindings: Env; Variables: GodxVariables };
45
+
46
+ const sso = godxSso<AppEnv>((env) => ({
47
+ issuer: env.GODX_ISSUER, // e.g. https://platform.test
48
+ serviceSlug: env.GODX_SERVICE_SLUG, // catalog slug: "boilerplate"
49
+ audience: env.GODX_AUDIENCE, // ServiceInstance slug: "boilerplate-local"
50
+ clientId: env.GODX_CLIENT_ID,
51
+ clientSecret: env.GODX_CLIENT_SECRET,
52
+ redirectUri: `${env.APP_URL}/auth/callback`,
53
+ sessionSecret: env.SESSION_SECRET,
54
+ defaultOrganizationContextId: env.GODX_DEFAULT_ORG_CONTEXT || undefined,
55
+ }));
56
+
57
+ app.route("/auth", sso.routes);
58
+ app.all("/api/dock/v1/*", sso.requireAuth, sso.dockProxy);
59
+ app.use("/api/*", sso.requireAuth, sso.requireWritable);
60
+ // handlers read c.get("principal").organizationId and scope every query by it
61
+ ```
62
+
63
+ The platform's launch URL carries `organization_context_id`; `/auth/login` forwards it (or falls
64
+ back to `defaultOrganizationContextId` for local dev). A session is bound to the token's
65
+ `organization_id` — an org switch is a new SSO round, never a reused session.
66
+
67
+ ### `GodxSsoConfig` — every field
68
+
69
+ | Field | Required | Default | Notes |
70
+ |---|---|---|---|
71
+ | `issuer` | yes | — | Platform origin; OIDC discovery is read from `{issuer}/.well-known/openid-configuration`, endpoints are never hardcoded. |
72
+ | `serviceSlug` | yes | — | Catalog **service** slug; sent as `service_slug` on authorize and token requests. |
73
+ | `clientId` | yes | — | ServiceInstance OAuth client id (`si_…`). |
74
+ | `clientSecret` | no | `undefined` | Omit for a public (secretless) instance — PKCE alone then. |
75
+ | `redirectUri` | yes | — | Must match the instance's `allowed_redirect_uris` exactly (scheme, host, port, path). |
76
+ | `sessionSecret` | yes | — | Hashed (SHA-256) into the JWE key that seals the `gx_txn` and `gx_sess` cookies (dir/A256GCM). |
77
+ | `scope` | no | `"openid profile email"` | Space-separated. Add `org.*` words to reach `/api/org/v1` — they are granted only as far as the signing-in user's RBAC reaches. |
78
+ | `audience` | no | `serviceSlug` | Expected token `aud` = your **ServiceInstance slug** (`boilerplate-local`), *not* the service slug. Leaving it unset only works when the two happen to be equal — they usually are not. |
79
+ | `defaultOrganizationContextId` | no | `undefined` | `console_organization_id` fallback for logins **not** started from a platform launch URL. Dev convenience only; leave unset in production multi-org setups. |
80
+
81
+ `godxSso(getConfig)` returns `{ routes, requireAuth, requireWritable, dockProxy }`:
82
+
83
+ | Member | Type | Behaviour |
84
+ |---|---|---|
85
+ | `routes` | `Hono` | `GET /login` (PKCE + state in a sealed 5-min `gx_txn` cookie, forwards `organization_context_id`), `GET /callback` (code exchange → `verifyAccessToken` → one `userinfo` call for display name → seals `{principal, accessToken}` into `gx_sess` with TTL = token exp), `GET /logout` (best-effort revoke, then redirect to `end_session_endpoint`). Every failure redirects to `/?sso_error=…&sso_error_description=…`. |
86
+ | `requireAuth` | middleware | Opens `gx_sess`; 401 `{"error":"unauthenticated"}` when missing/expired. Sets `c.set("principal", …)` and `c.set("accessToken", …)` (the `GodxVariables` pair). |
87
+ | `requireWritable` | middleware | Enforces `organization_access_mode`: non-GET/HEAD/OPTIONS from a `read_only` org answers 403. |
88
+ | `dockProxy` | middleware | Forwards `/api/dock/v1/*` to the platform with the session's Bearer token — the GoDX Dock `apiBase` lane: no CORS, no token in the page. Mount it behind `requireAuth` and **before** any `requireWritable` (Dock actions are the platform's to authorize). Passes `X-Request-Id` back through. |
89
+
90
+ ## API reference
91
+
92
+ Every export per subpath, checked against `src/*.ts`.
93
+
94
+ ### `./auth`
95
+
96
+ | Export | Signature (condensed) | One line |
97
+ |---|---|---|
98
+ | `Principal` | `interface { sub, organizationId, accessMode, scopes, serviceInstanceId?, clientId?, expiresAt, name?, email? }` | The verified, frozen identity; `accessMode` defaults to `read_only` unless the claim says `full` (fail closed). |
99
+ | `verifyAccessToken` | `(token, {issuer, serviceSlug, audience?}) => Promise<Principal>` | The one parse gate: JWKS signature, exact `iss`, `aud` (= instance slug, defaults to `serviceSlug`), `exp`, then requires `organization_id`. Failures quote expected vs actual `iss`/`aud`. |
100
+ | `Pkce` | `interface { verifier, challenge, method: "S256" }` | A PKCE pair. |
101
+ | `createPkce` | `() => Promise<Pkce>` | S256 challenge via WebCrypto. |
102
+ | `randomToken` | `(bytes = 16) => string` | base64url random token (state, nonce). |
103
+ | `sealData` | `(payload, secret, ttlSeconds) => Promise<string>` | Encrypt+sign into a compact cookie value (JWE dir/A256GCM; key = SHA-256 of secret). |
104
+ | `openData` | `<T>(token, secret) => Promise<T \| null>` | Inverse of `sealData`; returns `null` for anything invalid or expired — never throws on bad input. |
105
+
106
+ ### `./authorization`
107
+
108
+ | Export | Signature | One line |
109
+ |---|---|---|
110
+ | `EffectiveAccess` | `interface { permissions, roles, serviceAccess, contractVersion?, evaluatedAt?, authoritative }` | The effective-access read model. |
111
+ | `fetchEffectivePermissions` | `({issuer, accessToken, organizationId, branchId?}) => Promise<EffectiveAccess>` | `GET /api/sso/me/permissions`; `authoritative` passed through as-is — never cache a non-authoritative answer as a positive grant. |
112
+
113
+ ### `./http`
114
+
115
+ | Export | Signature | One line |
116
+ |---|---|---|
117
+ | `IssuerMetadata` / `Issuer` | interfaces | OIDC discovery document / metadata + remote JWKS pair. |
118
+ | `GodxHttpError` | `class extends Error { status, body }` | Every non-OK platform answer; base class of `OrgApiError`. |
119
+ | `loadIssuer` | `(issuerUrl) => Promise<Issuer>` | Discovery + JWKS, cached per isolate; a failed load evicts itself from the cache. |
120
+ | `SsoClientConfig` / `TokenResponse` / `UserinfoResponse` | interfaces | Client config and wire shapes. |
121
+ | `GodxSsoClient` | `class (config)` — `authorizeUrl(params)`, `exchangeCode({code, codeVerifier})`, `refresh(refreshToken)`, `revoke(token)`, `userinfo(accessToken)` | The OAuth/OIDC client. `authorizeUrl` sends `service_slug`, PKCE S256 and optional `organization_context_id`; `revoke` is a no-op when discovery lists no revocation endpoint. |
122
+ | `createServiceApi` | `({issuer, serviceSlug, serviceSecret}) => { users(q), branches(q), brands(q) }` | Server-to-server reads with `X-Service-Slug`/`X-Service-Secret`. Rate-limited by the platform (429) — cache and back off; no auto-retry here. |
123
+
124
+ ### `./hono`
125
+
126
+ | Export | Signature | One line |
127
+ |---|---|---|
128
+ | `GodxSsoConfig` | interface | See the field table above. |
129
+ | `GodxVariables` | `{ principal: Principal; accessToken: string }` | What `requireAuth` puts on the Hono context. |
130
+ | `godxSso` | `<E>(getConfig: (env) => GodxSsoConfig) => { routes, requireAuth, requireWritable, dockProxy }` | The adapter factory; see the member table above. |
131
+
132
+ ### `./sync`
133
+
134
+ | Export | Signature | One line |
135
+ |---|---|---|
136
+ | `IdentityFamily` | union type | `organization \| brand \| branch \| user \| employee \| service_user_access \| org_service_role \| role_assignment`. |
137
+ | `KNOWN_CONTRACT_VERSIONS` | `["v1", "v2"]` | An unknown `contract_version` **stops** the consumer, never guesses. |
138
+ | `IdentityEnvelope` | interface | CloudEvents 1.0 envelope as the relay publishes it (`id` = idempotency key, `sequence` = ordering fence, `subject` = `{family}/{id}`). |
139
+ | `ParsedSubject` | `{ family, id }` | Split subject. |
140
+ | `SyncContractError` | `class extends Error` | Every contract violation. |
141
+ | `parseSubject` | `(subject) => ParsedSubject` | Rejects anything not exactly `{family}/{id}`. |
142
+ | `parseEnvelope` | `(raw, {expectedSource?}?) => IdentityEnvelope` | Validates specversion/id/type/sequence/subject, optionally pins `source`; expected-vs-actual error messages. |
143
+ | `Projector` | `interface { apply(subject, data, context) }` | One code path for snapshot rows AND stream events; `data` is a full mirrored payload, never a diff. |
144
+ | `ProjectorContext` | `{ organizationId, sequence }` | The watermark the row is consistent up to. |
145
+ | `Transport` | `interface { pull(handler) }` | The stream-leg seam (JetStream durable on a Node host). **Not implemented here yet.** |
146
+ | `Inbox` | `interface { seen(eventId), record(eventId, sequence) }` | Dedupe seam keyed on the CloudEvents `id`. |
147
+ | `TenantPolicy` / `acceptTenants` | `{ allows(source, orgId) }` / `(source, orgIds) => TenantPolicy` | Which (source, organization) pairs this consumer accepts; a refusal is a `terminated` outcome, never an exception. |
148
+ | `Outcome` / `Delivery` | union / `{ outcome, eventId, reason? }` | What happened to one delivery — full table below. |
149
+ | `createProcessor` | `({projector, inbox, tenantPolicy?, families?}) => (envelope) => Promise<Delivery>` | The one pipeline: validate → dedupe → tenant gate → project → record. |
150
+ | `D1_INBOX_DDL` | `string` | DDL for the inbox table — copy into a migration verbatim (below). |
151
+ | `createD1Inbox` | `(db, table = "godx_sync_inbox") => Inbox` | Inbox over anything with D1's `prepare/bind` surface. |
152
+ | `signWebhook` | `(secret, body, {id?, timestamp?}?) => Promise<headers>` | Standard Webhooks signing — the bridge's half; also handy in tests. |
153
+ | `verifyWebhook` | `(secret, body, headers) => Promise<string \| null>` | `null` when valid, else a human-readable refusal; constant-time compare, ±5 min replay tolerance, multiple space-separated signatures (key rotation), `whsec_` or raw secrets. |
154
+ | `IngestResult` | `{ deliveries, response }` | Exported pair type for callers that want both halves. |
155
+ | `handleIngestRequest` | `(request: Request, {secret, process, expectedSource?}) => Promise<Response>` | The whole HTTP ingest endpoint — pure fetch API, one line to mount from any framework. |
156
+ | `SnapshotRow` / `SnapshotPage` / `ReconcileResult` | interfaces | Snapshot wire shapes and the pass summary (`sequence` watermark, pages, rows). |
157
+ | `createSnapshotClient` | `({issuer, serviceSlug, serviceSecret}) => { fetchPage(family, {cursor?, limit?}), reconcile(family, projector, {limit?}) }` | Keyset-paged `GET /api/sso/service/snapshot/{family}` to completion; per-status hints on failure (410 → restart the pass, 403 → `service_api_enabled`, …). |
158
+
159
+ ### `./org`
160
+
161
+ See [the dedicated section](#org--the-write-back-client) for the client surface, error handling
162
+ and the scope→permission mapping.
163
+
164
+ | Export | Signature | One line |
165
+ |---|---|---|
166
+ | `OrgApiConfig` | `{ issuer, accessToken }` | Bearer token whose `aud` includes `{issuer}/api/org/v1`. |
167
+ | `PageMeta` / `Page<T>` | interfaces | Laravel-style page envelope (`data` + `meta`). |
168
+ | `OrgEmployee` / `OrgDepartment` / `OrgBranch` / `OrgTeam` / `OrgMe` | interfaces | Row shapes (open-ended: `[key: string]: unknown`). |
169
+ | `OrgApiError` | `class extends GodxHttpError { wwwAuthenticate, retryAfterSeconds, validationErrors }` | Every non-OK org/v1 answer, with the standard headers/bodies surfaced as fields. |
170
+ | `createOrgApi` | `(config) => { me, employees, departments, branches, teams }` | The typed client; method table below. |
171
+
172
+ ## `./sync` — the identity feed
173
+
174
+ The platform publishes identity/directory changes (organizations, branches, brands, users,
175
+ employees, access grants) as CloudEvents on NATS JetStream — contract:
176
+ `docs/reference/identity-feed-protocol.md` in `godx-jp/id` (v2). **The feed is one-way.** A
177
+ service that wants to change platform data writes back through the platform's own APIs
178
+ (`/api/console/*`, `/api/org/v1/*`), never by publishing to the bus; the change then comes back
179
+ around the loop to every consumer, this one included.
180
+
181
+ The loop closes through `./org`: **read** via the feed's projections, **write** via
182
+ `/api/org/v1/*` — a service's write comes back around the feed to every consumer, itself
183
+ included (its own projector then answers `applied` with the platform-assigned sequence, which is
184
+ also how a service confirms its write propagated).
185
+
186
+ A Worker cannot hold a JetStream durable (no long-lived process), so this module implements the
187
+ two legs a Worker *can* run:
188
+
189
+ ```
190
+ ┌─────────── platform (godx-jp/id) ───────────┐
191
+ writes via API → │ MySQL → identity_outbox → IdentityOutboxRelay│
192
+ └──────┬───────────────────────────┬───────────┘
193
+ NATS JetStream WebhookTransport (PR #1956)
194
+ (hosts with a durable) CloudEvents + Standard Webhooks
195
+ │ │
196
+ ▼ ▼
197
+ php-sdk Receiver handleIngestRequest ← YOU ARE HERE (event leg)
198
+ createSnapshotClient ← and here (reconcile leg)
199
+ │
200
+ Projector → your D1 mirror
201
+ ```
202
+
203
+ ### Reconcile leg (pull, HTTP)
204
+
205
+ `createSnapshotClient(...).reconcile(family, projector)` pages
206
+ `GET /api/sso/service/snapshot/{family}` (keyset cursor) to completion and reports the
207
+ `sequence` watermark. It is a projection of **current state**, not a replay. Rules encoded:
208
+ unknown `contract_version` stops instead of guessing; `410 CURSOR_EXPIRED` means restart the
209
+ pass from the beginning; snapshot first, **then** resume events from the watermark — never the
210
+ reverse.
211
+
212
+ ### Event leg (push, HTTP)
213
+
214
+ A bridge — the platform's `WebhookTransport`, or any JetStream puller — POSTs each event here:
215
+
216
+ ```ts
217
+ app.post("/api/sync/ingest", (c) =>
218
+ handleIngestRequest(c.req.raw, {
219
+ secret: c.env.SYNC_WEBHOOK_SECRET, // whsec_...
220
+ expectedSource: c.env.GODX_ISSUER, // pin the issuer per environment
221
+ process: createProcessor({
222
+ projector: myProjector(drizzle(c.env.DB)),
223
+ inbox: createD1Inbox(c.env.DB), // table: D1_INBOX_DDL
224
+ families: ["organization"],
225
+ tenantPolicy: acceptTenants(c.env.GODX_ISSUER, [myOrgId]),
226
+ }),
227
+ }),
228
+ );
229
+ ```
230
+
231
+ Wire contract: **CloudEvents HTTP binding, structured mode** (`application/cloudevents+json`, or
232
+ `-batch+json` for an array) and **Standard Webhooks** signatures (`webhook-id` / `-timestamp` /
233
+ `-signature`, HMAC-SHA256 over `{id}.{timestamp}.{body}`, `whsec_` secrets, ±5 min replay
234
+ tolerance, constant-time verify, multiple space-separated signatures for key rotation).
235
+
236
+ ### Mounting ingest outside Hono
237
+
238
+ `handleIngestRequest` is `(Request) → Promise<Response>` — pure fetch API, no framework types.
239
+ A plain Workers module (or anything fetch-shaped: Bun, Deno, Node ≥18 with an adapter) mounts it
240
+ directly:
241
+
242
+ ```ts
243
+ export default {
244
+ async fetch(request: Request, env: Env): Promise<Response> {
245
+ const url = new URL(request.url);
246
+ if (url.pathname === "/api/sync/ingest") {
247
+ return handleIngestRequest(request, {
248
+ secret: env.SYNC_WEBHOOK_SECRET,
249
+ expectedSource: env.GODX_ISSUER,
250
+ process: createProcessor({ projector, inbox: createD1Inbox(env.DB), families: ["organization"] }),
251
+ });
252
+ }
253
+ return new Response("not found", { status: 404 });
254
+ },
255
+ };
256
+ ```
257
+
258
+ Keep it **outside** any session/auth middleware: the caller is a bridge, not a browser —
259
+ authentication is the HMAC signature, replay-guarded by the timestamp.
260
+
261
+ ### Inbox DDL (copy-paste)
262
+
263
+ `D1_INBOX_DDL` exports exactly this — paste it into a migration verbatim and keep any ORM-side
264
+ schema (e.g. Drizzle) in lockstep:
265
+
266
+ ```sql
267
+ CREATE TABLE IF NOT EXISTS godx_sync_inbox (
268
+ event_id TEXT PRIMARY KEY,
269
+ sequence INTEGER NOT NULL,
270
+ applied_at INTEGER NOT NULL
271
+ );
272
+ ```
273
+
274
+ ### Pattern: scheduled reconcile (Cron Trigger)
275
+
276
+ The boilerplate reconciles lazily from a request handler (stale > 5 min). A Worker that must not
277
+ pay that latency on a request can run the same pass from a Cron Trigger instead — guidance, not
278
+ code shipped in this repo:
279
+
280
+ ```jsonc
281
+ // wrangler.jsonc
282
+ "triggers": { "crons": ["*/15 * * * *"] }
283
+ ```
284
+
285
+ ```ts
286
+ export default {
287
+ fetch: app.fetch,
288
+ async scheduled(_event: ScheduledEvent, env: Env) {
289
+ const snapshots = createSnapshotClient({
290
+ issuer: env.GODX_ISSUER,
291
+ serviceSlug: env.GODX_SERVICE_SLUG,
292
+ serviceSecret: env.GODX_CLIENT_SECRET,
293
+ });
294
+ await snapshots.reconcile("organization", myProjector(drizzle(env.DB)));
295
+ },
296
+ };
297
+ ```
298
+
299
+ The projector's sequence fence makes the two safe to combine with the event leg in any order —
300
+ snapshot rows carry the page watermark and cannot overwrite newer event-applied state.
301
+
302
+ ### Outcome semantics — every value
303
+
304
+ | Outcome | Meaning | Recorded in inbox? | HTTP (per batch) |
305
+ |---|---|---|---|
306
+ | `applied` | The projector upserted the row; the event id is now in the inbox. | yes | 200 |
307
+ | `skipped_duplicate` | The CloudEvents `id` was already applied — dedupe hit. Also what copies 2..N of a fanned-out `user` event answer. | already was | 200 |
308
+ | `skipped_stale` | Reserved for a consumer that checks the fence before applying; the current processor lets the SQL fence absorb staleness instead. | — | 200 |
309
+ | `terminated_unknown_type` | Malformed subject, or a family this consumer does not mirror — redelivery cannot make us know it (term, not nak). | no | 200 |
310
+ | `terminated_tenant_refused` | The `TenantPolicy` refused (source, organization) — noise, not an incident. | no | 200 |
311
+ | `retry` | The projector threw — transient until proven otherwise; the sender must redeliver. **Not recorded**, so the redelivery reaches the projector again. | no | **500** for the whole batch |
312
+
313
+ ### Outcome → HTTP mapping (the cross-SDK contract)
314
+
315
+ | Outcome | HTTP | Sender's move |
316
+ |---|---|---|
317
+ | `applied`, `skipped_duplicate`, `terminated_*` | 200, outcome named per delivery | done — redelivery cannot change a durable answer |
318
+ | any `retry` in the batch | 500 | redeliver the **whole** batch; already-applied rows absorb as `skipped_duplicate` |
319
+ | bad signature | 401 | fix the secret |
320
+ | wrong content type | 415 | send a CloudEvents structured mode |
321
+ | malformed body/envelope | 400 | fix the bytes — redelivering them cannot help |
322
+ | non-POST method | 405 | POST only |
323
+
324
+ `php-sdk`'s `Sync\Http\WebhookIngest` answers identically (its outcome names differ —
325
+ `duplicate`, `rejected`, `failed` — the HTTP statuses do not).
326
+
327
+ ### Edge-case semantics, stated rather than implied
328
+
329
+ - **Idempotency** is the CloudEvents `id` (the inbox's key). The inbox records **after** a
330
+ successful projection, never before — a failure must reach the projector again on redelivery.
331
+ - **Ordering** is the `sequence` fence. A projector must refuse to overwrite newer state (SQL:
332
+ `... ON CONFLICT DO UPDATE ... WHERE stored.sequence <= incoming.sequence`) because
333
+ redeliveries and snapshot/stream overlap deliver twice **by design**.
334
+ - **`user` events fan out** — one copy per organization, same `id`, org carried as delivery
335
+ metadata (`X-Godx-Organization`), never in the envelope. A consumer serving **one** org (an
336
+ on-premise instance) needs nothing special. A consumer serving **several** orgs through one
337
+ endpoint must make its projector org-aware before trusting id-level dedupe, since copy 2..N of
338
+ a `user` event answer `skipped_duplicate`.
339
+ - **Known limitation — concurrent duplicate deliveries**: `createD1Inbox` does
340
+ seen → apply → record without a transaction, so two simultaneous deliveries of the same event
341
+ can both reach the projector. The sequence fence makes that harmless for last-write-wins
342
+ mirrors; a projector with side effects must be idempotent per event id. (php-sdk's SQL inbox
343
+ runs in a transaction and has a 4-process concurrency test; a D1 `batch()`-based inbox is the
344
+ fix here when it matters.)
345
+
346
+ ## `./org` — the write-back client
347
+
348
+ The Delegated Organization API (`/api/org/v1/*`) is the only sanctioned way a service changes
349
+ platform-owned directory data; the identity feed then carries the change back to every consumer.
350
+ Auth is a Bearer token minted for the **`organization-api` recipient resource** (`aud` contains
351
+ `{issuer}/api/org/v1`) by a `user`-lane principal; tenancy is the token's organization,
352
+ re-derived server-side per request — this client never sends an organization id.
353
+
354
+ ### Access model — pick the right principal (house rule)
355
+
356
+ | Principal | Authority | Use it for |
357
+ |---|---|---|
358
+ | **Mini app** (customer-created) | **Narrow by design**: consent tiers, `MiniAppScopeNarrower`, writes gated behind `write_scopes_enabled_at`. Do NOT design around getting a mini app more power | small customer-owned integrations acting for their signed-in users |
359
+ | **Service launch** (registered downstream service) | acts **as the signed-in human** — `org.*` words survive only where that person's RBAC holds the mapped permission, re-checked on every mint (godx-jp/id#1960) | this client, in request context: writes carry the human's authority and audit trail |
360
+ | **ServiceAccount** (machine identity) | `client_credentials`, per-client scope allowlist, `trust_tier` gates privilege (`internal_agent` + `elevated` is the only path to `admin:*`, super-admin-only) | broad or background access with no human in the loop. Note: `/api/org/v1` admits `prn=user` only — a machine token uses the surfaces its allowlist grants (directory/snapshot reads via the service credential, and whatever the platform grants that account), never this client with a borrowed user token |
361
+
362
+ If a task seems to need "a mini app with more rights", the answer is a ServiceAccount with the
363
+ right allowlist — never widening the mini app.
364
+
365
+ **Lane note (updated):** the caveat that only mini-app tokens carried this audience is
366
+ **resolved** — since godx-jp/id#1960 a downstream **service-launch token carries the audience
367
+ when org.\* scopes are granted**, i.e. when at least one delegated `org.*` scope was requested at
368
+ login *and* survived RBAC narrowing (#1958). A launch that asked for no org scope, or whose
369
+ user's RBAC stripped them all, mints exactly the token it minted before — and this surface then
370
+ answers 401.
371
+
372
+ ### Methods
373
+
374
+ Every write sends an `Idempotency-Key` (generated UUID when not supplied). `all()` returns an
375
+ async iterator that follows page-based pagination to the last page, so callers cannot forget
376
+ page 2.
377
+
378
+ | Method | HTTP call | Scope required |
379
+ |---|---|---|
380
+ | `me()` | `GET /me` | `org:profile` (membership alone suffices) |
381
+ | `employees.list(page?)` | `GET /employees` | `org.employees:read` |
382
+ | `employees.all()` | `GET /employees` (all pages, async iterator) | `org.employees:read` |
383
+ | `employees.create(payload, idempotencyKey?)` | `POST /employees` | `org.employees:write` |
384
+ | `employees.managerOf(consoleUserId)` | `GET /users/{id}/manager` | `org.employees:read` (deliberately not a scope of its own) |
385
+ | `departments.list(page?)` / `.all()` / `.create(...)` | `GET`/`POST /departments` | `org.departments:read` / `:write` |
386
+ | `branches.list(page?)` / `.all()` / `.create(...)` | `GET`/`POST /branches` | `org.branches:read` / `:write` |
387
+ | `teams.list(page?)` / `.all()` / `.create(...)` | `GET`/`POST /teams` | `org.teams:read` / `:write` |
388
+
389
+ ### Scope → permission mapping
390
+
391
+ A delegated scope survives RBAC narrowing only when the signing-in user holds **any one** of the
392
+ listed permissions (platform's `OrganizationScopeCatalog` — each entry mirrors the gate the
393
+ equivalent human surface already enforces, so a token can never be a quieter path to something
394
+ the Console refuses):
395
+
396
+ | Scope | Permission (any one of) |
397
+ |---|---|
398
+ | `org:profile` | — (membership alone) |
399
+ | `org.employees:read`, `org.departments:read`, `org.branches:read`, `org.teams:read` | `organization.view` |
400
+ | `org.employees:write` | `organization.manage`, `settings.manage` |
401
+ | `org.departments:write` | `organization.departments.manage`, `settings.manage` |
402
+ | `org.branches:write` | `organization.branches.manage`, `settings.manage` |
403
+ | `org.teams:write` | `organization.teams.manage` |
404
+
405
+ ### Handling `OrgApiError`
406
+
407
+ Every non-OK answer throws `OrgApiError` with the standard fields surfaced — switch on `status`
408
+ and read the field made for that status:
409
+
410
+ ```ts
411
+ import { createOrgApi, OrgApiError } from "@godxjp/server-sdk/org";
412
+
413
+ const orgApi = createOrgApi({ issuer, accessToken });
414
+ try {
415
+ await orgApi.employees.create({ employee_code: "E-42", full_name: "山田 太郎" });
416
+ } catch (e) {
417
+ if (!(e instanceof OrgApiError)) throw e;
418
+ switch (e.status) {
419
+ case 401: // token missing/expired, or minted without the organization-api audience
420
+ case 403: // token lacks the route's org.* scope
421
+ console.warn(e.message, e.wwwAuthenticate); // RFC 6750 challenge, e.g. scope="org.employees:write"
422
+ break;
423
+ case 422: // validation refused the payload
424
+ console.warn(e.validationErrors); // { employee_code: ["already taken"] }
425
+ break;
426
+ case 429: // rate limited
427
+ await scheduleRetryIn(e.retryAfterSeconds ?? 60);
428
+ break;
429
+ default:
430
+ throw e;
431
+ }
432
+ }
433
+ ```
434
+
435
+ The error `message` already names the likely cause per status ("missing/expired, or was not
436
+ minted for the organization-api audience", "lacks the route's org.* scope", …) so a bare log is
437
+ still actionable.
438
+
439
+ ## Tests
440
+
441
+ ```bash
442
+ npx vitest run # from packages/server-sdk
443
+ ```
444
+
445
+ 30 tests, doubling as executable behaviour docs:
446
+
447
+ - `sync.test.ts` (23): envelope validation (specversion/subject/source pinning), webhook
448
+ sign/verify (tamper, replay, rotation, raw-secret fallback), processor outcomes (duplicate,
449
+ unknown family, tenant refused, retry-not-recorded), ingest HTTP mapping (single, batch, mixed
450
+ outcomes, batch-retry, 401/415/400/405), snapshot pagination/contract-stop/410.
451
+ - `org.test.ts` (7): Bearer + Accept headers, Idempotency-Key (generated and caller-supplied),
452
+ pagination to the last page, 403 `WWW-Authenticate` surfaced, 401 audience hint, 422 per-field
453
+ validation, 429 `Retry-After`.
454
+
455
+ The php-sdk mirrors the sync suite in `tests/Sync/Http/`.
456
+
457
+ ## Troubleshooting
458
+
459
+ Short version — the boilerplate's root `README.md` has the full symptom → cause → fix list.
460
+
461
+ - **"unexpected aud"** on verify: `audience` must be the ServiceInstance slug, not the service
462
+ slug. The error message quotes expected vs actual `iss`/`aud`.
463
+ - **401 from `/api/org/v1/*`**: the token lacks the `organization-api` audience — no `org.*`
464
+ scope requested, RBAC stripped them all, or a pre-#1960 platform. 403 `insufficient_scope`
465
+ means the audience is there but the route's scope is not — read `wwwAuthenticate`.
466
+ - **TLS "internal error" from workerd** against a Herd-hosted local platform: workerd must trust
467
+ the Herd CA (`NODE_EXTRA_CA_CERTS`, see the boilerplate's `dev:herd` script).
468
+ - **Ingest answers 401**: same `whsec_` secret on both ends, and the bridge's clock within
469
+ ±5 min. 415 means the content type is not a CloudEvents structured mode.
470
+ - **Snapshot 403/410**: `service_api_enabled` missing on the credential / cursor expired —
471
+ restart the pass from the beginning. The thrown error carries the per-status hint.
472
+
473
+ ## Roadmap
474
+
475
+ - `Transport` (JetStream durable) for Node hosts, feeding the same `Projector`.
476
+ - Extraction to `godx-jp/server-sdk` + npm publication once the surface settles. Publish
477
+ checklist:
478
+ - [ ] Compile to `dist/` (tsc) and point `exports` at built JS + `.d.ts` — the conditions
479
+ currently resolve `.ts` source, which only works inside a workspace.
480
+ - [ ] Keep the `workerd`/`worker`-before-`browser` condition order in every subpath (see
481
+ [Package exports](#package-exports--condition-order-is-load-bearing)).
482
+ - [ ] Ship `browser-guard` in the build and verify a browser-bundler build actually throws.
483
+ - [ ] `"private": true` → off; add `files`, `license`, `repository`; version from `0.1.0`.
484
+ - [ ] Move `hono` to `peerDependencies` review (already `>=4`) and keep `jose` as the only
485
+ runtime dependency.
486
+ - [ ] CI: `vitest run` (30) + `tsc --noEmit` + a pack-and-import smoke test on Workers and Node.
package/dist/auth.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ interface Principal {
2
+ readonly sub: string;
3
+ readonly organizationId: string;
4
+ /** Defaults to read_only unless the claim says full — fail closed. */
5
+ readonly accessMode: "full" | "read_only";
6
+ readonly scopes: readonly string[];
7
+ readonly serviceInstanceId?: string;
8
+ readonly clientId?: string;
9
+ /** Unix seconds, from the access token's exp. */
10
+ readonly expiresAt: number;
11
+ /** From userinfo, when the adapter fetched it at login. */
12
+ readonly name?: string;
13
+ readonly email?: string;
14
+ }
15
+ /**
16
+ * The one parse gate. Validates signature (JWKS), iss (discovery issuer,
17
+ * exact), aud (= service slug), exp — then requires organization_id.
18
+ */
19
+ declare function verifyAccessToken(token: string, opts: {
20
+ issuer: string;
21
+ serviceSlug: string;
22
+ /** The token audience = your ServiceInstance slug. Defaults to serviceSlug. */
23
+ audience?: string;
24
+ }): Promise<Principal>;
25
+ interface Pkce {
26
+ verifier: string;
27
+ challenge: string;
28
+ method: "S256";
29
+ }
30
+ declare function createPkce(): Promise<Pkce>;
31
+ declare function randomToken(bytes?: number): string;
32
+ /** Encrypt + sign an arbitrary payload into a compact cookie value (JWE dir/A256GCM). */
33
+ declare function sealData(payload: Record<string, unknown>, secret: string, ttlSeconds: number): Promise<string>;
34
+ /** Returns null for anything invalid or expired — never throws on bad input. */
35
+ declare function openData<T = Record<string, unknown>>(token: string, secret: string): Promise<T | null>;
36
+
37
+ export { type Pkce, type Principal, createPkce, openData, randomToken, sealData, verifyAccessToken };
package/dist/auth.js ADDED
@@ -0,0 +1,16 @@
1
+ import {
2
+ createPkce,
3
+ openData,
4
+ randomToken,
5
+ sealData,
6
+ verifyAccessToken
7
+ } from "./chunk-GPGSVZCR.js";
8
+ import "./chunk-DMXYEWYY.js";
9
+ export {
10
+ createPkce,
11
+ openData,
12
+ randomToken,
13
+ sealData,
14
+ verifyAccessToken
15
+ };
16
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -0,0 +1,16 @@
1
+ interface EffectiveAccess {
2
+ permissions: string[];
3
+ roles: string[];
4
+ serviceAccess: Record<string, unknown>;
5
+ contractVersion?: string;
6
+ evaluatedAt?: string;
7
+ authoritative: boolean;
8
+ }
9
+ declare function fetchEffectivePermissions(params: {
10
+ issuer: string;
11
+ accessToken: string;
12
+ organizationId: string;
13
+ branchId?: string;
14
+ }): Promise<EffectiveAccess>;
15
+
16
+ export { type EffectiveAccess, fetchEffectivePermissions };
@@ -0,0 +1,31 @@
1
+ import {
2
+ GodxHttpError
3
+ } from "./chunk-DMXYEWYY.js";
4
+
5
+ // src/authorization.ts
6
+ async function fetchEffectivePermissions(params) {
7
+ const url = new URL("/api/sso/me/permissions", params.issuer);
8
+ url.search = new URLSearchParams({
9
+ organization_id: params.organizationId,
10
+ ...params.branchId ? { branch_id: params.branchId } : {}
11
+ }).toString();
12
+ const res = await fetch(url, {
13
+ headers: { Authorization: `Bearer ${params.accessToken}` }
14
+ });
15
+ if (!res.ok) {
16
+ throw new GodxHttpError(res.status, await res.text(), "effective permissions failed");
17
+ }
18
+ const body = await res.json();
19
+ return {
20
+ permissions: body.permissions ?? [],
21
+ roles: body.roles ?? [],
22
+ serviceAccess: body.service_access ?? {},
23
+ contractVersion: body.contract_version,
24
+ evaluatedAt: body.evaluated_at,
25
+ authoritative: body.authoritative === true
26
+ };
27
+ }
28
+ export {
29
+ fetchEffectivePermissions
30
+ };
31
+ //# sourceMappingURL=authorization.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/authorization.ts"],"sourcesContent":["/**\n * ./authorization — effective-access read model. Mirrors php-sdk's\n * Authorization module: results carry the platform's authoritative flag,\n * nothing is cached as a positive grant here.\n */\nimport { GodxHttpError } from \"./http\";\n\nexport interface EffectiveAccess {\n permissions: string[];\n roles: string[];\n serviceAccess: Record<string, unknown>;\n contractVersion?: string;\n evaluatedAt?: string;\n authoritative: boolean;\n}\n\nexport async function fetchEffectivePermissions(params: {\n issuer: string;\n accessToken: string;\n organizationId: string;\n branchId?: string;\n}): Promise<EffectiveAccess> {\n const url = new URL(\"/api/sso/me/permissions\", params.issuer);\n url.search = new URLSearchParams({\n organization_id: params.organizationId,\n ...(params.branchId ? { branch_id: params.branchId } : {}),\n }).toString();\n\n const res = await fetch(url, {\n headers: { Authorization: `Bearer ${params.accessToken}` },\n });\n if (!res.ok) {\n throw new GodxHttpError(res.status, await res.text(), \"effective permissions failed\");\n }\n const body = (await res.json()) as {\n permissions?: string[];\n roles?: string[];\n service_access?: Record<string, unknown>;\n contract_version?: string;\n evaluated_at?: string;\n authoritative?: boolean;\n };\n return {\n permissions: body.permissions ?? [],\n roles: body.roles ?? [],\n serviceAccess: body.service_access ?? {},\n contractVersion: body.contract_version,\n evaluatedAt: body.evaluated_at,\n authoritative: body.authoritative === true,\n };\n}\n"],"mappings":";;;;;AAgBA,eAAsB,0BAA0B,QAKnB;AAC3B,QAAM,MAAM,IAAI,IAAI,2BAA2B,OAAO,MAAM;AAC5D,MAAI,SAAS,IAAI,gBAAgB;AAAA,IAC/B,iBAAiB,OAAO;AAAA,IACxB,GAAI,OAAO,WAAW,EAAE,WAAW,OAAO,SAAS,IAAI,CAAC;AAAA,EAC1D,CAAC,EAAE,SAAS;AAEZ,QAAM,MAAM,MAAM,MAAM,KAAK;AAAA,IAC3B,SAAS,EAAE,eAAe,UAAU,OAAO,WAAW,GAAG;AAAA,EAC3D,CAAC;AACD,MAAI,CAAC,IAAI,IAAI;AACX,UAAM,IAAI,cAAc,IAAI,QAAQ,MAAM,IAAI,KAAK,GAAG,8BAA8B;AAAA,EACtF;AACA,QAAM,OAAQ,MAAM,IAAI,KAAK;AAQ7B,SAAO;AAAA,IACL,aAAa,KAAK,eAAe,CAAC;AAAA,IAClC,OAAO,KAAK,SAAS,CAAC;AAAA,IACtB,eAAe,KAAK,kBAAkB,CAAC;AAAA,IACvC,iBAAiB,KAAK;AAAA,IACtB,aAAa,KAAK;AAAA,IAClB,eAAe,KAAK,kBAAkB;AAAA,EACxC;AACF;","names":[]}
@@ -0,0 +1,2 @@
1
+
2
+ export { }
@@ -0,0 +1,5 @@
1
+ // src/browser-guard.ts
2
+ throw new Error(
3
+ "@godxjp/server-sdk was imported into a browser bundle. It holds service credentials and must only run server-side (Workers/Node). Use @godxjp/web-sdk in frontend code."
4
+ );
5
+ //# sourceMappingURL=browser-guard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/browser-guard.ts"],"sourcesContent":["// Resolved instead of the real module when a bundler builds for the browser.\n// This SDK holds service credentials (client_secret, session secret); it must\n// only ever run server-side. For browser code use @godxjp/web-sdk.\nthrow new Error(\n \"@godxjp/server-sdk was imported into a browser bundle. \" +\n \"It holds service credentials and must only run server-side (Workers/Node). \" +\n \"Use @godxjp/web-sdk in frontend code.\",\n);\n\nexport {};\n"],"mappings":";AAGA,MAAM,IAAI;AAAA,EACR;AAGF;","names":[]}