@godxjp/server-sdk 0.3.0 → 0.5.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 CHANGED
@@ -75,7 +75,8 @@ back to `defaultOrganizationContextId` for local dev). A session is bound to the
75
75
  | `clientSecret` | no | `undefined` | Omit for a public (secretless) instance — PKCE alone then. |
76
76
  | `redirectUri` | yes | — | Must match the instance's `allowed_redirect_uris` exactly (scheme, host, port, path). |
77
77
  | `sessionSecret` | yes | — | Hashed (SHA-256) into the JWE key that seals the `gx_txn` and `gx_sess` cookies (dir/A256GCM). |
78
- | `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
+ | `scope` | no | `"openid profile email offline_access"` | Space-separated. `offline_access` gets a refresh token, so a session outlives one access token (see `POST /refresh` below); drop it and a session ends with the access token. Add `org.*` words to reach `/api/org/v1` — they are granted only as far as the signing-in user's RBAC reaches. |
79
+ | `sessionMaxAge` | no | `2592000` (30 days) | Lifetime of a session cookie that holds a refresh token (= the platform's refresh-token lifetime). The access token inside still expires on time. |
79
80
  | `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. |
80
81
  | `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. |
81
82
 
@@ -83,11 +84,29 @@ back to `defaultOrganizationContextId` for local dev). A session is bound to the
83
84
 
84
85
  | Member | Type | Behaviour |
85
86
  |---|---|---|
86
- | `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=…`. |
87
- | `requireAuth` | middleware | Opens `gx_sess`; 401 `{"error":"unauthenticated"}` when missing/expired. Sets `c.set("principal", …)` and `c.set("accessToken", …)` (the `GodxVariables` pair). |
87
+ | `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, refreshToken?}` into `gx_sess`), `POST /refresh` (renews the session with the refresh token: 204 + new cookie, or 401 and the cookie cleared), `GET /logout` (best-effort revoke of the access AND refresh token, then redirect to `end_session_endpoint`). Every failure redirects to `/?sso_error=…&sso_error_description=…`. |
88
+ | `requireAuth` | middleware | Opens `gx_sess`; 401 `{"error":"unauthenticated","refreshable":bool}` when missing/expired — `refreshable: true` means `POST /refresh` can renew it without a new sign-in. Sets `c.set("principal", …)` and `c.set("accessToken", …)` (the `GodxVariables` pair). |
88
89
  | `requireWritable` | middleware | Enforces `organization_access_mode`: non-GET/HEAD/OPTIONS from a `read_only` org answers 403. |
89
90
  | `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. |
90
91
 
92
+ ### Keeping a session alive — `POST /refresh` (single-flight)
93
+
94
+ The platform **rotates** refresh tokens and revokes the whole family when an old one is replayed.
95
+ So the SDK never refreshes inside `requireAuth` (parallel API calls would replay the same token and
96
+ sign the user out). The browser does it once, then retries:
97
+
98
+ ```ts
99
+ let refreshing: Promise<boolean> | null = null;
100
+ async function api(path: string, init?: RequestInit): Promise<Response> {
101
+ const res = await fetch(path, init);
102
+ if (res.status !== 401) return res;
103
+ const body = await res.clone().json().catch(() => ({}));
104
+ if (!body.refreshable) return res; // sign in again
105
+ refreshing ??= fetch("/auth/refresh", { method: "POST" }).then((r) => r.ok).finally(() => (refreshing = null));
106
+ return (await refreshing) ? fetch(path, init) : res;
107
+ }
108
+ ```
109
+
91
110
  ## API reference
92
111
 
93
112
  Every export per subpath, checked against `src/*.ts`.
@@ -96,7 +115,7 @@ Every export per subpath, checked against `src/*.ts`.
96
115
 
97
116
  | Export | Signature (condensed) | One line |
98
117
  |---|---|---|
99
- | `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). |
118
+ | `Principal` | `interface { sub, organizationId, organizationContextId?, accessMode, scopes, serviceInstanceId?, clientId?, expiresAt, name?, email? }` | The verified, frozen identity. `organizationId` = claim `organization_id` (internal id); `organizationContextId` = claim `organization_context_id` (console id — the one `fetchEffectivePermissions` needs). `accessMode` defaults to `read_only` unless the claim says `full` (fail closed). |
100
119
  | `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`. |
101
120
  | `Pkce` | `interface { verifier, challenge, method: "S256" }` | A PKCE pair. |
102
121
  | `createPkce` | `() => Promise<Pkce>` | S256 challenge via WebCrypto. |
@@ -109,7 +128,7 @@ Every export per subpath, checked against `src/*.ts`.
109
128
  | Export | Signature | One line |
110
129
  |---|---|---|
111
130
  | `EffectiveAccess` | `interface { permissions, roles, serviceAccess, contractVersion?, evaluatedAt?, authoritative }` | The effective-access read model. |
112
- | `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. |
131
+ | `fetchEffectivePermissions` | `({issuer, accessToken, organizationId, branchId?}) => Promise<EffectiveAccess>` | `GET /api/sso/me/permissions`. Pass `principal.organizationContextId` (console id) — the internal `organizationId` answers 403 CONTEXT_UNAVAILABLE; `authoritative` passed through as-is — never cache a non-authoritative answer as a positive grant. |
113
132
 
114
133
  ### `./http`
115
134
 
@@ -595,3 +614,30 @@ Short version — the boilerplate's root `README.md` has the full symptom → ca
595
614
  - [ ] Move `hono` to `peerDependencies` review (already `>=4`) and keep `jose` as the only
596
615
  runtime dependency.
597
616
  - [ ] CI: `vitest run` (40) + `tsc --noEmit` + a pack-and-import smoke test on Workers and Node.
617
+
618
+ ## `./logger` — godx-logger for Workers
619
+
620
+ Ship a Worker's logs to [godx-logger](https://logger.godx.jp). Turning it on:
621
+
622
+ ```sh
623
+ npx wrangler secret put GODX_LOGGER_TOKEN # this app's ingest key (Vault; ask the platform operator)
624
+ ```
625
+
626
+ ```ts
627
+ import { godxLogger, type GodxLoggerVariables } from "@godxjp/server-sdk/logger";
628
+
629
+ type AppEnv = { Bindings: Env; Variables: GodxVariables & GodxLoggerVariables };
630
+ app.use(godxLogger()); // reads GODX_LOGGER_TOKEN (+ optional GODX_LOGGER_LEVEL/_APP/_URL, ENVIRONMENT)
631
+
632
+ app.get("/x", (c) => {
633
+ c.var.log.info("doing x", { order: 42 }); // debug | info | warn | error(msg, Error | fields)
634
+ return c.text("ok");
635
+ });
636
+ ```
637
+
638
+ Per request: `c.var.log` carries `method`, `path`, `request_id` (cf-ray); a request line is logged at the end
639
+ (`info`, `warn` for 4xx, `error` for 5xx) and a thrown error with its stack. The batch is shipped with
640
+ `executionCtx.waitUntil` — the response never waits, and a down logger never fails the request. Without a
641
+ token nothing is sent. The ingest key pins the tenant and the `app` label server-side, so every Worker of the
642
+ app lands in one log group `app:<name>`. Outside Hono: `GodxLogger.create({ token, app }).info(…)` then
643
+ `await log.flush()`.
package/dist/auth.d.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  interface Principal {
2
2
  readonly sub: string;
3
+ /** Claim organization_id — the organization's INTERNAL id. */
3
4
  readonly organizationId: string;
5
+ /**
6
+ * Claim organization_context_id — the CONSOLE organization id, when the token carries one.
7
+ * This is the id the platform's organization-scoped APIs look organizations up by
8
+ * (`/api/sso/me/permissions`, `fetchEffectivePermissions`); passing `organizationId` there
9
+ * answers 403 CONTEXT_UNAVAILABLE (godx-jp/server-sdk#6).
10
+ */
11
+ readonly organizationContextId?: string;
4
12
  /** Defaults to read_only unless the claim says full — fail closed. */
5
13
  readonly accessMode: "full" | "read_only";
6
14
  readonly scopes: readonly string[];
package/dist/auth.js CHANGED
@@ -4,8 +4,8 @@ import {
4
4
  randomToken,
5
5
  sealData,
6
6
  verifyAccessToken
7
- } from "./chunk-GPGSVZCR.js";
8
- import "./chunk-DMXYEWYY.js";
7
+ } from "./chunk-UAVDVHJ3.js";
8
+ import "./chunk-XJOH2N4R.js";
9
9
  export {
10
10
  createPkce,
11
11
  openData,
@@ -9,6 +9,10 @@ interface EffectiveAccess {
9
9
  declare function fetchEffectivePermissions(params: {
10
10
  issuer: string;
11
11
  accessToken: string;
12
+ /**
13
+ * The CONSOLE organization id — `principal.organizationContextId`, not
14
+ * `principal.organizationId` (the internal id answers 403 CONTEXT_UNAVAILABLE).
15
+ */
12
16
  organizationId: string;
13
17
  branchId?: string;
14
18
  }): Promise<EffectiveAccess>;
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  GodxHttpError
3
- } from "./chunk-DMXYEWYY.js";
3
+ } from "./chunk-XJOH2N4R.js";
4
4
 
5
5
  // src/authorization.ts
6
6
  async function fetchEffectivePermissions(params) {
@@ -1 +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":[]}
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 /**\n * The CONSOLE organization id — `principal.organizationContextId`, not\n * `principal.organizationId` (the internal id answers 403 CONTEXT_UNAVAILABLE).\n */\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,QASnB;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":[]}
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  loadIssuer
3
- } from "./chunk-DMXYEWYY.js";
3
+ } from "./chunk-XJOH2N4R.js";
4
4
 
5
5
  // src/auth.ts
6
6
  import { EncryptJWT, jwtDecrypt, jwtVerify } from "jose";
