@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/LICENSE +201 -0
- package/README.md +486 -0
- package/dist/auth.d.ts +37 -0
- package/dist/auth.js +16 -0
- package/dist/auth.js.map +1 -0
- package/dist/authorization.d.ts +16 -0
- package/dist/authorization.js +31 -0
- package/dist/authorization.js.map +1 -0
- package/dist/browser-guard.d.ts +2 -0
- package/dist/browser-guard.js +5 -0
- package/dist/browser-guard.js.map +1 -0
- package/dist/chunk-DMXYEWYY.js +132 -0
- package/dist/chunk-DMXYEWYY.js.map +1 -0
- package/dist/chunk-GPGSVZCR.js +87 -0
- package/dist/chunk-GPGSVZCR.js.map +1 -0
- package/dist/hono.d.ts +35 -0
- package/dist/hono.js +159 -0
- package/dist/hono.js.map +1 -0
- package/dist/http.d.ts +96 -0
- package/dist/http.js +13 -0
- package/dist/http.js.map +1 -0
- package/dist/org.d.ts +126 -0
- package/dist/org.js +101 -0
- package/dist/org.js.map +1 -0
- package/dist/sync.d.ts +174 -0
- package/dist/sync.js +241 -0
- package/dist/sync.js.map +1 -0
- package/package.json +89 -0
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
|
package/dist/auth.js.map
ADDED
|
@@ -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 @@
|
|
|
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":[]}
|