@@ -30,6 +30,7 @@ async function verifyAccessToken(token, opts) {
30
30
  return Object.freeze({
31
31
  sub: payload.sub,
32
32
  organizationId,
33
+ organizationContextId: typeof payload["organization_context_id"] === "string" && payload["organization_context_id"] !== "" ? payload["organization_context_id"] : void 0,
33
34
  accessMode: payload["organization_access_mode"] === "full" ? "full" : "read_only",
34
35
  scopes: Object.freeze(String(payload["scope"] ?? "").split(" ").filter(Boolean)),
35
36
  serviceInstanceId: typeof payload["service_instance_id"] === "string" ? payload["service_instance_id"] : void 0,
@@ -84,4 +85,4 @@ export {
84
85
  sealData,
85
86
  openData
86
87
  };
87
- //# sourceMappingURL=chunk-GPGSVZCR.js.map
88
+ //# sourceMappingURL=chunk-UAVDVHJ3.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/auth.ts"],"sourcesContent":["/**\n * ./auth — verified immutable Principal, PKCE, and sealed session values.\n * Mirrors php-sdk's Auth module: the Principal only comes out of one parse\n * gate (verifyAccessToken) and is frozen after that.\n */\nimport { EncryptJWT, jwtDecrypt, jwtVerify } from \"jose\";\nimport { loadIssuer } from \"./http\";\n\nexport interface Principal {\n readonly sub: string;\n /** Claim organization_id — the organization's INTERNAL id. */\n readonly organizationId: string;\n /**\n * Claim organization_context_id — the CONSOLE organization id, when the token carries one.\n * This is the id the platform's organization-scoped APIs look organizations up by\n * (`/api/sso/me/permissions`, `fetchEffectivePermissions`); passing `organizationId` there\n * answers 403 CONTEXT_UNAVAILABLE (godx-jp/server-sdk#6).\n */\n readonly organizationContextId?: string;\n /** Defaults to read_only unless the claim says full — fail closed. */\n readonly accessMode: \"full\" | \"read_only\";\n readonly scopes: readonly string[];\n readonly serviceInstanceId?: string;\n readonly clientId?: string;\n /** Unix seconds, from the access token's exp. */\n readonly expiresAt: number;\n /** From userinfo, when the adapter fetched it at login. */\n readonly name?: string;\n readonly email?: string;\n}\n\n/**\n * The one parse gate. Validates signature (JWKS), iss (discovery issuer,\n * exact), aud (= service slug), exp — then requires organization_id.\n */\nexport async function verifyAccessToken(\n token: string,\n opts: {\n issuer: string;\n serviceSlug: string;\n /** The token audience = your ServiceInstance slug. Defaults to serviceSlug. */\n audience?: string;\n },\n): Promise<Principal> {\n const expectedAudience = opts.audience ?? opts.serviceSlug;\n const { metadata, jwks } = await loadIssuer(opts.issuer);\n let payload;\n try {\n ({ payload } = await jwtVerify(token, jwks, {\n issuer: metadata.issuer,\n audience: expectedAudience,\n }));\n } catch (e) {\n // Say what was expected vs what the token actually carries — a bare\n // \"unexpected claim\" costs whoever reads it a debugging session.\n const actual = decodeUnverified(token);\n throw new Error(\n `${e instanceof Error ? e.message : String(e)} — expected iss=\"${metadata.issuer}\" aud=\"${expectedAudience}\", ` +\n `token carries iss=\"${String(actual?.iss ?? \"?\")}\" aud=${JSON.stringify(actual?.aud ?? \"?\")}`,\n { cause: e },\n );\n }\n\n const organizationId = payload[\"organization_id\"];\n if (typeof organizationId !== \"string\" || organizationId === \"\") {\n throw new Error(\"access token has no organization_id claim\");\n }\n if (typeof payload.sub !== \"string\" || typeof payload.exp !== \"number\") {\n throw new Error(\"access token is missing sub/exp\");\n }\n\n return Object.freeze({\n sub: payload.sub,\n organizationId,\n organizationContextId:\n typeof payload[\"organization_context_id\"] === \"string\" && payload[\"organization_context_id\"] !== \"\"\n ? payload[\"organization_context_id\"]\n : undefined,\n accessMode: payload[\"organization_access_mode\"] === \"full\" ? \"full\" : \"read_only\",\n scopes: Object.freeze(String(payload[\"scope\"] ?? \"\").split(\" \").filter(Boolean)),\n serviceInstanceId: typeof payload[\"service_instance_id\"] === \"string\" ? payload[\"service_instance_id\"] : undefined,\n clientId: typeof payload[\"client_id\"] === \"string\" ? payload[\"client_id\"] : undefined,\n expiresAt: payload.exp,\n } satisfies Principal);\n}\n\nexport interface Pkce {\n verifier: string;\n challenge: string;\n method: \"S256\";\n}\n\nexport async function createPkce(): Promise<Pkce> {\n const verifier = randomToken(32);\n const digest = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(verifier));\n return { verifier, challenge: base64url(new Uint8Array(digest)), method: \"S256\" };\n}\n\nexport function randomToken(bytes = 16): string {\n const buf = new Uint8Array(bytes);\n crypto.getRandomValues(buf);\n return base64url(buf);\n}\n\n/** Diagnostics only — an UNVERIFIED read of the payload for error messages. */\nfunction decodeUnverified(token: string): Record<string, unknown> | null {\n try {\n const part = token.split(\".\")[1] ?? \"\";\n const pad = part.replace(/-/g, \"+\").replace(/_/g, \"/\");\n return JSON.parse(atob(pad)) as Record<string, unknown>;\n } catch {\n return null;\n }\n}\n\nfunction base64url(bytes: Uint8Array): string {\n let bin = \"\";\n for (const b of bytes) bin += String.fromCharCode(b);\n return btoa(bin).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nasync function sessionKey(secret: string): Promise<Uint8Array> {\n const digest = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(secret));\n return new Uint8Array(digest);\n}\n\n/** Encrypt + sign an arbitrary payload into a compact cookie value (JWE dir/A256GCM). */\nexport async function sealData(\n payload: Record<string, unknown>,\n secret: string,\n ttlSeconds: number,\n): Promise<string> {\n return new EncryptJWT(payload)\n .setProtectedHeader({ alg: \"dir\", enc: \"A256GCM\" })\n .setIssuedAt()\n .setExpirationTime(Math.floor(Date.now() / 1000) + ttlSeconds)\n .encrypt(await sessionKey(secret));\n}\n\n/** Returns null for anything invalid or expired — never throws on bad input. */\nexport async function openData<T = Record<string, unknown>>(\n token: string,\n secret: string,\n): Promise<T | null> {\n try {\n const { payload } = await jwtDecrypt(token, await sessionKey(secret));\n return payload as T;\n } catch {\n return null;\n }\n}\n"],"mappings":"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","names":[]}
@@ -62,7 +62,11 @@ var GodxSsoClient = class {
62
62
  const { metadata } = await loadIssuer(this.config.issuer);
63
63
  return this.postForm(new URL("/api/sso/refresh", metadata.issuer).toString(), {
64
64
  grant_type: "refresh_token",
65
- refresh_token: refreshToken
65
+ refresh_token: refreshToken,
66
+ // The platform files a refresh token under the INSTANCE slug (the token's audience,
67
+ // e.g. "godx-logger-prod"), not the catalog service slug: sending the service slug
68
+ // finds no row and answers invalid_grant (measured against id.godx.jp).
69
+ service_slug: this.config.audience ?? this.config.serviceSlug
66
70
  });
67
71
  }
68
72
  /** Best-effort revocation; failures are the caller's decision to ignore. */
@@ -129,4 +133,4 @@ export {
129
133
  GodxSsoClient,
130
134
  createServiceApi
131
135
  };
132
- //# sourceMappingURL=chunk-DMXYEWYY.js.map
136
+ //# sourceMappingURL=chunk-XJOH2N4R.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/http.ts"],"sourcesContent":["/**\n * ./http — endpoint contract from OIDC discovery, the SSO client, and the\n * server-to-server service API. Runtime-agnostic: fetch + WebCrypto only.\n * Mirrors php-sdk's Http module boundary.\n */\nimport { createRemoteJWKSet } from \"jose\";\n\nexport interface IssuerMetadata {\n issuer: string;\n authorization_endpoint: string;\n token_endpoint: string;\n userinfo_endpoint: string;\n jwks_uri: string;\n end_session_endpoint?: string;\n revocation_endpoint?: string;\n}\n\nexport interface Issuer {\n metadata: IssuerMetadata;\n jwks: ReturnType<typeof createRemoteJWKSet>;\n}\n\nexport class GodxHttpError extends Error {\n readonly status: number;\n readonly body: string;\n\n constructor(status: number, body: string, message?: string) {\n super(message ?? `GoDX platform request failed with ${status}`);\n this.name = \"GodxHttpError\";\n this.status = status;\n this.body = body;\n }\n}\n\n// Per-isolate cache; the platform docs require reading discovery, not hardcoding paths.\nconst issuerCache = new Map<string, Promise<Issuer>>();\n\nexport function loadIssuer(issuerUrl: string): Promise<Issuer> {\n let cached = issuerCache.get(issuerUrl);\n if (!cached) {\n cached = (async () => {\n const res = await fetch(new URL(\"/.well-known/openid-configuration\", issuerUrl));\n if (!res.ok) {\n issuerCache.delete(issuerUrl);\n throw new GodxHttpError(res.status, await res.text(), \"OIDC discovery failed\");\n }\n const metadata = (await res.json()) as IssuerMetadata;\n return { metadata, jwks: createRemoteJWKSet(new URL(metadata.jwks_uri)) };\n })();\n issuerCache.set(issuerUrl, cached);\n cached.catch(() => issuerCache.delete(issuerUrl));\n }\n return cached;\n}\n\nexport interface SsoClientConfig {\n issuer: string;\n serviceSlug: string;\n clientId: string;\n /** Omit for a public (secretless) instance — PKCE alone then. */\n clientSecret?: string;\n redirectUri: string;\n /** Token audience = the ServiceInstance slug (defaults to serviceSlug). */\n audience?: string;\n}\n\nexport interface TokenResponse {\n access_token: string;\n token_type: string;\n expires_in: number;\n id_token?: string;\n refresh_token?: string;\n scope?: string;\n}\n\nexport interface UserinfoResponse {\n sub: string;\n email?: string;\n email_verified?: boolean;\n name?: string;\n locale?: string;\n timezone?: string;\n}\n\nexport class GodxSsoClient {\n readonly config: SsoClientConfig;\n\n constructor(config: SsoClientConfig) {\n this.config = config;\n }\n\n async authorizeUrl(params: {\n state: string;\n nonce: string;\n codeChallenge: string;\n scope?: string;\n /** console_organization_id — required by the launch gates; usually arrives on the launch URL. */\n organizationContextId?: string;\n }): Promise<string> {\n const { metadata } = await loadIssuer(this.config.issuer);\n const url = new URL(metadata.authorization_endpoint);\n url.search = new URLSearchParams({\n service_slug: this.config.serviceSlug,\n client_id: this.config.clientId,\n redirect_uri: this.config.redirectUri,\n response_type: \"code\",\n scope: params.scope ?? \"openid profile email\",\n state: params.state,\n nonce: params.nonce,\n code_challenge: params.codeChallenge,\n code_challenge_method: \"S256\",\n ...(params.organizationContextId\n ? { organization_context_id: params.organizationContextId }\n : {}),\n }).toString();\n return url.toString();\n }\n\n async exchangeCode(params: { code: string; codeVerifier: string }): Promise<TokenResponse> {\n return this.tokenRequest(\"token_endpoint\", {\n grant_type: \"authorization_code\",\n redirect_uri: this.config.redirectUri,\n code: params.code,\n code_verifier: params.codeVerifier,\n });\n }\n\n async refresh(refreshToken: string): Promise<TokenResponse> {\n const { metadata } = await loadIssuer(this.config.issuer);\n return this.postForm(new URL(\"/api/sso/refresh\", metadata.issuer).toString(), {\n grant_type: \"refresh_token\",\n refresh_token: refreshToken,\n // The platform files a refresh token under the INSTANCE slug (the token's audience,\n // e.g. \"godx-logger-prod\"), not the catalog service slug: sending the service slug\n // finds no row and answers invalid_grant (measured against id.godx.jp).\n service_slug: this.config.audience ?? this.config.serviceSlug,\n });\n }\n\n /** Best-effort revocation; failures are the caller's decision to ignore. */\n async revoke(token: string): Promise<void> {\n const { metadata } = await loadIssuer(this.config.issuer);\n if (!metadata.revocation_endpoint) return;\n await this.postForm(metadata.revocation_endpoint, { token });\n }\n\n async userinfo(accessToken: string): Promise<UserinfoResponse> {\n const { metadata } = await loadIssuer(this.config.issuer);\n const res = await fetch(metadata.userinfo_endpoint, {\n headers: { Authorization: `Bearer ${accessToken}` },\n });\n if (!res.ok) throw new GodxHttpError(res.status, await res.text(), \"userinfo failed\");\n return (await res.json()) as UserinfoResponse;\n }\n\n private async tokenRequest(\n endpoint: \"token_endpoint\",\n fields: Record<string, string>,\n ): Promise<TokenResponse> {\n const { metadata } = await loadIssuer(this.config.issuer);\n return this.postForm(metadata[endpoint], fields);\n }\n\n private async postForm(url: string, fields: Record<string, string>): Promise<TokenResponse> {\n const body = new URLSearchParams({\n service_slug: this.config.serviceSlug,\n client_id: this.config.clientId,\n ...(this.config.clientSecret ? { client_secret: this.config.clientSecret } : {}),\n ...fields,\n });\n const res = await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body,\n });\n if (!res.ok) throw new GodxHttpError(res.status, await res.text(), \"token request failed\");\n return (await res.json()) as TokenResponse;\n }\n}\n\n/**\n * Server-to-server reads with service credentials (X-Service-Slug/X-Service-Secret).\n * Rate-limited by the platform (429) — cache results and back off; no auto-retry here.\n */\nexport function createServiceApi(config: {\n issuer: string;\n serviceSlug: string;\n serviceSecret: string;\n}) {\n const get = async <T>(path: string, query: Record<string, string>): Promise<T> => {\n const url = new URL(path, config.issuer);\n url.search = new URLSearchParams(query).toString();\n const res = await fetch(url, {\n headers: {\n \"X-Service-Slug\": config.serviceSlug,\n \"X-Service-Secret\": config.serviceSecret,\n },\n });\n if (!res.ok) throw new GodxHttpError(res.status, await res.text(), `service API ${path} failed`);\n return (await res.json()) as T;\n };\n\n return {\n users: (q: { organizationSlug: string; perPage?: number; page?: number }) =>\n get<unknown>(\"/api/sso/service/users\", {\n organization_slug: q.organizationSlug,\n ...(q.perPage ? { per_page: String(q.perPage) } : {}),\n ...(q.page ? { page: String(q.page) } : {}),\n }),\n branches: (q: { organizationSlug: string }) =>\n get<unknown>(\"/api/sso/service/branches\", { organization_slug: q.organizationSlug }),\n brands: (q: { organizationSlug: string }) =>\n get<unknown>(\"/api/sso/service/brands\", { organization_slug: q.organizationSlug }),\n };\n}\n"],"mappings":"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","names":[]}
package/dist/hono.d.ts CHANGED
@@ -9,7 +9,16 @@ interface GodxSsoConfig {
9
9
  clientSecret?: string;
10
10
  redirectUri: string;
11
11
  sessionSecret: string;
12
+ /**
13
+ * Requested scopes. Default `openid profile email offline_access`: `offline_access` gets a
14
+ * refresh token, so a session outlives one access token (see `POST <mount>/refresh`).
15
+ */
12
16
  scope?: string;
17
+ /**
18
+ * How long a session with a refresh token may live, in seconds (default 30 days = the
19
+ * platform's refresh-token lifetime). The access token inside is still short-lived.
20
+ */
21
+ sessionMaxAge?: number;
13
22
  /** Token audience = your ServiceInstance slug (defaults to serviceSlug). */
14
23
  audience?: string;
15
24
  /**
package/dist/hono.js CHANGED
@@ -4,21 +4,34 @@ import {
4
4
  randomToken,
5
5
  sealData,
6
6
  verifyAccessToken
7
- } from "./chunk-GPGSVZCR.js";
7
+ } from "./chunk-UAVDVHJ3.js";
8
8
  import {
9
9
  GodxSsoClient,
10
10
  loadIssuer
11
- } from "./chunk-DMXYEWYY.js";
11
+ } from "./chunk-XJOH2N4R.js";
12
12
 
13
13
  // src/hono.ts
14
14
  import { Hono } from "hono";
15
15
  import { deleteCookie, getCookie, setCookie } from "hono/cookie";
16
+ var DEFAULT_SCOPE = "openid profile email offline_access";
17
+ var DEFAULT_SESSION_MAX_AGE = 30 * 24 * 3600;
16
18
  var TXN_COOKIE = "gx_txn";
17
19
  var SESSION_COOKIE = "gx_sess";
18
20
  function godxSso(getConfig) {
19
21
  const client = (c) => new GodxSsoClient(getConfig(c.env));
20
22
  const cfg = (c) => getConfig(c.env);
21
23
  const secure = (c) => c.req.url.startsWith("https:");
24
+ const writeSession = async (c, data) => {
25
+ const config = cfg(c);
26
+ const ttl = data.rt ? config.sessionMaxAge ?? DEFAULT_SESSION_MAX_AGE : Math.max(0, data.p.expiresAt - Math.floor(Date.now() / 1e3));
27
+ setCookie(c, SESSION_COOKIE, await sealData(data, config.sessionSecret, ttl), {
28
+ httpOnly: true,
29
+ secure: secure(c),
30
+ sameSite: "Lax",
31
+ path: "/",
32
+ maxAge: ttl
33
+ });
34
+ };
22
35
  const routes = new Hono();
23
36
  routes.get("/login", async (c) => {
24
37
  const config = cfg(c);
@@ -38,7 +51,7 @@ function godxSso(getConfig) {
38
51
  state,
39
52
  nonce,
40
53
  codeChallenge: pkce.challenge,
41
- scope: config.scope,
54
+ scope: config.scope ?? DEFAULT_SCOPE,
42
55
  organizationContextId
43
56
  })
44
57
  );
@@ -94,23 +107,38 @@ function godxSso(getConfig) {
94
107
  principal = Object.freeze({ ...verified, name: info.name, email: info.email });
95
108
  } catch {
96
109
  }
97
- const ttl = Math.max(0, principal.expiresAt - Math.floor(Date.now() / 1e3));
98
- setCookie(
99
- c,
100
- SESSION_COOKIE,
101
- await sealData({ p: principal, at: tokens.access_token }, config.sessionSecret, ttl),
102
- { httpOnly: true, secure: secure(c), sameSite: "Lax", path: "/", maxAge: ttl }
103
- );
110
+ await writeSession(c, { p: principal, at: tokens.access_token, rt: tokens.refresh_token });
104
111
  return c.redirect("/");
105
112
  });
113
+ routes.post("/refresh", async (c) => {
114
+ const config = cfg(c);
115
+ const raw = getCookie(c, SESSION_COOKIE);
116
+ const session = raw ? await openData(raw, config.sessionSecret) : null;
117
+ if (!session?.rt) return c.json({ error: "unauthenticated", refreshable: false }, 401);
118
+ try {
119
+ const tokens = await client(c).refresh(session.rt);
120
+ const verified = await verifyAccessToken(tokens.access_token, config);
121
+ const principal = Object.freeze({ ...verified, name: session.p.name, email: session.p.email });
122
+ await writeSession(c, { p: principal, at: tokens.access_token, rt: tokens.refresh_token ?? session.rt });
123
+ return c.body(null, 204);
124
+ } catch (e) {
125
+ console.warn("godx-sso: refresh failed", e instanceof Error ? e.message : String(e));
126
+ deleteCookie(c, SESSION_COOKIE, { path: "/" });
127
+ return c.json({ error: "unauthenticated", refreshable: false }, 401);
128
+ }
129
+ });
106
130
  routes.get("/logout", async (c) => {
107
131
  const config = cfg(c);
108
132
  const raw = getCookie(c, SESSION_COOKIE);
109
133
  deleteCookie(c, SESSION_COOKIE, { path: "/" });
110
134
  if (raw) {
111
135
  const session = await openData(raw, config.sessionSecret);
112
- if (session) await client(c).revoke(session.at).catch(() => {
113
- });
136
+ if (session) {
137
+ await client(c).revoke(session.at).catch(() => {
138
+ });
139
+ if (session.rt) await client(c).revoke(session.rt).catch(() => {
140
+ });
141
+ }
114
142
  }
115
143
  const { metadata } = await loadIssuer(config.issuer);
116
144
  return c.redirect(metadata.end_session_endpoint ?? "/");
@@ -119,7 +147,7 @@ function godxSso(getConfig) {
119
147
  const raw = getCookie(c, SESSION_COOKIE);
120
148
  const session = raw ? await openData(raw, cfg(c).sessionSecret) : null;
121
149
  if (!session || session.p.expiresAt <= Math.floor(Date.now() / 1e3)) {
122
- return c.json({ error: "unauthenticated" }, 401);
150
+ return c.json({ error: "unauthenticated", refreshable: !!session?.rt }, 401);
123
151
  }
124
152
  c.set("principal", session.p);
125
153
  c.set("accessToken", session.at);
package/dist/hono.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/hono.ts"],"sourcesContent":["/**\n * ./hono — thin adapter over ./auth + ./http for Hono apps on Workers.\n * Mounts the browser SSO routes and exposes the auth middlewares.\n * Everything platform-shaped stays in the core modules.\n */\nimport { Hono } from \"hono\";\nimport type { Context, Env as HonoEnv, MiddlewareHandler } from \"hono\";\nimport { deleteCookie, getCookie, setCookie } from \"hono/cookie\";\nimport { createPkce, openData, randomToken, sealData, verifyAccessToken } from \"./auth\";\nimport type { Principal } from \"./auth\";\nimport { GodxSsoClient, loadIssuer } from \"./http\";\n\nexport interface GodxSsoConfig {\n issuer: string;\n serviceSlug: string;\n clientId: string;\n clientSecret?: string;\n redirectUri: string;\n sessionSecret: string;\n scope?: string;\n /** Token audience = your ServiceInstance slug (defaults to serviceSlug). */\n audience?: string;\n /**\n * console_organization_id to fall back on when the login was not started\n * from a platform launch URL (which carries organization_context_id).\n * Useful in local dev; leave unset in production multi-org setups.\n */\n defaultOrganizationContextId?: string;\n}\n\ntype SessionData = { p: Principal; at: string };\n\nconst TXN_COOKIE = \"gx_txn\";\nconst SESSION_COOKIE = \"gx_sess\";\n\nexport type GodxVariables = { principal: Principal; accessToken: string };\n\nexport function godxSso<E extends HonoEnv & { Variables: GodxVariables }>(\n getConfig: (env: E[\"Bindings\"]) => GodxSsoConfig,\n) {\n const client = (c: Context<E>) => new GodxSsoClient(getConfig(c.env));\n const cfg = (c: Context<E>) => getConfig(c.env);\n const secure = (c: Context<E>) => c.req.url.startsWith(\"https:\");\n\n const routes = new Hono<E>();\n\n routes.get(\"/login\", async (c) => {\n const config = cfg(c);\n const pkce = await createPkce();\n const state = randomToken();\n const nonce = randomToken();\n // The platform launch URL carries organization_context_id; forward it, or\n // fall back to the configured default (single-org / local dev).\n const organizationContextId =\n c.req.query(\"organization_context_id\") ?? config.defaultOrganizationContextId;\n setCookie(c, TXN_COOKIE, await sealData({ v: pkce.verifier, s: state }, config.sessionSecret, 300), {\n httpOnly: true,\n secure: secure(c),\n sameSite: \"Lax\",\n path: \"/\",\n maxAge: 300,\n });\n return c.redirect(\n await client(c).authorizeUrl({\n state,\n nonce,\n codeChallenge: pkce.challenge,\n scope: config.scope,\n organizationContextId,\n }),\n );\n });\n\n // Every failure lands back on the app with a human-readable description —\n // the SPA renders it; a bare 400 helps nobody.\n const failRedirect = (c: Context<E>, error: string, description: string) =>\n c.redirect(\n `/?sso_error=${encodeURIComponent(error)}&sso_error_description=${encodeURIComponent(description)}`,\n );\n\n routes.get(\"/callback\", async (c) => {\n const config = cfg(c);\n\n // The platform denied the launch (access_denied etc.) and already wrote a\n // localized, human-readable explanation into error_description. Show it.\n const platformError = c.req.query(\"error\");\n if (platformError) {\n return failRedirect(\n c,\n platformError,\n c.req.query(\"error_description\") ?? `サインインが拒否されました(${platformError})。`,\n );\n }\n\n const code = c.req.query(\"code\");\n const state = c.req.query(\"state\");\n const txnCookie = getCookie(c, TXN_COOKIE);\n deleteCookie(c, TXN_COOKIE, { path: \"/\" });\n\n const txn = txnCookie ? await openData<{ v: string; s: string }>(txnCookie, config.sessionSecret) : null;\n if (!code || !state || !txn || txn.s !== state) {\n return failRedirect(\n c,\n \"transaction_invalid\",\n \"ログイン処理の有効期限が切れたか、途中で中断されました。もう一度サインインしてください。\",\n );\n }\n\n let tokens;\n try {\n tokens = await client(c).exchangeCode({ code, codeVerifier: txn.v });\n } catch (e) {\n return failRedirect(\n c,\n \"token_exchange_failed\",\n `認可コードの交換に失敗しました(認可コードは一度しか使えません)。もう一度サインインしてください。詳細: ${e instanceof Error ? e.message : String(e)}`,\n );\n }\n\n let verified;\n try {\n verified = await verifyAccessToken(tokens.access_token, config);\n } catch (e) {\n return failRedirect(\n c,\n \"token_verification_failed\",\n `トークンの検証に失敗しました。サービス設定(issuer / service_slug)を確認してください。詳細: ${e instanceof Error ? e.message : String(e)}`,\n );\n }\n\n // One userinfo call at login so the UI has a display name without further round-trips.\n let principal = verified;\n try {\n const info = await client(c).userinfo(tokens.access_token);\n principal = Object.freeze({ ...verified, name: info.name, email: info.email });\n } catch {\n // profile display data is optional; the verified principal stands on its own\n }\n\n const ttl = Math.max(0, principal.expiresAt - Math.floor(Date.now() / 1000));\n setCookie(\n c,\n SESSION_COOKIE,\n await sealData({ p: principal, at: tokens.access_token } satisfies SessionData, config.sessionSecret, ttl),\n { httpOnly: true, secure: secure(c), sameSite: \"Lax\", path: \"/\", maxAge: ttl },\n );\n return c.redirect(\"/\");\n });\n\n routes.get(\"/logout\", async (c) => {\n const config = cfg(c);\n const raw = getCookie(c, SESSION_COOKIE);\n deleteCookie(c, SESSION_COOKIE, { path: \"/\" });\n if (raw) {\n const session = await openData<SessionData>(raw, config.sessionSecret);\n if (session) await client(c).revoke(session.at).catch(() => {});\n }\n const { metadata } = await loadIssuer(config.issuer);\n return c.redirect(metadata.end_session_endpoint ?? \"/\");\n });\n\n const requireAuth: MiddlewareHandler<E> = async (c, next) => {\n const raw = getCookie(c, SESSION_COOKIE);\n const session = raw ? await openData<SessionData>(raw, cfg(c).sessionSecret) : null;\n if (!session || session.p.expiresAt <= Math.floor(Date.now() / 1000)) {\n return c.json({ error: \"unauthenticated\" }, 401);\n }\n c.set(\"principal\", session.p);\n c.set(\"accessToken\", session.at);\n await next();\n };\n\n /**\n * Forwards /api/dock/v1/* to the platform with the session's Bearer token —\n * the GoDX Dock's `apiBase` lane: no CORS, no token in the page. Mount it\n * BEHIND requireAuth and BEFORE any requireWritable (Dock actions are the\n * platform's calls to authorize, not this app's writes).\n */\n const dockProxy: MiddlewareHandler<E> = async (c) => {\n const config = cfg(c);\n const url = new URL(c.req.url);\n const target = new URL(url.pathname + url.search, config.issuer);\n const method = c.req.method;\n const res = await fetch(target, {\n method,\n headers: {\n Authorization: `Bearer ${c.get(\"accessToken\")}`,\n Accept: \"application/json\",\n \"Content-Type\": c.req.header(\"Content-Type\") ?? \"application/json\",\n },\n body: method === \"GET\" || method === \"HEAD\" ? undefined : await c.req.arrayBuffer(),\n });\n const headers = new Headers({ \"Content-Type\": res.headers.get(\"Content-Type\") ?? \"application/json\" });\n const requestId = res.headers.get(\"X-Request-Id\");\n if (requestId) headers.set(\"X-Request-Id\", requestId);\n return new Response(res.body, { status: res.status, headers });\n };\n\n /** Enforces organization_access_mode: read-only orgs may not write. */\n const requireWritable: MiddlewareHandler<E> = async (c, next) => {\n const safe = c.req.method === \"GET\" || c.req.method === \"HEAD\" || c.req.method === \"OPTIONS\";\n if (!safe && c.get(\"principal\")?.accessMode !== \"full\") {\n return c.json({ error: \"organization is read-only\" }, 403);\n }\n await next();\n };\n\n return { routes, requireAuth, requireWritable, dockProxy };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/hono.ts"],"sourcesContent":["/**\n * ./hono — thin adapter over ./auth + ./http for Hono apps on Workers.\n * Mounts the browser SSO routes and exposes the auth middlewares.\n * Everything platform-shaped stays in the core modules.\n */\nimport { Hono } from \"hono\";\nimport type { Context, Env as HonoEnv, MiddlewareHandler } from \"hono\";\nimport { deleteCookie, getCookie, setCookie } from \"hono/cookie\";\nimport { createPkce, openData, randomToken, sealData, verifyAccessToken } from \"./auth\";\nimport type { Principal } from \"./auth\";\nimport { GodxSsoClient, loadIssuer } from \"./http\";\n\nexport interface GodxSsoConfig {\n issuer: string;\n serviceSlug: string;\n clientId: string;\n clientSecret?: string;\n redirectUri: string;\n sessionSecret: string;\n /**\n * Requested scopes. Default `openid profile email offline_access`: `offline_access` gets a\n * refresh token, so a session outlives one access token (see `POST <mount>/refresh`).\n */\n scope?: string;\n /**\n * How long a session with a refresh token may live, in seconds (default 30 days = the\n * platform's refresh-token lifetime). The access token inside is still short-lived.\n */\n sessionMaxAge?: number;\n /** Token audience = your ServiceInstance slug (defaults to serviceSlug). */\n audience?: string;\n /**\n * console_organization_id to fall back on when the login was not started\n * from a platform launch URL (which carries organization_context_id).\n * Useful in local dev; leave unset in production multi-org setups.\n */\n defaultOrganizationContextId?: string;\n}\n\n/** p = verified principal, at = access token, rt = refresh token (offline_access). */\ntype SessionData = { p: Principal; at: string; rt?: string };\n\nconst DEFAULT_SCOPE = \"openid profile email offline_access\";\nconst DEFAULT_SESSION_MAX_AGE = 30 * 24 * 3600;\n\nconst TXN_COOKIE = \"gx_txn\";\nconst SESSION_COOKIE = \"gx_sess\";\n\nexport type GodxVariables = { principal: Principal; accessToken: string };\n\nexport function godxSso<E extends HonoEnv & { Variables: GodxVariables }>(\n getConfig: (env: E[\"Bindings\"]) => GodxSsoConfig,\n) {\n const client = (c: Context<E>) => new GodxSsoClient(getConfig(c.env));\n const cfg = (c: Context<E>) => getConfig(c.env);\n const secure = (c: Context<E>) => c.req.url.startsWith(\"https:\");\n\n /**\n * With a refresh token the cookie lives as long as the refresh token may (the access token\n * inside still expires on time — requireAuth checks it); without one, exactly as long as the\n * access token, as before.\n */\n const writeSession = async (c: Context<E>, data: SessionData) => {\n const config = cfg(c);\n const ttl = data.rt\n ? (config.sessionMaxAge ?? DEFAULT_SESSION_MAX_AGE)\n : Math.max(0, data.p.expiresAt - Math.floor(Date.now() / 1000));\n setCookie(c, SESSION_COOKIE, await sealData(data, config.sessionSecret, ttl), {\n httpOnly: true,\n secure: secure(c),\n sameSite: \"Lax\",\n path: \"/\",\n maxAge: ttl,\n });\n };\n\n const routes = new Hono<E>();\n\n routes.get(\"/login\", async (c) => {\n const config = cfg(c);\n const pkce = await createPkce();\n const state = randomToken();\n const nonce = randomToken();\n // The platform launch URL carries organization_context_id; forward it, or\n // fall back to the configured default (single-org / local dev).\n const organizationContextId =\n c.req.query(\"organization_context_id\") ?? config.defaultOrganizationContextId;\n setCookie(c, TXN_COOKIE, await sealData({ v: pkce.verifier, s: state }, config.sessionSecret, 300), {\n httpOnly: true,\n secure: secure(c),\n sameSite: \"Lax\",\n path: \"/\",\n maxAge: 300,\n });\n return c.redirect(\n await client(c).authorizeUrl({\n state,\n nonce,\n codeChallenge: pkce.challenge,\n scope: config.scope ?? DEFAULT_SCOPE,\n organizationContextId,\n }),\n );\n });\n\n // Every failure lands back on the app with a human-readable description —\n // the SPA renders it; a bare 400 helps nobody.\n const failRedirect = (c: Context<E>, error: string, description: string) =>\n c.redirect(\n `/?sso_error=${encodeURIComponent(error)}&sso_error_description=${encodeURIComponent(description)}`,\n );\n\n routes.get(\"/callback\", async (c) => {\n const config = cfg(c);\n\n // The platform denied the launch (access_denied etc.) and already wrote a\n // localized, human-readable explanation into error_description. Show it.\n const platformError = c.req.query(\"error\");\n if (platformError) {\n return failRedirect(\n c,\n platformError,\n c.req.query(\"error_description\") ?? `サインインが拒否されました(${platformError})。`,\n );\n }\n\n const code = c.req.query(\"code\");\n const state = c.req.query(\"state\");\n const txnCookie = getCookie(c, TXN_COOKIE);\n deleteCookie(c, TXN_COOKIE, { path: \"/\" });\n\n const txn = txnCookie ? await openData<{ v: string; s: string }>(txnCookie, config.sessionSecret) : null;\n if (!code || !state || !txn || txn.s !== state) {\n return failRedirect(\n c,\n \"transaction_invalid\",\n \"ログイン処理の有効期限が切れたか、途中で中断されました。もう一度サインインしてください。\",\n );\n }\n\n let tokens;\n try {\n tokens = await client(c).exchangeCode({ code, codeVerifier: txn.v });\n } catch (e) {\n return failRedirect(\n c,\n \"token_exchange_failed\",\n `認可コードの交換に失敗しました(認可コードは一度しか使えません)。もう一度サインインしてください。詳細: ${e instanceof Error ? e.message : String(e)}`,\n );\n }\n\n let verified;\n try {\n verified = await verifyAccessToken(tokens.access_token, config);\n } catch (e) {\n return failRedirect(\n c,\n \"token_verification_failed\",\n `トークンの検証に失敗しました。サービス設定(issuer / service_slug)を確認してください。詳細: ${e instanceof Error ? e.message : String(e)}`,\n );\n }\n\n // One userinfo call at login so the UI has a display name without further round-trips.\n let principal = verified;\n try {\n const info = await client(c).userinfo(tokens.access_token);\n principal = Object.freeze({ ...verified, name: info.name, email: info.email });\n } catch {\n // profile display data is optional; the verified principal stands on its own\n }\n\n await writeSession(c, { p: principal, at: tokens.access_token, rt: tokens.refresh_token });\n return c.redirect(\"/\");\n });\n\n /**\n * Renews an expired (or expiring) session with its refresh token. The platform ROTATES refresh\n * tokens and revokes the whole family when an old one is replayed, so this is a dedicated route\n * the browser calls ONCE (single-flight) when an API answers 401 `refreshable: true`, then\n * retries — refreshing inside `requireAuth` would let parallel requests replay the same token\n * and log the user out.\n *\n * 204 = renewed (new cookie set). 401 = no refresh token, or the platform refused it (expired,\n * revoked, membership gone): the session cookie is cleared and the app should sign in again.\n */\n routes.post(\"/refresh\", async (c) => {\n const config = cfg(c);\n const raw = getCookie(c, SESSION_COOKIE);\n const session = raw ? await openData<SessionData>(raw, config.sessionSecret) : null;\n if (!session?.rt) return c.json({ error: \"unauthenticated\", refreshable: false }, 401);\n try {\n const tokens = await client(c).refresh(session.rt);\n const verified = await verifyAccessToken(tokens.access_token, config);\n // Display data came from userinfo at login; the same person, so it carries over.\n const principal = Object.freeze({ ...verified, name: session.p.name, email: session.p.email });\n await writeSession(c, { p: principal, at: tokens.access_token, rt: tokens.refresh_token ?? session.rt });\n return c.body(null, 204);\n } catch (e) {\n // The platform's reason (invalid_grant, a revoked family, …) is the only clue an operator gets.\n console.warn(\"godx-sso: refresh failed\", e instanceof Error ? e.message : String(e));\n deleteCookie(c, SESSION_COOKIE, { path: \"/\" });\n return c.json({ error: \"unauthenticated\", refreshable: false }, 401);\n }\n });\n\n routes.get(\"/logout\", async (c) => {\n const config = cfg(c);\n const raw = getCookie(c, SESSION_COOKIE);\n deleteCookie(c, SESSION_COOKIE, { path: \"/\" });\n if (raw) {\n const session = await openData<SessionData>(raw, config.sessionSecret);\n if (session) {\n await client(c).revoke(session.at).catch(() => {});\n // The refresh token outlives the access token by weeks: it must not survive a sign-out.\n if (session.rt) await client(c).revoke(session.rt).catch(() => {});\n }\n }\n const { metadata } = await loadIssuer(config.issuer);\n return c.redirect(metadata.end_session_endpoint ?? \"/\");\n });\n\n const requireAuth: MiddlewareHandler<E> = async (c, next) => {\n const raw = getCookie(c, SESSION_COOKIE);\n const session = raw ? await openData<SessionData>(raw, cfg(c).sessionSecret) : null;\n if (!session || session.p.expiresAt <= Math.floor(Date.now() / 1000)) {\n // `refreshable`: POST <mount>/refresh can renew it without a new sign-in.\n return c.json({ error: \"unauthenticated\", refreshable: !!session?.rt }, 401);\n }\n c.set(\"principal\", session.p);\n c.set(\"accessToken\", session.at);\n await next();\n };\n\n /**\n * Forwards /api/dock/v1/* to the platform with the session's Bearer token —\n * the GoDX Dock's `apiBase` lane: no CORS, no token in the page. Mount it\n * BEHIND requireAuth and BEFORE any requireWritable (Dock actions are the\n * platform's calls to authorize, not this app's writes).\n */\n const dockProxy: MiddlewareHandler<E> = async (c) => {\n const config = cfg(c);\n const url = new URL(c.req.url);\n const target = new URL(url.pathname + url.search, config.issuer);\n const method = c.req.method;\n const res = await fetch(target, {\n method,\n headers: {\n Authorization: `Bearer ${c.get(\"accessToken\")}`,\n Accept: \"application/json\",\n \"Content-Type\": c.req.header(\"Content-Type\") ?? \"application/json\",\n },\n body: method === \"GET\" || method === \"HEAD\" ? undefined : await c.req.arrayBuffer(),\n });\n const headers = new Headers({ \"Content-Type\": res.headers.get(\"Content-Type\") ?? \"application/json\" });\n const requestId = res.headers.get(\"X-Request-Id\");\n if (requestId) headers.set(\"X-Request-Id\", requestId);\n return new Response(res.body, { status: res.status, headers });\n };\n\n /** Enforces organization_access_mode: read-only orgs may not write. */\n const requireWritable: MiddlewareHandler<E> = async (c, next) => {\n const safe = c.req.method === \"GET\" || c.req.method === \"HEAD\" || c.req.method === \"OPTIONS\";\n if (!safe && c.get(\"principal\")?.accessMode !== \"full\") {\n return c.json({ error: \"organization is read-only\" }, 403);\n }\n await next();\n };\n\n return { routes, requireAuth, requireWritable, dockProxy };\n}\n"],"mappings":"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","names":[]}
package/dist/http.d.ts CHANGED
@@ -32,6 +32,8 @@ interface SsoClientConfig {
32
32
  /** Omit for a public (secretless) instance — PKCE alone then. */
33
33
  clientSecret?: string;
34
34
  redirectUri: string;
35
+ /** Token audience = the ServiceInstance slug (defaults to serviceSlug). */
36
+ audience?: string;
35
37
  }
36
38
  interface TokenResponse {
37
39
  access_token: string;
package/dist/http.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  GodxSsoClient,
4
4
  createServiceApi,
5
5
  loadIssuer
6
- } from "./chunk-DMXYEWYY.js";
6
+ } from "./chunk-XJOH2N4R.js";
7
7
  export {
8
8
  GodxHttpError,
9
9
  GodxSsoClient,
@@ -0,0 +1,60 @@
1
+ import { Env, MiddlewareHandler } from 'hono';
2
+
3
+ /**
4
+ * godx-logger (https://logger.godx.jp) for Workers.
5
+ *
6
+ * Turning it on is one secret: `wrangler secret put GODX_LOGGER_TOKEN` (this app's ingest key,
7
+ * from the platform operator / Vault) and one line: `app.use(godxLogger())`. Every request then
8
+ * gets `c.var.log`, a request line (status, duration) is logged at the end, a thrown error is
9
+ * logged with its stack, and the batch is shipped with `executionCtx.waitUntil` — the response
10
+ * never waits for the logger, and a down logger never breaks the request.
11
+ *
12
+ * The ingest key pins the tenant and the `app` label server-side; every Worker of the app lands in
13
+ * one log group `app:<name>`.
14
+ */
15
+ type LogLevel = "debug" | "info" | "warn" | "error";
16
+ type LogFields = Record<string, unknown>;
17
+ interface GodxLoggerConfig {
18
+ /** Ingest key. Empty or missing = the logger does nothing (local dev, tests). */
19
+ token?: string;
20
+ /** Fallback app label; the key pins the real one server-side. */
21
+ app?: string;
22
+ env?: string;
23
+ /** Stream name inside the app's log group. Default "worker". */
24
+ host?: string;
25
+ /** Minimum level shipped. Default "info". */
26
+ level?: LogLevel;
27
+ endpoint?: string;
28
+ /** Records per request to the logger. Default 200. */
29
+ batchSize?: number;
30
+ /** Test seam; defaults to the global fetch. */
31
+ fetch?: typeof fetch;
32
+ }
33
+ declare class GodxLogger {
34
+ private readonly sink;
35
+ private readonly cfg;
36
+ private readonly bound;
37
+ private constructor();
38
+ static create(cfg: GodxLoggerConfig): GodxLogger;
39
+ /** A logger that adds `fields` to every record; shares the parent's buffer. */
40
+ child(fields: LogFields): GodxLogger;
41
+ debug(message: string, fields?: LogFields): void;
42
+ info(message: string, fields?: LogFields): void;
43
+ warn(message: string, fields?: LogFields): void;
44
+ error(message: string, fields?: LogFields | unknown): void;
45
+ log(level: LogLevel, message: string, fields?: LogFields): void;
46
+ /** Ship everything buffered. Never throws. */
47
+ flush(): Promise<void>;
48
+ }
49
+ type GodxLoggerVariables = {
50
+ log: GodxLogger;
51
+ };
52
+ /**
53
+ * Hono middleware: `c.var.log` for the request, a request line at the end, errors with stacks,
54
+ * shipped via waitUntil. Config comes from the Worker's bindings unless given.
55
+ */
56
+ declare function godxLogger<E extends Env & {
57
+ Variables: GodxLoggerVariables;
58
+ }>(config?: (env: E["Bindings"]) => GodxLoggerConfig): MiddlewareHandler<E>;
59
+
60
+ export { GodxLogger, type GodxLoggerConfig, type GodxLoggerVariables, type LogFields, type LogLevel, godxLogger };
package/dist/logger.js ADDED
@@ -0,0 +1,143 @@
1
+ // src/logger.ts
2
+ var RANK = { debug: 10, info: 20, warn: 30, error: 40 };
3
+ var DEFAULT_ENDPOINT = "https://logger.godx.jp/api/v1/ingest";
4
+ var MAX_MESSAGE = 32768;
5
+ var MAX_STACK = 8192;
6
+ var cut = (s, max) => s.length > max ? `${s.slice(0, max)}\u2026` : s;
7
+ function errorFields(error) {
8
+ if (!(error instanceof Error)) return { error: String(error) };
9
+ return {
10
+ "exception.class": error.name,
11
+ "exception.message": cut(error.message, MAX_MESSAGE),
12
+ "exception.stack": cut(error.stack ?? "", MAX_STACK)
13
+ };
14
+ }
15
+ var Sink = class {
16
+ constructor(cfg) {
17
+ this.cfg = cfg;
18
+ }
19
+ cfg;
20
+ lines = [];
21
+ get enabled() {
22
+ return !!this.cfg.token;
23
+ }
24
+ push(line) {
25
+ this.lines.push(line);
26
+ }
27
+ get size() {
28
+ return this.lines.length;
29
+ }
30
+ async flush() {
31
+ if (!this.enabled || this.lines.length === 0) return;
32
+ const batch = this.lines.splice(0, this.cfg.batchSize ?? 200);
33
+ const url = new URL(`${(this.cfg.endpoint ?? DEFAULT_ENDPOINT).replace(/\/$/, "")}/insert/jsonline`);
34
+ url.search = new URLSearchParams({ _stream_fields: "app,host", _time_field: "_time", _msg_field: "_msg" }).toString();
35
+ try {
36
+ await (this.cfg.fetch ?? fetch)(url, {
37
+ method: "POST",
38
+ headers: { Authorization: `Bearer ${this.cfg.token}`, "Content-Type": "application/stream+json" },
39
+ body: batch.join("\n") + "\n"
40
+ });
41
+ } catch {
42
+ }
43
+ if (this.lines.length > 0) await this.flush();
44
+ }
45
+ };
46
+ var GodxLogger = class _GodxLogger {
47
+ constructor(sink, cfg, bound) {
48
+ this.sink = sink;
49
+ this.cfg = cfg;
50
+ this.bound = bound;
51
+ }
52
+ sink;
53
+ cfg;
54
+ bound;
55
+ static create(cfg) {
56
+ return new _GodxLogger(new Sink(cfg), cfg, {});
57
+ }
58
+ /** A logger that adds `fields` to every record; shares the parent's buffer. */
59
+ child(fields) {
60
+ return new _GodxLogger(this.sink, this.cfg, { ...this.bound, ...fields });
61
+ }
62
+ debug(message, fields) {
63
+ this.log("debug", message, fields);
64
+ }
65
+ info(message, fields) {
66
+ this.log("info", message, fields);
67
+ }
68
+ warn(message, fields) {
69
+ this.log("warn", message, fields);
70
+ }
71
+ error(message, fields) {
72
+ const extra = fields instanceof Error || fields !== void 0 && typeof fields !== "object" ? errorFields(fields) : fields;
73
+ this.log("error", message, extra);
74
+ }
75
+ log(level, message, fields = {}) {
76
+ if (!this.sink.enabled || RANK[level] < RANK[this.cfg.level ?? "info"]) return;
77
+ const { error, ...rest } = fields;
78
+ this.sink.push(
79
+ JSON.stringify({
80
+ _time: (/* @__PURE__ */ new Date()).toISOString(),
81
+ _msg: cut(message, MAX_MESSAGE),
82
+ level,
83
+ app: this.cfg.app ?? "",
84
+ env: this.cfg.env ?? "production",
85
+ host: this.cfg.host ?? "worker",
86
+ ...this.bound,
87
+ ...rest,
88
+ ...error !== void 0 ? errorFields(error) : {}
89
+ })
90
+ );
91
+ }
92
+ /** Ship everything buffered. Never throws. */
93
+ flush() {
94
+ return this.sink.flush();
95
+ }
96
+ };
97
+ var isLevel = (v) => typeof v === "string" && v in RANK;
98
+ function godxLogger(config) {
99
+ return async (c, next) => {
100
+ const b = c.env ?? {};
101
+ const cfg = config ? config(c.env) : {
102
+ token: b.GODX_LOGGER_TOKEN,
103
+ app: b.GODX_LOGGER_APP,
104
+ env: b.ENVIRONMENT,
105
+ level: isLevel(b.GODX_LOGGER_LEVEL) ? b.GODX_LOGGER_LEVEL : void 0,
106
+ endpoint: b.GODX_LOGGER_URL
107
+ };
108
+ const url = new URL(c.req.url);
109
+ const log = GodxLogger.create(cfg).child({
110
+ method: c.req.method,
111
+ path: url.pathname,
112
+ request_id: c.req.header("cf-ray") ?? crypto.randomUUID()
113
+ });
114
+ c.set("log", log);
115
+ const started = Date.now();
116
+ try {
117
+ await next();
118
+ } catch (error) {
119
+ log.error("unhandled error", { error, status: 500, duration_ms: Date.now() - started });
120
+ throw error;
121
+ } finally {
122
+ const status = c.res?.status ?? 500;
123
+ if (!c.error) {
124
+ log.log(status >= 500 ? "error" : status >= 400 ? "warn" : "info", `${c.req.method} ${url.pathname} ${status}`, {
125
+ status,
126
+ duration_ms: Date.now() - started
127
+ });
128
+ } else {
129
+ log.error("request failed", { error: c.error, status, duration_ms: Date.now() - started });
130
+ }
131
+ const shipping = log.flush();
132
+ try {
133
+ c.executionCtx.waitUntil(shipping);
134
+ } catch {
135
+ }
136
+ }
137
+ };
138
+ }
139
+ export {
140
+ GodxLogger,
141
+ godxLogger
142
+ };
143
+ //# sourceMappingURL=logger.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/logger.ts"],"sourcesContent":["import type { Context, Env, MiddlewareHandler } from \"hono\";\n\n/**\n * godx-logger (https://logger.godx.jp) for Workers.\n *\n * Turning it on is one secret: `wrangler secret put GODX_LOGGER_TOKEN` (this app's ingest key,\n * from the platform operator / Vault) and one line: `app.use(godxLogger())`. Every request then\n * gets `c.var.log`, a request line (status, duration) is logged at the end, a thrown error is\n * logged with its stack, and the batch is shipped with `executionCtx.waitUntil` — the response\n * never waits for the logger, and a down logger never breaks the request.\n *\n * The ingest key pins the tenant and the `app` label server-side; every Worker of the app lands in\n * one log group `app:<name>`.\n */\n\nexport type LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\";\nconst RANK: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 };\n\nexport type LogFields = Record<string, unknown>;\n\nexport interface GodxLoggerConfig {\n /** Ingest key. Empty or missing = the logger does nothing (local dev, tests). */\n token?: string;\n /** Fallback app label; the key pins the real one server-side. */\n app?: string;\n env?: string;\n /** Stream name inside the app's log group. Default \"worker\". */\n host?: string;\n /** Minimum level shipped. Default \"info\". */\n level?: LogLevel;\n endpoint?: string;\n /** Records per request to the logger. Default 200. */\n batchSize?: number;\n /** Test seam; defaults to the global fetch. */\n fetch?: typeof fetch;\n}\n\nconst DEFAULT_ENDPOINT = \"https://logger.godx.jp/api/v1/ingest\";\nconst MAX_MESSAGE = 32_768;\nconst MAX_STACK = 8_192;\n\nconst cut = (s: string, max: number) => (s.length > max ? `${s.slice(0, max)}…` : s);\n\nfunction errorFields(error: unknown): LogFields {\n if (!(error instanceof Error)) return { error: String(error) };\n return {\n \"exception.class\": error.name,\n \"exception.message\": cut(error.message, MAX_MESSAGE),\n \"exception.stack\": cut(error.stack ?? \"\", MAX_STACK),\n };\n}\n\n/** Shared buffer behind a logger and its children. */\nclass Sink {\n private lines: string[] = [];\n\n constructor(private readonly cfg: GodxLoggerConfig) {}\n\n get enabled(): boolean {\n return !!this.cfg.token;\n }\n\n push(line: string): void {\n this.lines.push(line);\n }\n\n get size(): number {\n return this.lines.length;\n }\n\n async flush(): Promise<void> {\n if (!this.enabled || this.lines.length === 0) return;\n const batch = this.lines.splice(0, this.cfg.batchSize ?? 200);\n const url = new URL(`${(this.cfg.endpoint ?? DEFAULT_ENDPOINT).replace(/\\/$/, \"\")}/insert/jsonline`);\n url.search = new URLSearchParams({ _stream_fields: \"app,host\", _time_field: \"_time\", _msg_field: \"_msg\" }).toString();\n try {\n await (this.cfg.fetch ?? fetch)(url, {\n method: \"POST\",\n headers: { Authorization: `Bearer ${this.cfg.token}`, \"Content-Type\": \"application/stream+json\" },\n body: batch.join(\"\\n\") + \"\\n\",\n });\n } catch {\n // A log line is not worth the request.\n }\n if (this.lines.length > 0) await this.flush();\n }\n}\n\nexport class GodxLogger {\n private constructor(\n private readonly sink: Sink,\n private readonly cfg: GodxLoggerConfig,\n private readonly bound: LogFields,\n ) {}\n\n static create(cfg: GodxLoggerConfig): GodxLogger {\n return new GodxLogger(new Sink(cfg), cfg, {});\n }\n\n /** A logger that adds `fields` to every record; shares the parent's buffer. */\n child(fields: LogFields): GodxLogger {\n return new GodxLogger(this.sink, this.cfg, { ...this.bound, ...fields });\n }\n\n debug(message: string, fields?: LogFields): void {\n this.log(\"debug\", message, fields);\n }\n info(message: string, fields?: LogFields): void {\n this.log(\"info\", message, fields);\n }\n warn(message: string, fields?: LogFields): void {\n this.log(\"warn\", message, fields);\n }\n error(message: string, fields?: LogFields | unknown): void {\n const extra = fields instanceof Error || (fields !== undefined && typeof fields !== \"object\")\n ? errorFields(fields)\n : (fields as LogFields | undefined);\n this.log(\"error\", message, extra);\n }\n\n log(level: LogLevel, message: string, fields: LogFields = {}): void {\n if (!this.sink.enabled || RANK[level] < RANK[this.cfg.level ?? \"info\"]) return;\n const { error, ...rest } = fields;\n this.sink.push(\n JSON.stringify({\n _time: new Date().toISOString(),\n _msg: cut(message, MAX_MESSAGE),\n level,\n app: this.cfg.app ?? \"\",\n env: this.cfg.env ?? \"production\",\n host: this.cfg.host ?? \"worker\",\n ...this.bound,\n ...rest,\n ...(error !== undefined ? errorFields(error) : {}),\n }),\n );\n }\n\n /** Ship everything buffered. Never throws. */\n flush(): Promise<void> {\n return this.sink.flush();\n }\n}\n\nexport type GodxLoggerVariables = { log: GodxLogger };\n\ntype LoggerBindings = {\n GODX_LOGGER_TOKEN?: string;\n GODX_LOGGER_APP?: string;\n GODX_LOGGER_LEVEL?: string;\n GODX_LOGGER_URL?: string;\n ENVIRONMENT?: string;\n};\n\nconst isLevel = (v: unknown): v is LogLevel => typeof v === \"string\" && v in RANK;\n\n/**\n * Hono middleware: `c.var.log` for the request, a request line at the end, errors with stacks,\n * shipped via waitUntil. Config comes from the Worker's bindings unless given.\n */\nexport function godxLogger<E extends Env & { Variables: GodxLoggerVariables }>(\n config?: (env: E[\"Bindings\"]) => GodxLoggerConfig,\n): MiddlewareHandler<E> {\n return async (c: Context<E>, next) => {\n const b = (c.env ?? {}) as LoggerBindings;\n const cfg: GodxLoggerConfig = config\n ? config(c.env)\n : {\n token: b.GODX_LOGGER_TOKEN,\n app: b.GODX_LOGGER_APP,\n env: b.ENVIRONMENT,\n level: isLevel(b.GODX_LOGGER_LEVEL) ? b.GODX_LOGGER_LEVEL : undefined,\n endpoint: b.GODX_LOGGER_URL,\n };\n const url = new URL(c.req.url);\n const log = GodxLogger.create(cfg).child({\n method: c.req.method,\n path: url.pathname,\n request_id: c.req.header(\"cf-ray\") ?? crypto.randomUUID(),\n });\n c.set(\"log\", log);\n\n const started = Date.now();\n try {\n await next();\n } catch (error) {\n log.error(\"unhandled error\", { error, status: 500, duration_ms: Date.now() - started });\n throw error;\n } finally {\n const status = c.res?.status ?? 500;\n if (!c.error) {\n log.log(status >= 500 ? \"error\" : status >= 400 ? \"warn\" : \"info\", `${c.req.method} ${url.pathname} ${status}`, {\n status,\n duration_ms: Date.now() - started,\n });\n } else {\n log.error(\"request failed\", { error: c.error, status, duration_ms: Date.now() - started });\n }\n const shipping = log.flush();\n try {\n c.executionCtx.waitUntil(shipping);\n } catch {\n // No execution context (tests, Node): the promise still runs.\n }\n }\n };\n}\n"],"mappings":";AAgBA,IAAM,OAAiC,EAAE,OAAO,IAAI,MAAM,IAAI,MAAM,IAAI,OAAO,GAAG;AAqBlF,IAAM,mBAAmB;AACzB,IAAM,cAAc;AACpB,IAAM,YAAY;AAElB,IAAM,MAAM,CAAC,GAAW,QAAiB,EAAE,SAAS,MAAM,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC,WAAM;AAElF,SAAS,YAAY,OAA2B;AAC9C,MAAI,EAAE,iBAAiB,OAAQ,QAAO,EAAE,OAAO,OAAO,KAAK,EAAE;AAC7D,SAAO;AAAA,IACL,mBAAmB,MAAM;AAAA,IACzB,qBAAqB,IAAI,MAAM,SAAS,WAAW;AAAA,IACnD,mBAAmB,IAAI,MAAM,SAAS,IAAI,SAAS;AAAA,EACrD;AACF;AAGA,IAAM,OAAN,MAAW;AAAA,EAGT,YAA6B,KAAuB;AAAvB;AAAA,EAAwB;AAAA,EAAxB;AAAA,EAFrB,QAAkB,CAAC;AAAA,EAI3B,IAAI,UAAmB;AACrB,WAAO,CAAC,CAAC,KAAK,IAAI;AAAA,EACpB;AAAA,EAEA,KAAK,MAAoB;AACvB,SAAK,MAAM,KAAK,IAAI;AAAA,EACtB;AAAA,EAEA,IAAI,OAAe;AACjB,WAAO,KAAK,MAAM;AAAA,EACpB;AAAA,EAEA,MAAM,QAAuB;AAC3B,QAAI,CAAC,KAAK,WAAW,KAAK,MAAM,WAAW,EAAG;AAC9C,UAAM,QAAQ,KAAK,MAAM,OAAO,GAAG,KAAK,IAAI,aAAa,GAAG;AAC5D,UAAM,MAAM,IAAI,IAAI,IAAI,KAAK,IAAI,YAAY,kBAAkB,QAAQ,OAAO,EAAE,CAAC,kBAAkB;AACnG,QAAI,SAAS,IAAI,gBAAgB,EAAE,gBAAgB,YAAY,aAAa,SAAS,YAAY,OAAO,CAAC,EAAE,SAAS;AACpH,QAAI;AACF,aAAO,KAAK,IAAI,SAAS,OAAO,KAAK;AAAA,QACnC,QAAQ;AAAA,QACR,SAAS,EAAE,eAAe,UAAU,KAAK,IAAI,KAAK,IAAI,gBAAgB,0BAA0B;AAAA,QAChG,MAAM,MAAM,KAAK,IAAI,IAAI;AAAA,MAC3B,CAAC;AAAA,IACH,QAAQ;AAAA,IAER;AACA,QAAI,KAAK,MAAM,SAAS,EAAG,OAAM,KAAK,MAAM;AAAA,EAC9C;AACF;AAEO,IAAM,aAAN,MAAM,YAAW;AAAA,EACd,YACW,MACA,KACA,OACjB;AAHiB;AACA;AACA;AAAA,EAChB;AAAA,EAHgB;AAAA,EACA;AAAA,EACA;AAAA,EAGnB,OAAO,OAAO,KAAmC;AAC/C,WAAO,IAAI,YAAW,IAAI,KAAK,GAAG,GAAG,KAAK,CAAC,CAAC;AAAA,EAC9C;AAAA;AAAA,EAGA,MAAM,QAA+B;AACnC,WAAO,IAAI,YAAW,KAAK,MAAM,KAAK,KAAK,EAAE,GAAG,KAAK,OAAO,GAAG,OAAO,CAAC;AAAA,EACzE;AAAA,EAEA,MAAM,SAAiB,QAA0B;AAC/C,SAAK,IAAI,SAAS,SAAS,MAAM;AAAA,EACnC;AAAA,EACA,KAAK,SAAiB,QAA0B;AAC9C,SAAK,IAAI,QAAQ,SAAS,MAAM;AAAA,EAClC;AAAA,EACA,KAAK,SAAiB,QAA0B;AAC9C,SAAK,IAAI,QAAQ,SAAS,MAAM;AAAA,EAClC;AAAA,EACA,MAAM,SAAiB,QAAoC;AACzD,UAAM,QAAQ,kBAAkB,SAAU,WAAW,UAAa,OAAO,WAAW,WAChF,YAAY,MAAM,IACjB;AACL,SAAK,IAAI,SAAS,SAAS,KAAK;AAAA,EAClC;AAAA,EAEA,IAAI,OAAiB,SAAiB,SAAoB,CAAC,GAAS;AAClE,QAAI,CAAC,KAAK,KAAK,WAAW,KAAK,KAAK,IAAI,KAAK,KAAK,IAAI,SAAS,MAAM,EAAG;AACxE,UAAM,EAAE,OAAO,GAAG,KAAK,IAAI;AAC3B,SAAK,KAAK;AAAA,MACR,KAAK,UAAU;AAAA,QACb,QAAO,oBAAI,KAAK,GAAE,YAAY;AAAA,QAC9B,MAAM,IAAI,SAAS,WAAW;AAAA,QAC9B;AAAA,QACA,KAAK,KAAK,IAAI,OAAO;AAAA,QACrB,KAAK,KAAK,IAAI,OAAO;AAAA,QACrB,MAAM,KAAK,IAAI,QAAQ;AAAA,QACvB,GAAG,KAAK;AAAA,QACR,GAAG;AAAA,QACH,GAAI,UAAU,SAAY,YAAY,KAAK,IAAI,CAAC;AAAA,MAClD,CAAC;AAAA,IACH;AAAA,EACF;AAAA;AAAA,EAGA,QAAuB;AACrB,WAAO,KAAK,KAAK,MAAM;AAAA,EACzB;AACF;AAYA,IAAM,UAAU,CAAC,MAA8B,OAAO,MAAM,YAAY,KAAK;AAMtE,SAAS,WACd,QACsB;AACtB,SAAO,OAAO,GAAe,SAAS;AACpC,UAAM,IAAK,EAAE,OAAO,CAAC;AACrB,UAAM,MAAwB,SAC1B,OAAO,EAAE,GAAG,IACZ;AAAA,MACE,OAAO,EAAE;AAAA,MACT,KAAK,EAAE;AAAA,MACP,KAAK,EAAE;AAAA,MACP,OAAO,QAAQ,EAAE,iBAAiB,IAAI,EAAE,oBAAoB;AAAA,MAC5D,UAAU,EAAE;AAAA,IACd;AACJ,UAAM,MAAM,IAAI,IAAI,EAAE,IAAI,GAAG;AAC7B,UAAM,MAAM,WAAW,OAAO,GAAG,EAAE,MAAM;AAAA,MACvC,QAAQ,EAAE,IAAI;AAAA,MACd,MAAM,IAAI;AAAA,MACV,YAAY,EAAE,IAAI,OAAO,QAAQ,KAAK,OAAO,WAAW;AAAA,IAC1D,CAAC;AACD,MAAE,IAAI,OAAO,GAAG;AAEhB,UAAM,UAAU,KAAK,IAAI;AACzB,QAAI;AACF,YAAM,KAAK;AAAA,IACb,SAAS,OAAO;AACd,UAAI,MAAM,mBAAmB,EAAE,OAAO,QAAQ,KAAK,aAAa,KAAK,IAAI,IAAI,QAAQ,CAAC;AACtF,YAAM;AAAA,IACR,UAAE;AACA,YAAM,SAAS,EAAE,KAAK,UAAU;AAChC,UAAI,CAAC,EAAE,OAAO;AACZ,YAAI,IAAI,UAAU,MAAM,UAAU,UAAU,MAAM,SAAS,QAAQ,GAAG,EAAE,IAAI,MAAM,IAAI,IAAI,QAAQ,IAAI,MAAM,IAAI;AAAA,UAC9G;AAAA,UACA,aAAa,KAAK,IAAI,IAAI;AAAA,QAC5B,CAAC;AAAA,MACH,OAAO;AACL,YAAI,MAAM,kBAAkB,EAAE,OAAO,EAAE,OAAO,QAAQ,aAAa,KAAK,IAAI,IAAI,QAAQ,CAAC;AAAA,MAC3F;AACA,YAAM,WAAW,IAAI,MAAM;AAC3B,UAAI;AACF,UAAE,aAAa,UAAU,QAAQ;AAAA,MACnC,QAAQ;AAAA,MAER;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
package/dist/org.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  GodxHttpError
3
- } from "./chunk-DMXYEWYY.js";
3
+ } from "./chunk-XJOH2N4R.js";
4
4
 
5
5
  // src/org.ts
6
6
  var OrgApiError = class extends GodxHttpError {
package/dist/sync.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  GodxHttpError
3
- } from "./chunk-DMXYEWYY.js";
3
+ } from "./chunk-XJOH2N4R.js";
4
4
 
5
5
  // src/sync.ts
6
6
  var KNOWN_CONTRACT_VERSIONS = ["v1", "v2"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@godxjp/server-sdk",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "GoDX server-side SDK (Workers/Node) — holds service credentials; for browser code use @godxjp/web-sdk. Counterpart of godx-jp/php-sdk.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -62,6 +62,13 @@
62
62
  "worker": "./dist/producer.js",
63
63
  "browser": "./dist/browser-guard.js",
64
64
  "default": "./dist/producer.js"
65
+ },
66
+ "./logger": {
67
+ "types": "./dist/logger.d.ts",
68
+ "workerd": "./dist/logger.js",
69
+ "worker": "./dist/logger.js",
70
+ "browser": "./dist/browser-guard.js",
71
+ "default": "./dist/logger.js"
65
72
  }
66
73
  },
67
74
  "scripts": {
@@ -93,4 +100,4 @@
93
100
  "publishConfig": {
94
101
  "access": "public"
95
102
  }
96
- }
103
+ }
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/http.ts"],"sourcesContent":["/**\n * ./http — endpoint contract from OIDC discovery, the SSO client, and the\n * server-to-server service API. Runtime-agnostic: fetch + WebCrypto only.\n * Mirrors php-sdk's Http module boundary.\n */\nimport { createRemoteJWKSet } from \"jose\";\n\nexport interface IssuerMetadata {\n issuer: string;\n authorization_endpoint: string;\n token_endpoint: string;\n userinfo_endpoint: string;\n jwks_uri: string;\n end_session_endpoint?: string;\n revocation_endpoint?: string;\n}\n\nexport interface Issuer {\n metadata: IssuerMetadata;\n jwks: ReturnType<typeof createRemoteJWKSet>;\n}\n\nexport class GodxHttpError extends Error {\n readonly status: number;\n readonly body: string;\n\n constructor(status: number, body: string, message?: string) {\n super(message ?? `GoDX platform request failed with ${status}`);\n this.name = \"GodxHttpError\";\n this.status = status;\n this.body = body;\n }\n}\n\n// Per-isolate cache; the platform docs require reading discovery, not hardcoding paths.\nconst issuerCache = new Map<string, Promise<Issuer>>();\n\nexport function loadIssuer(issuerUrl: string): Promise<Issuer> {\n let cached = issuerCache.get(issuerUrl);\n if (!cached) {\n cached = (async () => {\n const res = await fetch(new URL(\"/.well-known/openid-configuration\", issuerUrl));\n if (!res.ok) {\n issuerCache.delete(issuerUrl);\n throw new GodxHttpError(res.status, await res.text(), \"OIDC discovery failed\");\n }\n const metadata = (await res.json()) as IssuerMetadata;\n return { metadata, jwks: createRemoteJWKSet(new URL(metadata.jwks_uri)) };\n })();\n issuerCache.set(issuerUrl, cached);\n cached.catch(() => issuerCache.delete(issuerUrl));\n }\n return cached;\n}\n\nexport interface SsoClientConfig {\n issuer: string;\n serviceSlug: string;\n clientId: string;\n /** Omit for a public (secretless) instance — PKCE alone then. */\n clientSecret?: string;\n redirectUri: string;\n}\n\nexport interface TokenResponse {\n access_token: string;\n token_type: string;\n expires_in: number;\n id_token?: string;\n refresh_token?: string;\n scope?: string;\n}\n\nexport interface UserinfoResponse {\n sub: string;\n email?: string;\n email_verified?: boolean;\n name?: string;\n locale?: string;\n timezone?: string;\n}\n\nexport class GodxSsoClient {\n readonly config: SsoClientConfig;\n\n constructor(config: SsoClientConfig) {\n this.config = config;\n }\n\n async authorizeUrl(params: {\n state: string;\n nonce: string;\n codeChallenge: string;\n scope?: string;\n /** console_organization_id — required by the launch gates; usually arrives on the launch URL. */\n organizationContextId?: string;\n }): Promise<string> {\n const { metadata } = await loadIssuer(this.config.issuer);\n const url = new URL(metadata.authorization_endpoint);\n url.search = new URLSearchParams({\n service_slug: this.config.serviceSlug,\n client_id: this.config.clientId,\n redirect_uri: this.config.redirectUri,\n response_type: \"code\",\n scope: params.scope ?? \"openid profile email\",\n state: params.state,\n nonce: params.nonce,\n code_challenge: params.codeChallenge,\n code_challenge_method: \"S256\",\n ...(params.organizationContextId\n ? { organization_context_id: params.organizationContextId }\n : {}),\n }).toString();\n return url.toString();\n }\n\n async exchangeCode(params: { code: string; codeVerifier: string }): Promise<TokenResponse> {\n return this.tokenRequest(\"token_endpoint\", {\n grant_type: \"authorization_code\",\n redirect_uri: this.config.redirectUri,\n code: params.code,\n code_verifier: params.codeVerifier,\n });\n }\n\n async refresh(refreshToken: string): Promise<TokenResponse> {\n const { metadata } = await loadIssuer(this.config.issuer);\n return this.postForm(new URL(\"/api/sso/refresh\", metadata.issuer).toString(), {\n grant_type: \"refresh_token\",\n refresh_token: refreshToken,\n });\n }\n\n /** Best-effort revocation; failures are the caller's decision to ignore. */\n async revoke(token: string): Promise<void> {\n const { metadata } = await loadIssuer(this.config.issuer);\n if (!metadata.revocation_endpoint) return;\n await this.postForm(metadata.revocation_endpoint, { token });\n }\n\n async userinfo(accessToken: string): Promise<UserinfoResponse> {\n const { metadata } = await loadIssuer(this.config.issuer);\n const res = await fetch(metadata.userinfo_endpoint, {\n headers: { Authorization: `Bearer ${accessToken}` },\n });\n if (!res.ok) throw new GodxHttpError(res.status, await res.text(), \"userinfo failed\");\n return (await res.json()) as UserinfoResponse;\n }\n\n private async tokenRequest(\n endpoint: \"token_endpoint\",\n fields: Record<string, string>,\n ): Promise<TokenResponse> {\n const { metadata } = await loadIssuer(this.config.issuer);\n return this.postForm(metadata[endpoint], fields);\n }\n\n private async postForm(url: string, fields: Record<string, string>): Promise<TokenResponse> {\n const body = new URLSearchParams({\n service_slug: this.config.serviceSlug,\n client_id: this.config.clientId,\n ...(this.config.clientSecret ? { client_secret: this.config.clientSecret } : {}),\n ...fields,\n });\n const res = await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body,\n });\n if (!res.ok) throw new GodxHttpError(res.status, await res.text(), \"token request failed\");\n return (await res.json()) as TokenResponse;\n }\n}\n\n/**\n * Server-to-server reads with service credentials (X-Service-Slug/X-Service-Secret).\n * Rate-limited by the platform (429) — cache results and back off; no auto-retry here.\n */\nexport function createServiceApi(config: {\n issuer: string;\n serviceSlug: string;\n serviceSecret: string;\n}) {\n const get = async <T>(path: string, query: Record<string, string>): Promise<T> => {\n const url = new URL(path, config.issuer);\n url.search = new URLSearchParams(query).toString();\n const res = await fetch(url, {\n headers: {\n \"X-Service-Slug\": config.serviceSlug,\n \"X-Service-Secret\": config.serviceSecret,\n },\n });\n if (!res.ok) throw new GodxHttpError(res.status, await res.text(), `service API ${path} failed`);\n return (await res.json()) as T;\n };\n\n return {\n users: (q: { organizationSlug: string; perPage?: number; page?: number }) =>\n get<unknown>(\"/api/sso/service/users\", {\n organization_slug: q.organizationSlug,\n ...(q.perPage ? { per_page: String(q.perPage) } : {}),\n ...(q.page ? { page: String(q.page) } : {}),\n }),\n branches: (q: { organizationSlug: string }) =>\n get<unknown>(\"/api/sso/service/branches\", { organization_slug: q.organizationSlug }),\n brands: (q: { organizationSlug: string }) =>\n get<unknown>(\"/api/sso/service/brands\", { organization_slug: q.organizationSlug }),\n };\n}\n"],"mappings":"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","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/auth.ts"],"sourcesContent":["/**\n * ./auth — verified immutable Principal, PKCE, and sealed session values.\n * Mirrors php-sdk's Auth module: the Principal only comes out of one parse\n * gate (verifyAccessToken) and is frozen after that.\n */\nimport { EncryptJWT, jwtDecrypt, jwtVerify } from \"jose\";\nimport { loadIssuer } from \"./http\";\n\nexport interface Principal {\n readonly sub: string;\n readonly organizationId: string;\n /** Defaults to read_only unless the claim says full — fail closed. */\n readonly accessMode: \"full\" | \"read_only\";\n readonly scopes: readonly string[];\n readonly serviceInstanceId?: string;\n readonly clientId?: string;\n /** Unix seconds, from the access token's exp. */\n readonly expiresAt: number;\n /** From userinfo, when the adapter fetched it at login. */\n readonly name?: string;\n readonly email?: string;\n}\n\n/**\n * The one parse gate. Validates signature (JWKS), iss (discovery issuer,\n * exact), aud (= service slug), exp — then requires organization_id.\n */\nexport async function verifyAccessToken(\n token: string,\n opts: {\n issuer: string;\n serviceSlug: string;\n /** The token audience = your ServiceInstance slug. Defaults to serviceSlug. */\n audience?: string;\n },\n): Promise<Principal> {\n const expectedAudience = opts.audience ?? opts.serviceSlug;\n const { metadata, jwks } = await loadIssuer(opts.issuer);\n let payload;\n try {\n ({ payload } = await jwtVerify(token, jwks, {\n issuer: metadata.issuer,\n audience: expectedAudience,\n }));\n } catch (e) {\n // Say what was expected vs what the token actually carries — a bare\n // \"unexpected claim\" costs whoever reads it a debugging session.\n const actual = decodeUnverified(token);\n throw new Error(\n `${e instanceof Error ? e.message : String(e)} — expected iss=\"${metadata.issuer}\" aud=\"${expectedAudience}\", ` +\n `token carries iss=\"${String(actual?.iss ?? \"?\")}\" aud=${JSON.stringify(actual?.aud ?? \"?\")}`,\n { cause: e },\n );\n }\n\n const organizationId = payload[\"organization_id\"];\n if (typeof organizationId !== \"string\" || organizationId === \"\") {\n throw new Error(\"access token has no organization_id claim\");\n }\n if (typeof payload.sub !== \"string\" || typeof payload.exp !== \"number\") {\n throw new Error(\"access token is missing sub/exp\");\n }\n\n return Object.freeze({\n sub: payload.sub,\n organizationId,\n accessMode: payload[\"organization_access_mode\"] === \"full\" ? \"full\" : \"read_only\",\n scopes: Object.freeze(String(payload[\"scope\"] ?? \"\").split(\" \").filter(Boolean)),\n serviceInstanceId: typeof payload[\"service_instance_id\"] === \"string\" ? payload[\"service_instance_id\"] : undefined,\n clientId: typeof payload[\"client_id\"] === \"string\" ? payload[\"client_id\"] : undefined,\n expiresAt: payload.exp,\n } satisfies Principal);\n}\n\nexport interface Pkce {\n verifier: string;\n challenge: string;\n method: \"S256\";\n}\n\nexport async function createPkce(): Promise<Pkce> {\n const verifier = randomToken(32);\n const digest = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(verifier));\n return { verifier, challenge: base64url(new Uint8Array(digest)), method: \"S256\" };\n}\n\nexport function randomToken(bytes = 16): string {\n const buf = new Uint8Array(bytes);\n crypto.getRandomValues(buf);\n return base64url(buf);\n}\n\n/** Diagnostics only — an UNVERIFIED read of the payload for error messages. */\nfunction decodeUnverified(token: string): Record<string, unknown> | null {\n try {\n const part = token.split(\".\")[1] ?? \"\";\n const pad = part.replace(/-/g, \"+\").replace(/_/g, \"/\");\n return JSON.parse(atob(pad)) as Record<string, unknown>;\n } catch {\n return null;\n }\n}\n\nfunction base64url(bytes: Uint8Array): string {\n let bin = \"\";\n for (const b of bytes) bin += String.fromCharCode(b);\n return btoa(bin).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nasync function sessionKey(secret: string): Promise<Uint8Array> {\n const digest = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(secret));\n return new Uint8Array(digest);\n}\n\n/** Encrypt + sign an arbitrary payload into a compact cookie value (JWE dir/A256GCM). */\nexport async function sealData(\n payload: Record<string, unknown>,\n secret: string,\n ttlSeconds: number,\n): Promise<string> {\n return new EncryptJWT(payload)\n .setProtectedHeader({ alg: \"dir\", enc: \"A256GCM\" })\n .setIssuedAt()\n .setExpirationTime(Math.floor(Date.now() / 1000) + ttlSeconds)\n .encrypt(await sessionKey(secret));\n}\n\n/** Returns null for anything invalid or expired — never throws on bad input. */\nexport async function openData<T = Record<string, unknown>>(\n token: string,\n secret: string,\n): Promise<T | null> {\n try {\n const { payload } = await jwtDecrypt(token, await sessionKey(secret));\n return payload as T;\n } catch {\n return null;\n }\n}\n"],"mappings":"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","names":[]}