@godxjp/server-sdk 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/dist/auth.d.ts +5 -0
- package/dist/auth.js +2 -2
- package/dist/authorization.d.ts +11 -0
- package/dist/authorization.js +5 -4
- package/dist/authorization.js.map +1 -1
- package/dist/{chunk-XJOH2N4R.js → chunk-2N6BMNKT.js} +63 -16
- package/dist/chunk-2N6BMNKT.js.map +1 -0
- package/dist/{chunk-UAVDVHJ3.js → chunk-IKMBLXCC.js} +3 -3
- package/dist/chunk-IKMBLXCC.js.map +1 -0
- package/dist/hono.d.ts +4 -0
- package/dist/hono.js +7 -6
- package/dist/hono.js.map +1 -1
- package/dist/http.d.ts +40 -2
- package/dist/http.js +7 -3
- package/dist/org.d.ts +3 -1
- package/dist/org.js +7 -6
- package/dist/org.js.map +1 -1
- package/dist/sync.d.ts +21 -0
- package/dist/sync.js +5 -4
- package/dist/sync.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-UAVDVHJ3.js.map +0 -1
- package/dist/chunk-XJOH2N4R.js.map +0 -1
package/README.md
CHANGED
|
@@ -133,7 +133,7 @@ Every export per subpath, checked against `src/*.ts`.
|
|
|
133
133
|
| Export | Signature | One line |
|
|
134
134
|
|---|---|---|
|
|
135
135
|
| `EffectiveAccess` | `interface { permissions, roles, serviceAccess, contractVersion?, evaluatedAt?, authoritative }` | The effective-access read model. |
|
|
136
|
-
| `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. |
|
|
136
|
+
| `fetchEffectivePermissions` | `({issuer, accessToken, organizationId, branchId?, http?}) => 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. |
|
|
137
137
|
|
|
138
138
|
### `./http`
|
|
139
139
|
|
|
@@ -141,7 +141,9 @@ Every export per subpath, checked against `src/*.ts`.
|
|
|
141
141
|
|---|---|---|
|
|
142
142
|
| `IssuerMetadata` / `Issuer` | interfaces | OIDC discovery document / metadata + remote JWKS pair. |
|
|
143
143
|
| `GodxHttpError` | `class extends Error { status, body }` | Every non-OK platform answer; base class of `OrgApiError`. |
|
|
144
|
-
| `loadIssuer` | `(issuerUrl) => Promise<Issuer>` | Discovery + JWKS, cached per isolate; a failed load evicts itself from the cache. |
|
|
144
|
+
| `loadIssuer` | `(issuerUrl, http?) => Promise<Issuer>` | Discovery + JWKS, cached per isolate; a failed load evicts itself from the cache. `http.timeoutMs` becomes jose's `timeoutDuration` on every JWKS refresh; the first call per issuer decides the JWKS options. |
|
|
145
|
+
| `HttpOptions` | `interface { headers?: Record<string,string> \| () => Record<string,string>; timeoutMs?: number; fetch?: typeof fetch }` | Accepted as `http` by `GodxSsoClient`, `createServiceApi`, `createOrgApi`, `createSnapshotClient`, `verifyAccessToken`, the Hono config, and per call by `fetchEffectivePermissions`. Send your `X-Request-Id` (a function mints one per call); `timeoutMs` aborts and throws `GodxTimeoutError`; SDK-set headers always win. (0.10.0) |
|
|
146
|
+
| `GodxTimeoutError` | `class extends Error { url, timeoutMs }` | The request did not answer within `timeoutMs`. |
|
|
145
147
|
| `SsoClientConfig` / `TokenResponse` / `UserinfoResponse` | interfaces | Client config and wire shapes. |
|
|
146
148
|
| `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. |
|
|
147
149
|
| `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. |
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { HttpOptions } from './http.js';
|
|
2
|
+
import 'jose';
|
|
3
|
+
|
|
1
4
|
interface Principal {
|
|
2
5
|
readonly sub: string;
|
|
3
6
|
/** Claim organization_id — the organization's INTERNAL id. */
|
|
@@ -29,6 +32,8 @@ declare function verifyAccessToken(token: string, opts: {
|
|
|
29
32
|
serviceSlug: string;
|
|
30
33
|
/** The token audience = your ServiceInstance slug. Defaults to serviceSlug. */
|
|
31
34
|
audience?: string;
|
|
35
|
+
/** Headers, deadline and fetch for discovery and JWKS (first call per issuer decides the JWKS options). */
|
|
36
|
+
http?: HttpOptions;
|
|
32
37
|
}): Promise<Principal>;
|
|
33
38
|
interface Pkce {
|
|
34
39
|
verifier: string;
|
package/dist/auth.js
CHANGED
package/dist/authorization.d.ts
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
import { HttpOptions } from './http.js';
|
|
2
|
+
import 'jose';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ./authorization — effective-access read model. Mirrors php-sdk's
|
|
6
|
+
* Authorization module: results carry the platform's authoritative flag,
|
|
7
|
+
* nothing is cached as a positive grant here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
1
10
|
interface EffectiveAccess {
|
|
2
11
|
permissions: string[];
|
|
3
12
|
roles: string[];
|
|
@@ -15,6 +24,8 @@ declare function fetchEffectivePermissions(params: {
|
|
|
15
24
|
*/
|
|
16
25
|
organizationId: string;
|
|
17
26
|
branchId?: string;
|
|
27
|
+
/** `headers` (send your X-Request-Id), `timeoutMs` (a hung Platform must not hang you), `fetch`. */
|
|
28
|
+
http?: HttpOptions;
|
|
18
29
|
}): Promise<EffectiveAccess>;
|
|
19
30
|
|
|
20
31
|
export { type EffectiveAccess, fetchEffectivePermissions };
|
package/dist/authorization.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
-
GodxHttpError
|
|
3
|
-
|
|
2
|
+
GodxHttpError,
|
|
3
|
+
platformFetch
|
|
4
|
+
} from "./chunk-2N6BMNKT.js";
|
|
4
5
|
|
|
5
6
|
// src/authorization.ts
|
|
6
7
|
async function fetchEffectivePermissions(params) {
|
|
@@ -9,9 +10,9 @@ async function fetchEffectivePermissions(params) {
|
|
|
9
10
|
organization_id: params.organizationId,
|
|
10
11
|
...params.branchId ? { branch_id: params.branchId } : {}
|
|
11
12
|
}).toString();
|
|
12
|
-
const res = await
|
|
13
|
+
const res = await platformFetch(url, {
|
|
13
14
|
headers: { Authorization: `Bearer ${params.accessToken}` }
|
|
14
|
-
});
|
|
15
|
+
}, params.http);
|
|
15
16
|
if (!res.ok) {
|
|
16
17
|
throw new GodxHttpError(res.status, await res.text(), "effective permissions failed");
|
|
17
18
|
}
|
|
@@ -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 /**\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
|
|
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, type HttpOptions, platformFetch } 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 /** `headers` (send your X-Request-Id), `timeoutMs` (a hung Platform must not hang you), `fetch`. */\n http?: HttpOptions;\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 platformFetch(url, {\n headers: { Authorization: `Bearer ${params.accessToken}` },\n }, params.http);\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,QAWnB;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,cAAc,KAAK;AAAA,IACnC,SAAS,EAAE,eAAe,UAAU,OAAO,WAAW,GAAG;AAAA,EAC3D,GAAG,OAAO,IAAI;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,5 +1,41 @@
|
|
|
1
1
|
// src/http.ts
|
|
2
|
-
import { createRemoteJWKSet } from "jose";
|
|
2
|
+
import { createRemoteJWKSet, customFetch } from "jose";
|
|
3
|
+
var GodxTimeoutError = class extends Error {
|
|
4
|
+
url;
|
|
5
|
+
timeoutMs;
|
|
6
|
+
constructor(url, timeoutMs) {
|
|
7
|
+
super(`GoDX platform request to ${url} did not answer within ${timeoutMs} ms`);
|
|
8
|
+
this.name = "GodxTimeoutError";
|
|
9
|
+
this.url = url;
|
|
10
|
+
this.timeoutMs = timeoutMs;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
async function platformFetch(input, init = {}, options = {}) {
|
|
14
|
+
const headers = new Headers(init.headers);
|
|
15
|
+
const extra = typeof options.headers === "function" ? options.headers() : options.headers;
|
|
16
|
+
for (const [name, value] of Object.entries(extra ?? {})) {
|
|
17
|
+
if (!headers.has(name)) headers.set(name, value);
|
|
18
|
+
}
|
|
19
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
20
|
+
if (options.timeoutMs === void 0 || options.timeoutMs <= 0) {
|
|
21
|
+
return doFetch(input, { ...init, headers });
|
|
22
|
+
}
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
let timedOut = false;
|
|
25
|
+
const timer = setTimeout(() => {
|
|
26
|
+
timedOut = true;
|
|
27
|
+
controller.abort();
|
|
28
|
+
}, options.timeoutMs);
|
|
29
|
+
init.signal?.addEventListener("abort", () => controller.abort(), { once: true });
|
|
30
|
+
try {
|
|
31
|
+
return await doFetch(input, { ...init, headers, signal: controller.signal });
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (timedOut) throw new GodxTimeoutError(String(input), options.timeoutMs);
|
|
34
|
+
throw error;
|
|
35
|
+
} finally {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
3
39
|
var GodxHttpError = class extends Error {
|
|
4
40
|
status;
|
|
5
41
|
body;
|
|
@@ -11,17 +47,26 @@ var GodxHttpError = class extends Error {
|
|
|
11
47
|
}
|
|
12
48
|
};
|
|
13
49
|
var issuerCache = /* @__PURE__ */ new Map();
|
|
14
|
-
function loadIssuer(issuerUrl) {
|
|
50
|
+
function loadIssuer(issuerUrl, options = {}) {
|
|
15
51
|
let cached = issuerCache.get(issuerUrl);
|
|
16
52
|
if (!cached) {
|
|
17
53
|
cached = (async () => {
|
|
18
|
-
const res = await
|
|
54
|
+
const res = await platformFetch(new URL("/.well-known/openid-configuration", issuerUrl), {}, options);
|
|
19
55
|
if (!res.ok) {
|
|
20
56
|
issuerCache.delete(issuerUrl);
|
|
21
57
|
throw new GodxHttpError(res.status, await res.text(), "OIDC discovery failed");
|
|
22
58
|
}
|
|
23
59
|
const metadata = await res.json();
|
|
24
|
-
|
|
60
|
+
const jwksHeaders = typeof options.headers === "function" ? options.headers() : options.headers;
|
|
61
|
+
const jwks = createRemoteJWKSet(new URL(metadata.jwks_uri), {
|
|
62
|
+
...options.timeoutMs ? { timeoutDuration: options.timeoutMs } : {},
|
|
63
|
+
...jwksHeaders ? { headers: jwksHeaders } : {},
|
|
64
|
+
// jose keys its fetch override by its OWN exported symbol (a plain Symbol(), not
|
|
65
|
+
// Symbol.for): 0.10.0 passed Symbol.for("jose.fetch") and the JWKS fetch silently ignored
|
|
66
|
+
// `http.fetch` — caught by pages' lead (godx-jp/server-sdk#19).
|
|
67
|
+
...options.fetch ? { [customFetch]: options.fetch } : {}
|
|
68
|
+
});
|
|
69
|
+
return { metadata, jwks };
|
|
25
70
|
})();
|
|
26
71
|
issuerCache.set(issuerUrl, cached);
|
|
27
72
|
cached.catch(() => issuerCache.delete(issuerUrl));
|
|
@@ -34,7 +79,7 @@ var GodxSsoClient = class {
|
|
|
34
79
|
this.config = config;
|
|
35
80
|
}
|
|
36
81
|
async authorizeUrl(params) {
|
|
37
|
-
const { metadata } = await loadIssuer(this.config.issuer);
|
|
82
|
+
const { metadata } = await loadIssuer(this.config.issuer, this.config.http);
|
|
38
83
|
const url = new URL(metadata.authorization_endpoint);
|
|
39
84
|
url.search = new URLSearchParams({
|
|
40
85
|
service_slug: this.config.serviceSlug,
|
|
@@ -59,7 +104,7 @@ var GodxSsoClient = class {
|
|
|
59
104
|
});
|
|
60
105
|
}
|
|
61
106
|
async refresh(refreshToken) {
|
|
62
|
-
const { metadata } = await loadIssuer(this.config.issuer);
|
|
107
|
+
const { metadata } = await loadIssuer(this.config.issuer, this.config.http);
|
|
63
108
|
return this.postForm(new URL("/api/sso/refresh", metadata.issuer).toString(), {
|
|
64
109
|
grant_type: "refresh_token",
|
|
65
110
|
refresh_token: refreshToken,
|
|
@@ -71,20 +116,20 @@ var GodxSsoClient = class {
|
|
|
71
116
|
}
|
|
72
117
|
/** Best-effort revocation; failures are the caller's decision to ignore. */
|
|
73
118
|
async revoke(token) {
|
|
74
|
-
const { metadata } = await loadIssuer(this.config.issuer);
|
|
119
|
+
const { metadata } = await loadIssuer(this.config.issuer, this.config.http);
|
|
75
120
|
if (!metadata.revocation_endpoint) return;
|
|
76
121
|
await this.postForm(metadata.revocation_endpoint, { token });
|
|
77
122
|
}
|
|
78
123
|
async userinfo(accessToken) {
|
|
79
|
-
const { metadata } = await loadIssuer(this.config.issuer);
|
|
80
|
-
const res = await
|
|
124
|
+
const { metadata } = await loadIssuer(this.config.issuer, this.config.http);
|
|
125
|
+
const res = await platformFetch(metadata.userinfo_endpoint, {
|
|
81
126
|
headers: { Authorization: `Bearer ${accessToken}` }
|
|
82
|
-
});
|
|
127
|
+
}, this.config.http);
|
|
83
128
|
if (!res.ok) throw new GodxHttpError(res.status, await res.text(), "userinfo failed");
|
|
84
129
|
return await res.json();
|
|
85
130
|
}
|
|
86
131
|
async tokenRequest(endpoint, fields) {
|
|
87
|
-
const { metadata } = await loadIssuer(this.config.issuer);
|
|
132
|
+
const { metadata } = await loadIssuer(this.config.issuer, this.config.http);
|
|
88
133
|
return this.postForm(metadata[endpoint], fields);
|
|
89
134
|
}
|
|
90
135
|
async postForm(url, fields) {
|
|
@@ -94,11 +139,11 @@ var GodxSsoClient = class {
|
|
|
94
139
|
...this.config.clientSecret ? { client_secret: this.config.clientSecret } : {},
|
|
95
140
|
...fields
|
|
96
141
|
});
|
|
97
|
-
const res = await
|
|
142
|
+
const res = await platformFetch(url, {
|
|
98
143
|
method: "POST",
|
|
99
144
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
100
145
|
body
|
|
101
|
-
});
|
|
146
|
+
}, this.config.http);
|
|
102
147
|
if (!res.ok) throw new GodxHttpError(res.status, await res.text(), "token request failed");
|
|
103
148
|
return await res.json();
|
|
104
149
|
}
|
|
@@ -107,12 +152,12 @@ function createServiceApi(config) {
|
|
|
107
152
|
const get = async (path, query) => {
|
|
108
153
|
const url = new URL(path, config.issuer);
|
|
109
154
|
url.search = new URLSearchParams(query).toString();
|
|
110
|
-
const res = await
|
|
155
|
+
const res = await platformFetch(url, {
|
|
111
156
|
headers: {
|
|
112
157
|
"X-Service-Slug": config.serviceSlug,
|
|
113
158
|
"X-Service-Secret": config.serviceSecret
|
|
114
159
|
}
|
|
115
|
-
});
|
|
160
|
+
}, config.http);
|
|
116
161
|
if (!res.ok) throw new GodxHttpError(res.status, await res.text(), `service API ${path} failed`);
|
|
117
162
|
return await res.json();
|
|
118
163
|
};
|
|
@@ -128,9 +173,11 @@ function createServiceApi(config) {
|
|
|
128
173
|
}
|
|
129
174
|
|
|
130
175
|
export {
|
|
176
|
+
GodxTimeoutError,
|
|
177
|
+
platformFetch,
|
|
131
178
|
GodxHttpError,
|
|
132
179
|
loadIssuer,
|
|
133
180
|
GodxSsoClient,
|
|
134
181
|
createServiceApi
|
|
135
182
|
};
|
|
136
|
-
//# sourceMappingURL=chunk-
|
|
183
|
+
//# sourceMappingURL=chunk-2N6BMNKT.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, customFetch } from \"jose\";\n\n/**\n * Per-call knobs for every outbound request this SDK makes (godx-jp/server-sdk#17, from the\n * pages.godx.jp incident of 2026-10-04 where a Platform call hung 25–180 s with no request id\n * to find it by). Pass them on the client/config (`http`) or per call where a function takes\n * them; defaults are exactly the old behaviour.\n */\nexport interface HttpOptions {\n /**\n * Extra headers on every request — `X-Request-Id` is the one that makes a hang findable in\n * Platform's log. A function is called per request (mint a fresh id each time); a header the\n * SDK itself sets (Authorization, Content-Type, X-Service-*) always wins.\n */\n headers?: Record<string, string> | (() => Record<string, string>);\n /** Abort a request after this many milliseconds and throw {@link GodxTimeoutError}. Unset = the runtime's default (often none). */\n timeoutMs?: number;\n /** The fetch to use (tests, custom dispatchers). Default `globalThis.fetch`, read at call time. */\n fetch?: typeof fetch;\n}\n\n/** The request did not answer within `timeoutMs`; `url` and `timeoutMs` say which and how long. */\nexport class GodxTimeoutError extends Error {\n readonly url: string;\n readonly timeoutMs: number;\n constructor(url: string, timeoutMs: number) {\n super(`GoDX platform request to ${url} did not answer within ${timeoutMs} ms`);\n this.name = \"GodxTimeoutError\";\n this.url = url;\n this.timeoutMs = timeoutMs;\n }\n}\n\n/**\n * `fetch` with {@link HttpOptions} applied: merged headers, a deadline, an injectable fetch.\n * Every outbound call in this package goes through here, so one option reaches all of them.\n */\nexport async function platformFetch(\n input: URL | string,\n init: RequestInit = {},\n options: HttpOptions = {},\n): Promise<Response> {\n const headers = new Headers(init.headers);\n const extra = typeof options.headers === \"function\" ? options.headers() : options.headers;\n for (const [name, value] of Object.entries(extra ?? {})) {\n if (!headers.has(name)) headers.set(name, value);\n }\n const doFetch = options.fetch ?? globalThis.fetch;\n if (options.timeoutMs === undefined || options.timeoutMs <= 0) {\n return doFetch(input, { ...init, headers });\n }\n const controller = new AbortController();\n let timedOut = false;\n const timer = setTimeout(() => {\n timedOut = true;\n controller.abort();\n }, options.timeoutMs);\n init.signal?.addEventListener(\"abort\", () => controller.abort(), { once: true });\n try {\n return await doFetch(input, { ...init, headers, signal: controller.signal });\n } catch (error) {\n if (timedOut) throw new GodxTimeoutError(String(input), options.timeoutMs);\n throw error;\n } finally {\n clearTimeout(timer);\n }\n}\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\n/**\n * Discovery + JWKS, cached per isolate and issuer. `options` applies to the discovery request and\n * to every JWKS refresh of the cached set (`timeoutMs` → jose `timeoutDuration`; static headers\n * only — a header FUNCTION cannot run inside jose's refresh, so it is evaluated once here). The\n * first call for an issuer decides the JWKS options; a failed load evicts itself.\n */\nexport function loadIssuer(issuerUrl: string, options: HttpOptions = {}): Promise<Issuer> {\n let cached = issuerCache.get(issuerUrl);\n if (!cached) {\n cached = (async () => {\n const res = await platformFetch(new URL(\"/.well-known/openid-configuration\", issuerUrl), {}, options);\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 const jwksHeaders = typeof options.headers === \"function\" ? options.headers() : options.headers;\n const jwks = createRemoteJWKSet(new URL(metadata.jwks_uri), {\n ...(options.timeoutMs ? { timeoutDuration: options.timeoutMs } : {}),\n ...(jwksHeaders ? { headers: jwksHeaders } : {}),\n // jose keys its fetch override by its OWN exported symbol (a plain Symbol(), not\n // Symbol.for): 0.10.0 passed Symbol.for(\"jose.fetch\") and the JWKS fetch silently ignored\n // `http.fetch` — caught by pages' lead (godx-jp/server-sdk#19).\n ...(options.fetch ? { [customFetch]: options.fetch } : {}),\n });\n return { metadata, jwks };\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 /** Headers, deadline and fetch for every request this client makes. */\n http?: HttpOptions;\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, this.config.http);\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, this.config.http);\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, this.config.http);\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, this.config.http);\n const res = await platformFetch(metadata.userinfo_endpoint, {\n headers: { Authorization: `Bearer ${accessToken}` },\n }, this.config.http);\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, this.config.http);\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 platformFetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body,\n }, this.config.http);\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 http?: HttpOptions;\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 platformFetch(url, {\n headers: {\n \"X-Service-Slug\": config.serviceSlug,\n \"X-Service-Secret\": config.serviceSecret,\n },\n }, config.http);\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,12 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
loadIssuer
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-2N6BMNKT.js";
|
|
4
4
|
|
|
5
5
|
// src/auth.ts
|
|
6
6
|
import { EncryptJWT, jwtDecrypt, jwtVerify } from "jose";
|
|
7
7
|
async function verifyAccessToken(token, opts) {
|
|
8
8
|
const expectedAudience = opts.audience ?? opts.serviceSlug;
|
|
9
|
-
const { metadata, jwks } = await loadIssuer(opts.issuer);
|
|
9
|
+
const { metadata, jwks } = await loadIssuer(opts.issuer, opts.http);
|
|
10
10
|
let payload;
|
|
11
11
|
try {
|
|
12
12
|
({ payload } = await jwtVerify(token, jwks, {
|
|
@@ -85,4 +85,4 @@ export {
|
|
|
85
85
|
sealData,
|
|
86
86
|
openData
|
|
87
87
|
};
|
|
88
|
-
//# sourceMappingURL=chunk-
|
|
88
|
+
//# sourceMappingURL=chunk-IKMBLXCC.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 { type HttpOptions, 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 /** Headers, deadline and fetch for discovery and JWKS (first call per issuer decides the JWKS options). */\n http?: HttpOptions;\n },\n): Promise<Principal> {\n const expectedAudience = opts.audience ?? opts.serviceSlug;\n const { metadata, jwks } = await loadIssuer(opts.issuer, opts.http);\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":[]}
|
package/dist/hono.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as hono_types from 'hono/types';
|
|
2
2
|
import { Env, Hono, MiddlewareHandler } from 'hono';
|
|
3
3
|
import { Principal } from './auth.js';
|
|
4
|
+
import { HttpOptions } from './http.js';
|
|
5
|
+
import 'jose';
|
|
4
6
|
|
|
5
7
|
interface GodxSsoConfig {
|
|
6
8
|
issuer: string;
|
|
@@ -27,6 +29,8 @@ interface GodxSsoConfig {
|
|
|
27
29
|
* Useful in local dev; leave unset in production multi-org setups.
|
|
28
30
|
*/
|
|
29
31
|
defaultOrganizationContextId?: string;
|
|
32
|
+
/** Headers, deadline and fetch for every Platform call this middleware makes (discovery, JWKS, tokens, userinfo, Dock proxy). */
|
|
33
|
+
http?: HttpOptions;
|
|
30
34
|
}
|
|
31
35
|
/**
|
|
32
36
|
* `return_to` for `/login`: only a same-origin path ("/mail?x=1") is kept — anything that a
|
package/dist/hono.js
CHANGED
|
@@ -4,11 +4,12 @@ import {
|
|
|
4
4
|
randomToken,
|
|
5
5
|
sealData,
|
|
6
6
|
verifyAccessToken
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-IKMBLXCC.js";
|
|
8
8
|
import {
|
|
9
9
|
GodxSsoClient,
|
|
10
|
-
loadIssuer
|
|
11
|
-
|
|
10
|
+
loadIssuer,
|
|
11
|
+
platformFetch
|
|
12
|
+
} from "./chunk-2N6BMNKT.js";
|
|
12
13
|
|
|
13
14
|
// src/hono.ts
|
|
14
15
|
import { Hono } from "hono";
|
|
@@ -149,7 +150,7 @@ function godxSso(getConfig) {
|
|
|
149
150
|
});
|
|
150
151
|
}
|
|
151
152
|
}
|
|
152
|
-
const { metadata } = await loadIssuer(config.issuer);
|
|
153
|
+
const { metadata } = await loadIssuer(config.issuer, config.http);
|
|
153
154
|
return c.redirect(metadata.end_session_endpoint ?? "/");
|
|
154
155
|
});
|
|
155
156
|
const requireAuth = async (c, next) => {
|
|
@@ -167,7 +168,7 @@ function godxSso(getConfig) {
|
|
|
167
168
|
const url = new URL(c.req.url);
|
|
168
169
|
const target = new URL(url.pathname + url.search, config.issuer);
|
|
169
170
|
const method = c.req.method;
|
|
170
|
-
const res = await
|
|
171
|
+
const res = await platformFetch(target, {
|
|
171
172
|
method,
|
|
172
173
|
headers: {
|
|
173
174
|
Authorization: `Bearer ${c.get("accessToken")}`,
|
|
@@ -175,7 +176,7 @@ function godxSso(getConfig) {
|
|
|
175
176
|
"Content-Type": c.req.header("Content-Type") ?? "application/json"
|
|
176
177
|
},
|
|
177
178
|
body: method === "GET" || method === "HEAD" ? void 0 : await c.req.arrayBuffer()
|
|
178
|
-
});
|
|
179
|
+
}, config.http);
|
|
179
180
|
const headers = new Headers({ "Content-Type": res.headers.get("Content-Type") ?? "application/json" });
|
|
180
181
|
const requestId = res.headers.get("X-Request-Id");
|
|
181
182
|
if (requestId) headers.set("X-Request-Id", requestId);
|
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 /**\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\";\n\n/**\n * `return_to` for `/login`: only a same-origin path (\"/mail?x=1\") is kept — anything that a\n * browser could resolve to another origin (\"//evil\", \"/\\\\evil\", \"https://…\", control\n * characters) is dropped, so the login flow can never become an open redirect.\n */\nexport function safeReturnTo(raw: string | undefined | null): string | undefined {\n if (!raw || raw.length > 2048 || !/^\\/(?![/\\\\])/.test(raw) || /[\\u0000-\\u001f\\u007f\\\\]/.test(raw)) {\n return undefined;\n }\n const base = \"https://return-to.invalid\";\n const url = new URL(raw, base);\n return url.origin === base ? url.pathname + url.search + url.hash : undefined;\n}\n\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 // Where to land after the callback (an expired session re-signs in on the page it was on).\n const returnTo = safeReturnTo(c.req.query(\"return_to\"));\n setCookie(c, TXN_COOKIE, await sealData({ v: pkce.verifier, s: state, r: returnTo }, 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; r?: 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(safeReturnTo(txn.r) ?? \"/\");\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":";;;;;;;;;;;;;AAKA,SAAS,YAAY;AAErB,SAAS,cAAc,WAAW,iBAAiB;AAmCnD,IAAM,gBAAgB;AACtB,IAAM,0BAA0B,KAAK,KAAK;AAE1C,IAAM,aAAa;AAOZ,SAAS,aAAa,KAAoD;AAC/E,MAAI,CAAC,OAAO,IAAI,SAAS,QAAQ,CAAC,eAAe,KAAK,GAAG,KAAK,0BAA0B,KAAK,GAAG,GAAG;AACjG,WAAO;AAAA,EACT;AACA,QAAM,OAAO;AACb,QAAM,MAAM,IAAI,IAAI,KAAK,IAAI;AAC7B,SAAO,IAAI,WAAW,OAAO,IAAI,WAAW,IAAI,SAAS,IAAI,OAAO;AACtE;AAEA,IAAM,iBAAiB;AAIhB,SAAS,QACd,WACA;AACA,QAAM,SAAS,CAAC,MAAkB,IAAI,cAAc,UAAU,EAAE,GAAG,CAAC;AACpE,QAAM,MAAM,CAAC,MAAkB,UAAU,EAAE,GAAG;AAC9C,QAAM,SAAS,CAAC,MAAkB,EAAE,IAAI,IAAI,WAAW,QAAQ;AAO/D,QAAM,eAAe,OAAO,GAAe,SAAsB;AAC/D,UAAM,SAAS,IAAI,CAAC;AACpB,UAAM,MAAM,KAAK,KACZ,OAAO,iBAAiB,0BACzB,KAAK,IAAI,GAAG,KAAK,EAAE,YAAY,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI,CAAC;AAChE,cAAU,GAAG,gBAAgB,MAAM,SAAS,MAAM,OAAO,eAAe,GAAG,GAAG;AAAA,MAC5E,UAAU;AAAA,MACV,QAAQ,OAAO,CAAC;AAAA,MAChB,UAAU;AAAA,MACV,MAAM;AAAA,MACN,QAAQ;AAAA,IACV,CAAC;AAAA,EACH;AAEA,QAAM,SAAS,IAAI,KAAQ;AAE3B,SAAO,IAAI,UAAU,OAAO,MAAM;AAChC,UAAM,SAAS,IAAI,CAAC;AACpB,UAAM,OAAO,MAAM,WAAW;AAC9B,UAAM,QAAQ,YAAY;AAC1B,UAAM,QAAQ,YAAY;AAG1B,UAAM,wBACJ,EAAE,IAAI,MAAM,yBAAyB,KAAK,OAAO;AAEnD,UAAM,WAAW,aAAa,EAAE,IAAI,MAAM,WAAW,CAAC;AACtD,cAAU,GAAG,YAAY,MAAM,SAAS,EAAE,GAAG,KAAK,UAAU,GAAG,OAAO,GAAG,SAAS,GAAG,OAAO,eAAe,GAAG,GAAG;AAAA,MAC/G,UAAU;AAAA,MACV,QAAQ,OAAO,CAAC;AAAA,MAChB,UAAU;AAAA,MACV,MAAM;AAAA,MACN,QAAQ;AAAA,IACV,CAAC;AACD,WAAO,EAAE;AAAA,MACP,MAAM,OAAO,CAAC,EAAE,aAAa;AAAA,QAC3B;AAAA,QACA;AAAA,QACA,eAAe,KAAK;AAAA,QACpB,OAAO,OAAO,SAAS;AAAA,QACvB;AAAA,MACF,CAAC;AAAA,IACH;AAAA,EACF,CAAC;AAID,QAAM,eAAe,CAAC,GAAe,OAAe,gBAClD,EAAE;AAAA,IACA,eAAe,mBAAmB,KAAK,CAAC,0BAA0B,mBAAmB,WAAW,CAAC;AAAA,EACnG;AAEF,SAAO,IAAI,aAAa,OAAO,MAAM;AACnC,UAAM,SAAS,IAAI,CAAC;AAIpB,UAAM,gBAAgB,EAAE,IAAI,MAAM,OAAO;AACzC,QAAI,eAAe;AACjB,aAAO;AAAA,QACL;AAAA,QACA;AAAA,QACA,EAAE,IAAI,MAAM,mBAAmB,KAAK,uFAAiB,aAAa;AAAA,MACpE;AAAA,IACF;AAEA,UAAM,OAAO,EAAE,IAAI,MAAM,MAAM;AAC/B,UAAM,QAAQ,EAAE,IAAI,MAAM,OAAO;AACjC,UAAM,YAAY,UAAU,GAAG,UAAU;AACzC,iBAAa,GAAG,YAAY,EAAE,MAAM,IAAI,CAAC;AAEzC,UAAM,MAAM,YAAY,MAAM,SAA+C,WAAW,OAAO,aAAa,IAAI;AAChH,QAAI,CAAC,QAAQ,CAAC,SAAS,CAAC,OAAO,IAAI,MAAM,OAAO;AAC9C,aAAO;AAAA,QACL;AAAA,QACA;AAAA,QACA;AAAA,MACF;AAAA,IACF;AAEA,QAAI;AACJ,QAAI;AACF,eAAS,MAAM,OAAO,CAAC,EAAE,aAAa,EAAE,MAAM,cAAc,IAAI,EAAE,CAAC;AAAA,IACrE,SAAS,GAAG;AACV,aAAO;AAAA,QACL;AAAA,QACA;AAAA,QACA,uTAAwD,aAAa,QAAQ,EAAE,UAAU,OAAO,CAAC,CAAC;AAAA,MACpG;AAAA,IACF;AAEA,QAAI;AACJ,QAAI;AACF,iBAAW,MAAM,kBAAkB,OAAO,cAAc,MAAM;AAAA,IAChE,SAAS,GAAG;AACV,aAAO;AAAA,QACL;AAAA,QACA;AAAA,QACA,4OAA6D,aAAa,QAAQ,EAAE,UAAU,OAAO,CAAC,CAAC;AAAA,MACzG;AAAA,IACF;AAGA,QAAI,YAAY;AAChB,QAAI;AACF,YAAM,OAAO,MAAM,OAAO,CAAC,EAAE,SAAS,OAAO,YAAY;AACzD,kBAAY,OAAO,OAAO,EAAE,GAAG,UAAU,MAAM,KAAK,MAAM,OAAO,KAAK,MAAM,CAAC;AAAA,IAC/E,QAAQ;AAAA,IAER;AAEA,UAAM,aAAa,GAAG,EAAE,GAAG,WAAW,IAAI,OAAO,cAAc,IAAI,OAAO,cAAc,CAAC;AACzF,WAAO,EAAE,SAAS,aAAa,IAAI,CAAC,KAAK,GAAG;AAAA,EAC9C,CAAC;AAYD,SAAO,KAAK,YAAY,OAAO,MAAM;AACnC,UAAM,SAAS,IAAI,CAAC;AACpB,UAAM,MAAM,UAAU,GAAG,cAAc;AACvC,UAAM,UAAU,MAAM,MAAM,SAAsB,KAAK,OAAO,aAAa,IAAI;AAC/E,QAAI,CAAC,SAAS,GAAI,QAAO,EAAE,KAAK,EAAE,OAAO,mBAAmB,aAAa,MAAM,GAAG,GAAG;AACrF,QAAI;AACF,YAAM,SAAS,MAAM,OAAO,CAAC,EAAE,QAAQ,QAAQ,EAAE;AACjD,YAAM,WAAW,MAAM,kBAAkB,OAAO,cAAc,MAAM;AAEpE,YAAM,YAAY,OAAO,OAAO,EAAE,GAAG,UAAU,MAAM,QAAQ,EAAE,MAAM,OAAO,QAAQ,EAAE,MAAM,CAAC;AAC7F,YAAM,aAAa,GAAG,EAAE,GAAG,WAAW,IAAI,OAAO,cAAc,IAAI,OAAO,iBAAiB,QAAQ,GAAG,CAAC;AACvG,aAAO,EAAE,KAAK,MAAM,GAAG;AAAA,IACzB,SAAS,GAAG;AAEV,cAAQ,KAAK,4BAA4B,aAAa,QAAQ,EAAE,UAAU,OAAO,CAAC,CAAC;AACnF,mBAAa,GAAG,gBAAgB,EAAE,MAAM,IAAI,CAAC;AAC7C,aAAO,EAAE,KAAK,EAAE,OAAO,mBAAmB,aAAa,MAAM,GAAG,GAAG;AAAA,IACrE;AAAA,EACF,CAAC;AAED,SAAO,IAAI,WAAW,OAAO,MAAM;AACjC,UAAM,SAAS,IAAI,CAAC;AACpB,UAAM,MAAM,UAAU,GAAG,cAAc;AACvC,iBAAa,GAAG,gBAAgB,EAAE,MAAM,IAAI,CAAC;AAC7C,QAAI,KAAK;AACP,YAAM,UAAU,MAAM,SAAsB,KAAK,OAAO,aAAa;AACrE,UAAI,SAAS;AACX,cAAM,OAAO,CAAC,EAAE,OAAO,QAAQ,EAAE,EAAE,MAAM,MAAM;AAAA,QAAC,CAAC;AAEjD,YAAI,QAAQ,GAAI,OAAM,OAAO,CAAC,EAAE,OAAO,QAAQ,EAAE,EAAE,MAAM,MAAM;AAAA,QAAC,CAAC;AAAA,MACnE;AAAA,IACF;AACA,UAAM,EAAE,SAAS,IAAI,MAAM,WAAW,OAAO,MAAM;AACnD,WAAO,EAAE,SAAS,SAAS,wBAAwB,GAAG;AAAA,EACxD,CAAC;AAED,QAAM,cAAoC,OAAO,GAAG,SAAS;AAC3D,UAAM,MAAM,UAAU,GAAG,cAAc;AACvC,UAAM,UAAU,MAAM,MAAM,SAAsB,KAAK,IAAI,CAAC,EAAE,aAAa,IAAI;AAC/E,QAAI,CAAC,WAAW,QAAQ,EAAE,aAAa,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI,GAAG;AAEpE,aAAO,EAAE,KAAK,EAAE,OAAO,mBAAmB,aAAa,CAAC,CAAC,SAAS,GAAG,GAAG,GAAG;AAAA,IAC7E;AACA,MAAE,IAAI,aAAa,QAAQ,CAAC;AAC5B,MAAE,IAAI,eAAe,QAAQ,EAAE;AAC/B,UAAM,KAAK;AAAA,EACb;AAQA,QAAM,YAAkC,OAAO,MAAM;AACnD,UAAM,SAAS,IAAI,CAAC;AACpB,UAAM,MAAM,IAAI,IAAI,EAAE,IAAI,GAAG;AAC7B,UAAM,SAAS,IAAI,IAAI,IAAI,WAAW,IAAI,QAAQ,OAAO,MAAM;AAC/D,UAAM,SAAS,EAAE,IAAI;AACrB,UAAM,MAAM,MAAM,MAAM,QAAQ;AAAA,MAC9B;AAAA,MACA,SAAS;AAAA,QACP,eAAe,UAAU,EAAE,IAAI,aAAa,CAAC;AAAA,QAC7C,QAAQ;AAAA,QACR,gBAAgB,EAAE,IAAI,OAAO,cAAc,KAAK;AAAA,MAClD;AAAA,MACA,MAAM,WAAW,SAAS,WAAW,SAAS,SAAY,MAAM,EAAE,IAAI,YAAY;AAAA,IACpF,CAAC;AACD,UAAM,UAAU,IAAI,QAAQ,EAAE,gBAAgB,IAAI,QAAQ,IAAI,cAAc,KAAK,mBAAmB,CAAC;AACrG,UAAM,YAAY,IAAI,QAAQ,IAAI,cAAc;AAChD,QAAI,UAAW,SAAQ,IAAI,gBAAgB,SAAS;AACpD,WAAO,IAAI,SAAS,IAAI,MAAM,EAAE,QAAQ,IAAI,QAAQ,QAAQ,CAAC;AAAA,EAC/D;AAGA,QAAM,kBAAwC,OAAO,GAAG,SAAS;AAC/D,UAAM,OAAO,EAAE,IAAI,WAAW,SAAS,EAAE,IAAI,WAAW,UAAU,EAAE,IAAI,WAAW;AACnF,QAAI,CAAC,QAAQ,EAAE,IAAI,WAAW,GAAG,eAAe,QAAQ;AACtD,aAAO,EAAE,KAAK,EAAE,OAAO,4BAA4B,GAAG,GAAG;AAAA,IAC3D;AACA,UAAM,KAAK;AAAA,EACb;AAEA,SAAO,EAAE,QAAQ,aAAa,iBAAiB,UAAU;AAC3D;","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, type HttpOptions, platformFetch } 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 /** Headers, deadline and fetch for every Platform call this middleware makes (discovery, JWKS, tokens, userinfo, Dock proxy). */\n http?: HttpOptions;\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\";\n\n/**\n * `return_to` for `/login`: only a same-origin path (\"/mail?x=1\") is kept — anything that a\n * browser could resolve to another origin (\"//evil\", \"/\\\\evil\", \"https://…\", control\n * characters) is dropped, so the login flow can never become an open redirect.\n */\nexport function safeReturnTo(raw: string | undefined | null): string | undefined {\n if (!raw || raw.length > 2048 || !/^\\/(?![/\\\\])/.test(raw) || /[\\u0000-\\u001f\\u007f\\\\]/.test(raw)) {\n return undefined;\n }\n const base = \"https://return-to.invalid\";\n const url = new URL(raw, base);\n return url.origin === base ? url.pathname + url.search + url.hash : undefined;\n}\n\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 // Where to land after the callback (an expired session re-signs in on the page it was on).\n const returnTo = safeReturnTo(c.req.query(\"return_to\"));\n setCookie(c, TXN_COOKIE, await sealData({ v: pkce.verifier, s: state, r: returnTo }, 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; r?: 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(safeReturnTo(txn.r) ?? \"/\");\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, config.http);\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 platformFetch(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 }, config.http);\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
|
@@ -6,6 +6,35 @@ import { createRemoteJWKSet } from 'jose';
|
|
|
6
6
|
* Mirrors php-sdk's Http module boundary.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Per-call knobs for every outbound request this SDK makes (godx-jp/server-sdk#17, from the
|
|
11
|
+
* pages.godx.jp incident of 2026-10-04 where a Platform call hung 25–180 s with no request id
|
|
12
|
+
* to find it by). Pass them on the client/config (`http`) or per call where a function takes
|
|
13
|
+
* them; defaults are exactly the old behaviour.
|
|
14
|
+
*/
|
|
15
|
+
interface HttpOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Extra headers on every request — `X-Request-Id` is the one that makes a hang findable in
|
|
18
|
+
* Platform's log. A function is called per request (mint a fresh id each time); a header the
|
|
19
|
+
* SDK itself sets (Authorization, Content-Type, X-Service-*) always wins.
|
|
20
|
+
*/
|
|
21
|
+
headers?: Record<string, string> | (() => Record<string, string>);
|
|
22
|
+
/** Abort a request after this many milliseconds and throw {@link GodxTimeoutError}. Unset = the runtime's default (often none). */
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
/** The fetch to use (tests, custom dispatchers). Default `globalThis.fetch`, read at call time. */
|
|
25
|
+
fetch?: typeof fetch;
|
|
26
|
+
}
|
|
27
|
+
/** The request did not answer within `timeoutMs`; `url` and `timeoutMs` say which and how long. */
|
|
28
|
+
declare class GodxTimeoutError extends Error {
|
|
29
|
+
readonly url: string;
|
|
30
|
+
readonly timeoutMs: number;
|
|
31
|
+
constructor(url: string, timeoutMs: number);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* `fetch` with {@link HttpOptions} applied: merged headers, a deadline, an injectable fetch.
|
|
35
|
+
* Every outbound call in this package goes through here, so one option reaches all of them.
|
|
36
|
+
*/
|
|
37
|
+
declare function platformFetch(input: URL | string, init?: RequestInit, options?: HttpOptions): Promise<Response>;
|
|
9
38
|
interface IssuerMetadata {
|
|
10
39
|
issuer: string;
|
|
11
40
|
authorization_endpoint: string;
|
|
@@ -24,7 +53,13 @@ declare class GodxHttpError extends Error {
|
|
|
24
53
|
readonly body: string;
|
|
25
54
|
constructor(status: number, body: string, message?: string);
|
|
26
55
|
}
|
|
27
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Discovery + JWKS, cached per isolate and issuer. `options` applies to the discovery request and
|
|
58
|
+
* to every JWKS refresh of the cached set (`timeoutMs` → jose `timeoutDuration`; static headers
|
|
59
|
+
* only — a header FUNCTION cannot run inside jose's refresh, so it is evaluated once here). The
|
|
60
|
+
* first call for an issuer decides the JWKS options; a failed load evicts itself.
|
|
61
|
+
*/
|
|
62
|
+
declare function loadIssuer(issuerUrl: string, options?: HttpOptions): Promise<Issuer>;
|
|
28
63
|
interface SsoClientConfig {
|
|
29
64
|
issuer: string;
|
|
30
65
|
serviceSlug: string;
|
|
@@ -34,6 +69,8 @@ interface SsoClientConfig {
|
|
|
34
69
|
redirectUri: string;
|
|
35
70
|
/** Token audience = the ServiceInstance slug (defaults to serviceSlug). */
|
|
36
71
|
audience?: string;
|
|
72
|
+
/** Headers, deadline and fetch for every request this client makes. */
|
|
73
|
+
http?: HttpOptions;
|
|
37
74
|
}
|
|
38
75
|
interface TokenResponse {
|
|
39
76
|
access_token: string;
|
|
@@ -81,6 +118,7 @@ declare function createServiceApi(config: {
|
|
|
81
118
|
issuer: string;
|
|
82
119
|
serviceSlug: string;
|
|
83
120
|
serviceSecret: string;
|
|
121
|
+
http?: HttpOptions;
|
|
84
122
|
}): {
|
|
85
123
|
users: (q: {
|
|
86
124
|
organizationSlug: string;
|
|
@@ -95,4 +133,4 @@ declare function createServiceApi(config: {
|
|
|
95
133
|
}) => Promise<unknown>;
|
|
96
134
|
};
|
|
97
135
|
|
|
98
|
-
export { GodxHttpError, GodxSsoClient, type Issuer, type IssuerMetadata, type SsoClientConfig, type TokenResponse, type UserinfoResponse, createServiceApi, loadIssuer };
|
|
136
|
+
export { GodxHttpError, GodxSsoClient, GodxTimeoutError, type HttpOptions, type Issuer, type IssuerMetadata, type SsoClientConfig, type TokenResponse, type UserinfoResponse, createServiceApi, loadIssuer, platformFetch };
|
package/dist/http.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import {
|
|
2
2
|
GodxHttpError,
|
|
3
3
|
GodxSsoClient,
|
|
4
|
+
GodxTimeoutError,
|
|
4
5
|
createServiceApi,
|
|
5
|
-
loadIssuer
|
|
6
|
-
|
|
6
|
+
loadIssuer,
|
|
7
|
+
platformFetch
|
|
8
|
+
} from "./chunk-2N6BMNKT.js";
|
|
7
9
|
export {
|
|
8
10
|
GodxHttpError,
|
|
9
11
|
GodxSsoClient,
|
|
12
|
+
GodxTimeoutError,
|
|
10
13
|
createServiceApi,
|
|
11
|
-
loadIssuer
|
|
14
|
+
loadIssuer,
|
|
15
|
+
platformFetch
|
|
12
16
|
};
|
|
13
17
|
//# sourceMappingURL=http.js.map
|
package/dist/org.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { GodxHttpError } from './http.js';
|
|
1
|
+
import { HttpOptions, GodxHttpError } from './http.js';
|
|
2
2
|
import 'jose';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -35,6 +35,8 @@ interface OrgApiConfig {
|
|
|
35
35
|
issuer: string;
|
|
36
36
|
/** Bearer access token whose aud includes {issuer}/api/org/v1. */
|
|
37
37
|
accessToken: string;
|
|
38
|
+
/** Headers, deadline and fetch for every org/v1 call. */
|
|
39
|
+
http?: HttpOptions;
|
|
38
40
|
}
|
|
39
41
|
interface PageMeta {
|
|
40
42
|
current_page: number;
|
package/dist/org.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
-
GodxHttpError
|
|
3
|
-
|
|
2
|
+
GodxHttpError,
|
|
3
|
+
platformFetch
|
|
4
|
+
} from "./chunk-2N6BMNKT.js";
|
|
4
5
|
|
|
5
6
|
// src/org.ts
|
|
6
7
|
var OrgApiError = class extends GodxHttpError {
|
|
@@ -39,13 +40,13 @@ function createOrgApi(config) {
|
|
|
39
40
|
const get = async (path, query) => {
|
|
40
41
|
const url = new URL(base + path);
|
|
41
42
|
if (query) url.search = new URLSearchParams(query).toString();
|
|
42
|
-
const response = await
|
|
43
|
+
const response = await platformFetch(url, {
|
|
43
44
|
headers: { Authorization: `Bearer ${config.accessToken}`, Accept: "application/json" }
|
|
44
|
-
});
|
|
45
|
+
}, config.http);
|
|
45
46
|
return unwrap(response, `GET ${path}`);
|
|
46
47
|
};
|
|
47
48
|
const post = async (path, payload, idempotencyKey) => {
|
|
48
|
-
const response = await
|
|
49
|
+
const response = await platformFetch(base + path, {
|
|
49
50
|
method: "POST",
|
|
50
51
|
headers: {
|
|
51
52
|
Authorization: `Bearer ${config.accessToken}`,
|
|
@@ -54,7 +55,7 @@ function createOrgApi(config) {
|
|
|
54
55
|
"Idempotency-Key": idempotencyKey ?? crypto.randomUUID()
|
|
55
56
|
},
|
|
56
57
|
body: JSON.stringify(payload)
|
|
57
|
-
});
|
|
58
|
+
}, config.http);
|
|
58
59
|
return unwrap(response, `POST ${path}`);
|
|
59
60
|
};
|
|
60
61
|
async function* paginate(path) {
|
package/dist/org.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/org.ts"],"sourcesContent":["/**\n * ./org — typed client for the Delegated Organization API (`/api/org/v1/*`),\n * the platform's write-back surface: the ONLY sanctioned way a service changes\n * platform-owned directory data. The identity feed then carries the change to\n * every consumer (the feed itself is one-way; see ./sync).\n *\n * Auth model, exactly as the platform enforces it (routes/api/org.php):\n * - Bearer access token minted FOR the `organization-api` recipient resource\n * (`aud` contains `{issuer}/api/org/v1`) by a `user`-lane principal.\n * - Tenancy is the token's organization, re-derived server-side per request —\n * this client never sends an organization id.\n * - Each route needs its `org.{resource}:{read|write}` scope; a missing scope\n * answers 403 `insufficient_scope` with a WWW-Authenticate header (RFC 6750),\n * which this client surfaces verbatim.\n *\n * NOTE (2026-09): the platform grants that audience on the MINI-APP lane only\n * (`TokenIssuer::miniAppResourceAudiences`). A downstream service-launch token\n * does not carry it yet — godx-jp/id has the proposal to widen that lane.\n *\n * International-standard behaviours built in:\n * - `Idempotency-Key` (IETF draft-ietf-httpapi-idempotency-key-header) is sent\n * on every write — generated when not supplied. The org/v1 surface does not\n * *honor* it yet (the admin surface does); sending it is forward-compatible\n * and costs nothing.\n * - 401/403 surface the `WWW-Authenticate` challenge; 429 surfaces\n * `Retry-After`; validation errors (422) surface the per-field messages.\n * - Page-based pagination is wrapped in an async iterator so callers cannot\n * forget page 2.\n */\nimport { GodxHttpError } from \"./http\";\n\nexport interface OrgApiConfig {\n issuer: string;\n /** Bearer access token whose aud includes {issuer}/api/org/v1. */\n accessToken: string;\n}\n\nexport interface PageMeta {\n current_page: number;\n last_page: number;\n per_page: number;\n total: number;\n}\n\nexport interface Page<T> {\n data: T[];\n meta: PageMeta;\n}\n\nexport interface OrgEmployee {\n id: string;\n employee_code: string;\n full_name: string;\n email?: string | null;\n department_id?: string | null;\n branch_id?: string | null;\n user_id?: string | null;\n [key: string]: unknown;\n}\n\nexport interface OrgDepartment {\n id: string;\n code?: string | null;\n name: string;\n [key: string]: unknown;\n}\n\nexport interface OrgBranch {\n id: string;\n code?: string | null;\n name: string;\n [key: string]: unknown;\n}\n\nexport interface OrgTeam {\n id: string;\n name: string;\n [key: string]: unknown;\n}\n\nexport interface OrgMe {\n [key: string]: unknown;\n}\n\n/** Notification categories — the platform's `NotificationCenter::CATEGORIES`, verbatim. */\nexport type NotificationCategory =\n | \"organization_invitation\"\n | \"access_request\"\n | \"service_provisioning\"\n | \"billing\"\n | \"security\"\n | \"operator\"\n | \"service\";\n\n/** `ja` is the required base locale; `en`/`vi` optional. Title ≤200 chars, body ≤2000. */\nexport interface LocalizedText {\n ja: string;\n en?: string;\n vi?: string;\n}\n\nexport interface CreateNotificationInput {\n /** Console user ids — 1..100 per call. Every recipient must belong to the org AND hold access to this service. */\n recipients: string[];\n category: NotificationCategory;\n /** Producer-side reference (≤191) — also the platform's per-instance dedup key: same source_ref updates instead of duplicating. */\n source_ref: string;\n /** Absolute URL (≤2000) that must start with the service's base URL — the platform refuses anything else. */\n action_url: string;\n title: LocalizedText;\n body: LocalizedText;\n}\n\n/** The platform answers 201 when anything was created, 200 otherwise (updated/skipped only). */\nexport interface CreateNotificationResult {\n created: { id: string; user_id: string }[];\n updated: { id: string; user_id: string }[];\n /** e.g. reason \"preference_off\" — the recipient turned this category off. */\n skipped: { user_id: string; reason: string }[];\n}\n\nexport class OrgApiError extends GodxHttpError {\n /** RFC 6750 challenge, when the platform sent one (401/403). */\n readonly wwwAuthenticate: string | null;\n /** Seconds to wait, when the platform answered 429 with Retry-After. */\n readonly retryAfterSeconds: number | null;\n /** Laravel validation messages per field, when the answer was 422. */\n readonly validationErrors: Record<string, string[]> | null;\n\n constructor(status: number, body: string, message: string, headers: Headers) {\n super(status, body, message);\n this.name = \"OrgApiError\";\n this.wwwAuthenticate = headers.get(\"WWW-Authenticate\");\n const retryAfter = headers.get(\"Retry-After\");\n this.retryAfterSeconds = retryAfter !== null && /^\\d+$/.test(retryAfter) ? Number(retryAfter) : null;\n let validation: Record<string, string[]> | null = null;\n try {\n const parsed = JSON.parse(body) as { errors?: Record<string, string[]> };\n if (parsed && typeof parsed === \"object\" && parsed.errors) validation = parsed.errors;\n } catch {\n /* body was not JSON — nothing more to surface */\n }\n this.validationErrors = validation;\n }\n}\n\nasync function unwrap<T>(response: Response, what: string): Promise<T> {\n if (!response.ok) {\n const body = await response.text();\n const challenge = response.headers.get(\"WWW-Authenticate\");\n const hint =\n response.status === 401\n ? `the token is missing, expired, or was not minted for the organization-api audience${challenge ? ` — ${challenge}` : \"\"}`\n : response.status === 403\n ? `the token lacks the route's org.* scope${challenge ? ` — ${challenge}` : \"\"}`\n : response.status === 422\n ? \"validation refused the payload (see validationErrors)\"\n : response.status === 429\n ? \"rate limited — honor retryAfterSeconds\"\n : \"see the response body\";\n throw new OrgApiError(response.status, body, `${what} answered ${response.status}: ${hint}`, response.headers);\n }\n return (await response.json()) as T;\n}\n\nexport function createOrgApi(config: OrgApiConfig) {\n const base = config.issuer.replace(/\\/$/, \"\") + \"/api/org/v1\";\n\n const get = async <T>(path: string, query?: Record<string, string>): Promise<T> => {\n const url = new URL(base + path);\n if (query) url.search = new URLSearchParams(query).toString();\n const response = await fetch(url, {\n headers: { Authorization: `Bearer ${config.accessToken}`, Accept: \"application/json\" },\n });\n return unwrap<T>(response, `GET ${path}`);\n };\n\n const post = async <T>(path: string, payload: Record<string, unknown>, idempotencyKey?: string): Promise<T> => {\n const response = await fetch(base + path, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${config.accessToken}`,\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\",\n \"Idempotency-Key\": idempotencyKey ?? crypto.randomUUID(),\n },\n body: JSON.stringify(payload),\n });\n return unwrap<T>(response, `POST ${path}`);\n };\n\n /** Follow page-based pagination to the end, yielding every row once. */\n async function* paginate<T>(path: string): AsyncGenerator<T, void, void> {\n let page = 1;\n for (;;) {\n const result = await get<Page<T>>(path, { page: String(page) });\n for (const row of result.data) yield row;\n if (result.meta.current_page >= result.meta.last_page) return;\n page = result.meta.current_page + 1;\n }\n }\n\n return {\n /** GET /me — scope org:profile. */\n me: () => get<OrgMe>(\"/me\"),\n\n employees: {\n list: (page?: number) => get<Page<OrgEmployee>>(\"/employees\", page ? { page: String(page) } : undefined),\n all: () => paginate<OrgEmployee>(\"/employees\"),\n /** scope org.employees:write */\n create: (payload: Record<string, unknown>, idempotencyKey?: string) =>\n post<{ data: OrgEmployee }>(\"/employees\", payload, idempotencyKey),\n /** Who this user reports to — rides org.employees:read. */\n managerOf: (consoleUserId: string) => get<{ data: unknown }>(`/users/${encodeURIComponent(consoleUserId)}/manager`),\n },\n\n departments: {\n list: (page?: number) => get<Page<OrgDepartment>>(\"/departments\", page ? { page: String(page) } : undefined),\n all: () => paginate<OrgDepartment>(\"/departments\"),\n create: (payload: Record<string, unknown>, idempotencyKey?: string) =>\n post<{ data: OrgDepartment }>(\"/departments\", payload, idempotencyKey),\n },\n\n branches: {\n list: (page?: number) => get<Page<OrgBranch>>(\"/branches\", page ? { page: String(page) } : undefined),\n all: () => paginate<OrgBranch>(\"/branches\"),\n create: (payload: Record<string, unknown>, idempotencyKey?: string) =>\n post<{ data: OrgBranch }>(\"/branches\", payload, idempotencyKey),\n },\n\n teams: {\n list: (page?: number) => get<Page<OrgTeam>>(\"/teams\", page ? { page: String(page) } : undefined),\n all: () => paginate<OrgTeam>(\"/teams\"),\n create: (payload: Record<string, unknown>, idempotencyKey?: string) =>\n post<{ data: OrgTeam }>(\"/teams\", payload, idempotencyKey),\n },\n\n notifications: {\n /** scope org.notifications:write — send in-app notifications to members who use this service (godx-jp/id#1962). */\n create: (input: CreateNotificationInput, options?: { idempotencyKey?: string }) =>\n post<{ data: CreateNotificationResult }>(\"/notifications\", { ...input }, options?.idempotencyKey),\n },\n };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/org.ts"],"sourcesContent":["/**\n * ./org — typed client for the Delegated Organization API (`/api/org/v1/*`),\n * the platform's write-back surface: the ONLY sanctioned way a service changes\n * platform-owned directory data. The identity feed then carries the change to\n * every consumer (the feed itself is one-way; see ./sync).\n *\n * Auth model, exactly as the platform enforces it (routes/api/org.php):\n * - Bearer access token minted FOR the `organization-api` recipient resource\n * (`aud` contains `{issuer}/api/org/v1`) by a `user`-lane principal.\n * - Tenancy is the token's organization, re-derived server-side per request —\n * this client never sends an organization id.\n * - Each route needs its `org.{resource}:{read|write}` scope; a missing scope\n * answers 403 `insufficient_scope` with a WWW-Authenticate header (RFC 6750),\n * which this client surfaces verbatim.\n *\n * NOTE (2026-09): the platform grants that audience on the MINI-APP lane only\n * (`TokenIssuer::miniAppResourceAudiences`). A downstream service-launch token\n * does not carry it yet — godx-jp/id has the proposal to widen that lane.\n *\n * International-standard behaviours built in:\n * - `Idempotency-Key` (IETF draft-ietf-httpapi-idempotency-key-header) is sent\n * on every write — generated when not supplied. The org/v1 surface does not\n * *honor* it yet (the admin surface does); sending it is forward-compatible\n * and costs nothing.\n * - 401/403 surface the `WWW-Authenticate` challenge; 429 surfaces\n * `Retry-After`; validation errors (422) surface the per-field messages.\n * - Page-based pagination is wrapped in an async iterator so callers cannot\n * forget page 2.\n */\nimport { GodxHttpError, type HttpOptions, platformFetch } from \"./http\";\n\nexport interface OrgApiConfig {\n issuer: string;\n /** Bearer access token whose aud includes {issuer}/api/org/v1. */\n accessToken: string;\n /** Headers, deadline and fetch for every org/v1 call. */\n http?: HttpOptions;\n}\n\nexport interface PageMeta {\n current_page: number;\n last_page: number;\n per_page: number;\n total: number;\n}\n\nexport interface Page<T> {\n data: T[];\n meta: PageMeta;\n}\n\nexport interface OrgEmployee {\n id: string;\n employee_code: string;\n full_name: string;\n email?: string | null;\n department_id?: string | null;\n branch_id?: string | null;\n user_id?: string | null;\n [key: string]: unknown;\n}\n\nexport interface OrgDepartment {\n id: string;\n code?: string | null;\n name: string;\n [key: string]: unknown;\n}\n\nexport interface OrgBranch {\n id: string;\n code?: string | null;\n name: string;\n [key: string]: unknown;\n}\n\nexport interface OrgTeam {\n id: string;\n name: string;\n [key: string]: unknown;\n}\n\nexport interface OrgMe {\n [key: string]: unknown;\n}\n\n/** Notification categories — the platform's `NotificationCenter::CATEGORIES`, verbatim. */\nexport type NotificationCategory =\n | \"organization_invitation\"\n | \"access_request\"\n | \"service_provisioning\"\n | \"billing\"\n | \"security\"\n | \"operator\"\n | \"service\";\n\n/** `ja` is the required base locale; `en`/`vi` optional. Title ≤200 chars, body ≤2000. */\nexport interface LocalizedText {\n ja: string;\n en?: string;\n vi?: string;\n}\n\nexport interface CreateNotificationInput {\n /** Console user ids — 1..100 per call. Every recipient must belong to the org AND hold access to this service. */\n recipients: string[];\n category: NotificationCategory;\n /** Producer-side reference (≤191) — also the platform's per-instance dedup key: same source_ref updates instead of duplicating. */\n source_ref: string;\n /** Absolute URL (≤2000) that must start with the service's base URL — the platform refuses anything else. */\n action_url: string;\n title: LocalizedText;\n body: LocalizedText;\n}\n\n/** The platform answers 201 when anything was created, 200 otherwise (updated/skipped only). */\nexport interface CreateNotificationResult {\n created: { id: string; user_id: string }[];\n updated: { id: string; user_id: string }[];\n /** e.g. reason \"preference_off\" — the recipient turned this category off. */\n skipped: { user_id: string; reason: string }[];\n}\n\nexport class OrgApiError extends GodxHttpError {\n /** RFC 6750 challenge, when the platform sent one (401/403). */\n readonly wwwAuthenticate: string | null;\n /** Seconds to wait, when the platform answered 429 with Retry-After. */\n readonly retryAfterSeconds: number | null;\n /** Laravel validation messages per field, when the answer was 422. */\n readonly validationErrors: Record<string, string[]> | null;\n\n constructor(status: number, body: string, message: string, headers: Headers) {\n super(status, body, message);\n this.name = \"OrgApiError\";\n this.wwwAuthenticate = headers.get(\"WWW-Authenticate\");\n const retryAfter = headers.get(\"Retry-After\");\n this.retryAfterSeconds = retryAfter !== null && /^\\d+$/.test(retryAfter) ? Number(retryAfter) : null;\n let validation: Record<string, string[]> | null = null;\n try {\n const parsed = JSON.parse(body) as { errors?: Record<string, string[]> };\n if (parsed && typeof parsed === \"object\" && parsed.errors) validation = parsed.errors;\n } catch {\n /* body was not JSON — nothing more to surface */\n }\n this.validationErrors = validation;\n }\n}\n\nasync function unwrap<T>(response: Response, what: string): Promise<T> {\n if (!response.ok) {\n const body = await response.text();\n const challenge = response.headers.get(\"WWW-Authenticate\");\n const hint =\n response.status === 401\n ? `the token is missing, expired, or was not minted for the organization-api audience${challenge ? ` — ${challenge}` : \"\"}`\n : response.status === 403\n ? `the token lacks the route's org.* scope${challenge ? ` — ${challenge}` : \"\"}`\n : response.status === 422\n ? \"validation refused the payload (see validationErrors)\"\n : response.status === 429\n ? \"rate limited — honor retryAfterSeconds\"\n : \"see the response body\";\n throw new OrgApiError(response.status, body, `${what} answered ${response.status}: ${hint}`, response.headers);\n }\n return (await response.json()) as T;\n}\n\nexport function createOrgApi(config: OrgApiConfig) {\n const base = config.issuer.replace(/\\/$/, \"\") + \"/api/org/v1\";\n\n const get = async <T>(path: string, query?: Record<string, string>): Promise<T> => {\n const url = new URL(base + path);\n if (query) url.search = new URLSearchParams(query).toString();\n const response = await platformFetch(url, {\n headers: { Authorization: `Bearer ${config.accessToken}`, Accept: \"application/json\" },\n }, config.http);\n return unwrap<T>(response, `GET ${path}`);\n };\n\n const post = async <T>(path: string, payload: Record<string, unknown>, idempotencyKey?: string): Promise<T> => {\n const response = await platformFetch(base + path, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${config.accessToken}`,\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\",\n \"Idempotency-Key\": idempotencyKey ?? crypto.randomUUID(),\n },\n body: JSON.stringify(payload),\n }, config.http);\n return unwrap<T>(response, `POST ${path}`);\n };\n\n /** Follow page-based pagination to the end, yielding every row once. */\n async function* paginate<T>(path: string): AsyncGenerator<T, void, void> {\n let page = 1;\n for (;;) {\n const result = await get<Page<T>>(path, { page: String(page) });\n for (const row of result.data) yield row;\n if (result.meta.current_page >= result.meta.last_page) return;\n page = result.meta.current_page + 1;\n }\n }\n\n return {\n /** GET /me — scope org:profile. */\n me: () => get<OrgMe>(\"/me\"),\n\n employees: {\n list: (page?: number) => get<Page<OrgEmployee>>(\"/employees\", page ? { page: String(page) } : undefined),\n all: () => paginate<OrgEmployee>(\"/employees\"),\n /** scope org.employees:write */\n create: (payload: Record<string, unknown>, idempotencyKey?: string) =>\n post<{ data: OrgEmployee }>(\"/employees\", payload, idempotencyKey),\n /** Who this user reports to — rides org.employees:read. */\n managerOf: (consoleUserId: string) => get<{ data: unknown }>(`/users/${encodeURIComponent(consoleUserId)}/manager`),\n },\n\n departments: {\n list: (page?: number) => get<Page<OrgDepartment>>(\"/departments\", page ? { page: String(page) } : undefined),\n all: () => paginate<OrgDepartment>(\"/departments\"),\n create: (payload: Record<string, unknown>, idempotencyKey?: string) =>\n post<{ data: OrgDepartment }>(\"/departments\", payload, idempotencyKey),\n },\n\n branches: {\n list: (page?: number) => get<Page<OrgBranch>>(\"/branches\", page ? { page: String(page) } : undefined),\n all: () => paginate<OrgBranch>(\"/branches\"),\n create: (payload: Record<string, unknown>, idempotencyKey?: string) =>\n post<{ data: OrgBranch }>(\"/branches\", payload, idempotencyKey),\n },\n\n teams: {\n list: (page?: number) => get<Page<OrgTeam>>(\"/teams\", page ? { page: String(page) } : undefined),\n all: () => paginate<OrgTeam>(\"/teams\"),\n create: (payload: Record<string, unknown>, idempotencyKey?: string) =>\n post<{ data: OrgTeam }>(\"/teams\", payload, idempotencyKey),\n },\n\n notifications: {\n /** scope org.notifications:write — send in-app notifications to members who use this service (godx-jp/id#1962). */\n create: (input: CreateNotificationInput, options?: { idempotencyKey?: string }) =>\n post<{ data: CreateNotificationResult }>(\"/notifications\", { ...input }, options?.idempotencyKey),\n },\n };\n}\n"],"mappings":"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","names":[]}
|
package/dist/sync.d.ts
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
import { HttpOptions } from './http.js';
|
|
2
|
+
import 'jose';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ./sync — the identity-feed contracts (docs/reference/identity-feed-protocol.md,
|
|
6
|
+
* godx-jp/id, contract v2), mirrored from php-sdk's Sync module boundary.
|
|
7
|
+
*
|
|
8
|
+
* What ships today is the HTTP leg — snapshot reconciliation — because it is
|
|
9
|
+
* the one a Cloudflare Worker can run (a JetStream durable needs a long-lived
|
|
10
|
+
* process; run that on a Node host and feed the same Projector). The envelope
|
|
11
|
+
* types and the Transport/Inbox seams are here so both legs share one shape.
|
|
12
|
+
*
|
|
13
|
+
* Contract rules encoded below, in the contract's own words:
|
|
14
|
+
* - "A consumer that does not recognise contract_version must stop, not guess."
|
|
15
|
+
* - Snapshot first, THEN resume the stream from `sequence` — never the reverse.
|
|
16
|
+
* - `data` is a full mirrored snapshot, not a diff; absent ≠ null.
|
|
17
|
+
* - CloudEvents `id` is the idempotency key; `sequence` is the ordering fence.
|
|
18
|
+
*/
|
|
19
|
+
|
|
1
20
|
type IdentityFamily = "organization" | "brand" | "branch" | "user" | "employee" | "service_user_access" | "org_service_role" | "role_assignment" | "organization_member" | "team" | "team_member" | "service_team_access";
|
|
2
21
|
declare const KNOWN_CONTRACT_VERSIONS: readonly ["v1", "v2"];
|
|
3
22
|
/** CloudEvents 1.0 envelope as the relay publishes it (contract §4). */
|
|
@@ -171,6 +190,8 @@ declare function createSnapshotClient(config: {
|
|
|
171
190
|
issuer: string;
|
|
172
191
|
serviceSlug: string;
|
|
173
192
|
serviceSecret: string;
|
|
193
|
+
/** Headers, deadline and fetch for every snapshot page. */
|
|
194
|
+
http?: HttpOptions;
|
|
174
195
|
}): {
|
|
175
196
|
fetchPage: (family: IdentityFamily, opts?: SnapshotTenantOptions & {
|
|
176
197
|
cursor?: string;
|
package/dist/sync.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
-
GodxHttpError
|
|
3
|
-
|
|
2
|
+
GodxHttpError,
|
|
3
|
+
platformFetch
|
|
4
|
+
} from "./chunk-2N6BMNKT.js";
|
|
4
5
|
|
|
5
6
|
// src/sync.ts
|
|
6
7
|
var KNOWN_CONTRACT_VERSIONS = ["v1", "v2"];
|
|
@@ -175,13 +176,13 @@ function createSnapshotClient(config) {
|
|
|
175
176
|
if (opts?.limit) url.searchParams.set("limit", String(opts.limit));
|
|
176
177
|
if (opts?.organizationId) url.searchParams.set("organization_id", opts.organizationId);
|
|
177
178
|
if (opts?.organizationSlug) url.searchParams.set("organization_slug", opts.organizationSlug);
|
|
178
|
-
const res = await
|
|
179
|
+
const res = await platformFetch(url, {
|
|
179
180
|
headers: {
|
|
180
181
|
"X-Service-Slug": config.serviceSlug,
|
|
181
182
|
"X-Service-Secret": config.serviceSecret,
|
|
182
183
|
Accept: "application/json"
|
|
183
184
|
}
|
|
184
|
-
});
|
|
185
|
+
}, config.http);
|
|
185
186
|
if (!res.ok) {
|
|
186
187
|
const body = await res.text();
|
|
187
188
|
const hint = res.status === 410 ? "the cursor's watermark aged out of the stream \u2014 restart the pass from the beginning (no cursor)." : res.status === 400 ? "the cursor is malformed or belongs to another family \u2014 cursors are opaque, echo next_cursor verbatim." : res.status === 403 ? "the credential lacks service_api_enabled, or the family is not snapshot-able for it." : "check X-Service-Slug/X-Service-Secret and the platform logs (X-Request-Id).";
|
package/dist/sync.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/sync.ts"],"sourcesContent":["/**\n * ./sync — the identity-feed contracts (docs/reference/identity-feed-protocol.md,\n * godx-jp/id, contract v2), mirrored from php-sdk's Sync module boundary.\n *\n * What ships today is the HTTP leg — snapshot reconciliation — because it is\n * the one a Cloudflare Worker can run (a JetStream durable needs a long-lived\n * process; run that on a Node host and feed the same Projector). The envelope\n * types and the Transport/Inbox seams are here so both legs share one shape.\n *\n * Contract rules encoded below, in the contract's own words:\n * - \"A consumer that does not recognise contract_version must stop, not guess.\"\n * - Snapshot first, THEN resume the stream from `sequence` — never the reverse.\n * - `data` is a full mirrored snapshot, not a diff; absent ≠ null.\n * - CloudEvents `id` is the idempotency key; `sequence` is the ordering fence.\n */\nimport { GodxHttpError } from \"./http\";\n\nexport type IdentityFamily =\n | \"organization\"\n | \"brand\"\n | \"branch\"\n | \"user\"\n | \"employee\"\n | \"service_user_access\"\n | \"org_service_role\"\n | \"role_assignment\"\n | \"organization_member\"\n | \"team\"\n | \"team_member\"\n | \"service_team_access\";\n\nexport const KNOWN_CONTRACT_VERSIONS = [\"v1\", \"v2\"] as const;\n\n/** CloudEvents 1.0 envelope as the relay publishes it (contract §4). */\nexport interface IdentityEnvelope {\n specversion: \"1.0\";\n /** UUIDv4 — THE idempotency key (same across multi-tenant fan-out copies). */\n id: string;\n /** Platform origin — pin per environment and verify. */\n source: string;\n /** jp.godx.identity.{family}.{action} */\n type: string;\n /** {family}/{id-the-consumer-holds} — exactly one \"/\", both halves filled. */\n subject: string;\n time: string;\n datacontenttype: \"application/json\";\n /** Globally monotonic ordering fence (identity_outbox.sequence). */\n sequence: number;\n data: Record<string, unknown>;\n}\n\nexport interface ParsedSubject {\n family: IdentityFamily | (string & {});\n id: string;\n}\n\nexport class SyncContractError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"SyncContractError\";\n }\n}\n\n/** Split a CloudEvents/snapshot `subject`. Rejects anything the contract rejects. */\nexport function parseSubject(subject: string): ParsedSubject {\n const parts = subject.split(\"/\");\n if (parts.length !== 2 || parts[0] === \"\" || parts[1] === \"\") {\n throw new SyncContractError(\n `identity feed subject \"${subject}\" is not \"{family}/{id}\" — exactly one \"/\" with both halves filled.`,\n );\n }\n return { family: parts[0], id: parts[1] };\n}\n\n/** Validate a raw payload into an IdentityEnvelope, with expected-vs-actual errors. */\nexport function parseEnvelope(raw: unknown, opts?: { expectedSource?: string }): IdentityEnvelope {\n const e = raw as Partial<IdentityEnvelope> | null;\n if (!e || typeof e !== \"object\") throw new SyncContractError(\"envelope is not an object\");\n if (e.specversion !== \"1.0\") {\n throw new SyncContractError(`envelope specversion is ${JSON.stringify(e.specversion)}, expected \"1.0\" — rejected outright, as the php-sdk does.`);\n }\n if (typeof e.id !== \"string\" || e.id === \"\") throw new SyncContractError(\"envelope has no CloudEvents id (the idempotency key)\");\n if (typeof e.type !== \"string\" || !e.type.startsWith(\"jp.godx.identity.\")) {\n throw new SyncContractError(`envelope type ${JSON.stringify(e.type)} does not start with \"jp.godx.identity.\"`);\n }\n if (typeof e.sequence !== \"number\") throw new SyncContractError(\"envelope has no numeric sequence (the ordering fence)\");\n if (typeof e.subject !== \"string\") throw new SyncContractError(\"envelope has no subject\");\n parseSubject(e.subject);\n if (opts?.expectedSource !== undefined && e.source !== opts.expectedSource) {\n throw new SyncContractError(\n `envelope source is ${JSON.stringify(e.source)}, expected ${JSON.stringify(opts.expectedSource)} — pin the platform origin per environment.`,\n );\n }\n return e as IdentityEnvelope;\n}\n\n/**\n * What a consumer implements to hold a projection — one code path for\n * snapshot rows and stream events, exactly as the contract intends\n * (\"rows[].subject is byte-identical to the CloudEvents subject\").\n */\nexport interface Projector {\n /** Upsert the full mirrored payload for this subject (data is never a diff). */\n apply(subject: ParsedSubject, data: Record<string, unknown>, context: ProjectorContext): Promise<void>;\n}\n\nexport interface ProjectorContext {\n organizationId: string;\n /** The watermark this row is consistent up to. */\n sequence: number;\n}\n\n/** The stream-leg seam (JetStream durable on a Node host). Not implemented here yet. */\nexport interface Transport {\n pull(handler: (envelope: IdentityEnvelope) => Promise<void>): Promise<void>;\n}\n\n/** Dedupe seam keyed on the CloudEvents id — the consumer's job, per contract §4. */\nexport interface Inbox {\n /** true when this id was already applied — skip it. */\n seen(eventId: string): Promise<boolean>;\n record(eventId: string, sequence: number): Promise<void>;\n}\n\n/**\n * Which (source, organization) pairs this consumer accepts — php-sdk's\n * TenantPolicy. Deny is a `terminated` outcome, never an exception: an event\n * for a tenant that is not yours is noise, not an incident.\n */\nexport interface TenantPolicy {\n allows(source: string, organizationId: string | null): boolean;\n}\n\nexport const acceptTenants = (source: string, organizationIds: readonly string[]): TenantPolicy => ({\n allows: (s, org) => s === source && (org === null || organizationIds.includes(org)),\n});\n\n/**\n * What happened to one delivery — mirrors php-sdk's Outcome and the contract's\n * nak-vs-term rule. The HTTP mapping (see handleIngestRequest) is:\n * applied/skipped_* → 2xx (ack) · retry → 5xx (nak, redeliver) ·\n * terminated → 200 with the outcome named (term: redelivery cannot help).\n */\nexport type Outcome =\n | \"applied\"\n | \"skipped_duplicate\"\n | \"skipped_stale\"\n | \"terminated_unknown_type\"\n | \"terminated_tenant_refused\"\n | \"retry\";\n\nexport interface Delivery {\n outcome: Outcome;\n eventId: string;\n /** Present on terminated/retry — the human-readable why. */\n reason?: string;\n}\n\n/**\n * The one pipeline both legs feed: validate → dedupe → tenant gate → project\n * → record. A snapshot pass uses the Projector directly; every EVENT goes\n * through here.\n */\nexport function createProcessor(deps: {\n projector: Projector;\n inbox: Inbox;\n tenantPolicy?: TenantPolicy;\n /** Families this consumer mirrors; events for others are terminated (contract §3 nak-vs-term). */\n families?: readonly IdentityFamily[];\n}) {\n return async (envelope: IdentityEnvelope): Promise<Delivery> => {\n const { id } = envelope;\n let subject: ParsedSubject;\n try {\n subject = parseSubject(envelope.subject);\n } catch (e) {\n return { outcome: \"terminated_unknown_type\", eventId: id, reason: (e as Error).message };\n }\n\n if (deps.families && !deps.families.includes(subject.family as IdentityFamily)) {\n // Unknown family → term, not nak: redelivering cannot make us know it.\n return {\n outcome: \"terminated_unknown_type\",\n eventId: id,\n reason: `family \"${subject.family}\" is not mirrored here (mirrored: ${deps.families.join(\", \")})`,\n };\n }\n\n const organizationId =\n typeof envelope.data[\"console_organization_id\"] === \"string\"\n ? (envelope.data[\"console_organization_id\"] as string)\n : null;\n if (deps.tenantPolicy && !deps.tenantPolicy.allows(envelope.source, organizationId)) {\n return {\n outcome: \"terminated_tenant_refused\",\n eventId: id,\n reason: `source=${envelope.source} organization=${organizationId ?? \"(none)\"} is not accepted by this consumer's tenant policy`,\n };\n }\n\n if (await deps.inbox.seen(id)) {\n return { outcome: \"skipped_duplicate\", eventId: id };\n }\n\n try {\n await deps.projector.apply(subject, envelope.data, {\n organizationId: organizationId ?? \"\",\n sequence: envelope.sequence,\n });\n await deps.inbox.record(id, envelope.sequence);\n return { outcome: \"applied\", eventId: id };\n } catch (e) {\n // Projection failure is transient until proven otherwise → nak/redeliver.\n return { outcome: \"retry\", eventId: id, reason: e instanceof Error ? e.message : String(e) };\n }\n };\n}\n\n// ---------------------------------------------------------------------------\n// Inbox on Cloudflare D1 (or anything with the same prepare/bind surface).\n// ---------------------------------------------------------------------------\n\n/** Structural subset of D1Database, so this module has no CF type dependency. */\ninterface D1Like {\n prepare(sql: string): {\n bind(...values: unknown[]): { first<T = unknown>(): Promise<T | null>; run(): Promise<unknown> };\n };\n}\n\n/** DDL for the inbox table — copy into a migration verbatim. */\nexport const D1_INBOX_DDL = `CREATE TABLE IF NOT EXISTS godx_sync_inbox (\n event_id TEXT PRIMARY KEY,\n sequence INTEGER NOT NULL,\n applied_at INTEGER NOT NULL\n);`;\n\nexport function createD1Inbox(db: D1Like, table = \"godx_sync_inbox\"): Inbox {\n return {\n async seen(eventId) {\n const row = await db.prepare(`SELECT 1 AS x FROM ${table} WHERE event_id = ?`).bind(eventId).first();\n return row !== null;\n },\n async record(eventId, sequence) {\n await db\n .prepare(`INSERT OR IGNORE INTO ${table} (event_id, sequence, applied_at) VALUES (?, ?, ?)`)\n .bind(eventId, sequence, Date.now())\n .run();\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n// HTTP ingest — the event leg a Worker CAN run.\n//\n// A JetStream durable needs a long-lived process, so a bridge (Node host, or\n// the platform itself one day) pulls the stream and POSTs each event here.\n// Two international standards, applied as written:\n//\n// - CloudEvents HTTP protocol binding, structured mode:\n// Content-Type application/cloudevents+json (one event) or\n// application/cloudevents-batch+json (an array).\n// - Standard Webhooks (https://www.standardwebhooks.com) authentication:\n// webhook-id / webhook-timestamp / webhook-signature headers, where\n// signature = v1,base64(HMAC-SHA256(secret, \"{id}.{timestamp}.{body}\")),\n// secret encoded as whsec_<base64>, timestamp tolerance ±5 minutes.\n// ---------------------------------------------------------------------------\n\nconst WEBHOOK_TOLERANCE_SECONDS = 5 * 60;\n\nasync function hmacKey(secret: string): Promise<CryptoKey> {\n const raw = secret.startsWith(\"whsec_\")\n ? Uint8Array.from(atob(secret.slice(6)), (ch) => ch.charCodeAt(0))\n : new TextEncoder().encode(secret);\n return crypto.subtle.importKey(\"raw\", raw, { name: \"HMAC\", hash: \"SHA-256\" }, false, [\n \"sign\",\n \"verify\",\n ]);\n}\n\n/** Produce Standard Webhooks headers for a payload — the bridge's half. */\nexport async function signWebhook(\n secret: string,\n body: string,\n opts?: { id?: string; timestamp?: number },\n): Promise<{ \"webhook-id\": string; \"webhook-timestamp\": string; \"webhook-signature\": string }> {\n const id = opts?.id ?? crypto.randomUUID();\n const timestamp = String(opts?.timestamp ?? Math.floor(Date.now() / 1000));\n const key = await hmacKey(secret);\n const mac = await crypto.subtle.sign(\"HMAC\", key, new TextEncoder().encode(`${id}.${timestamp}.${body}`));\n const signature = `v1,${btoa(String.fromCharCode(...new Uint8Array(mac)))}`;\n return { \"webhook-id\": id, \"webhook-timestamp\": timestamp, \"webhook-signature\": signature };\n}\n\n/**\n * Verify Standard Webhooks headers. Returns null when valid, otherwise the\n * human-readable refusal (never throws on bad input).\n */\nexport async function verifyWebhook(\n secret: string,\n body: string,\n headers: { get(name: string): string | null },\n): Promise<string | null> {\n const id = headers.get(\"webhook-id\");\n const timestamp = headers.get(\"webhook-timestamp\");\n const signatureHeader = headers.get(\"webhook-signature\");\n if (!id || !timestamp || !signatureHeader) {\n return \"missing webhook-id / webhook-timestamp / webhook-signature header (Standard Webhooks)\";\n }\n const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));\n if (!Number.isFinite(age) || age > WEBHOOK_TOLERANCE_SECONDS) {\n return `webhook-timestamp is ${timestamp} — outside the ±${WEBHOOK_TOLERANCE_SECONDS}s tolerance (replay guard)`;\n }\n const key = await hmacKey(secret);\n const expected = new TextEncoder().encode(`${id}.${timestamp}.${body}`);\n // The header may carry several space-separated signatures (key rotation).\n for (const part of signatureHeader.split(\" \")) {\n const [version, value] = part.split(\",\", 2);\n if (version !== \"v1\" || !value) continue;\n let mac: Uint8Array<ArrayBuffer>;\n try {\n mac = Uint8Array.from(atob(value), (ch) => ch.charCodeAt(0));\n } catch {\n continue;\n }\n // crypto.subtle.verify is constant-time — never compare digests as strings.\n if (await crypto.subtle.verify(\"HMAC\", key, mac, expected)) return null;\n }\n return \"no webhook-signature entry matched (wrong secret, or the body was altered in transit)\";\n}\n\nexport interface IngestResult {\n deliveries: Delivery[];\n response: Response;\n}\n\n/**\n * Handle one CloudEvents-over-HTTP delivery. Pure fetch API — mount it from\n * any framework in one line. HTTP mapping of outcomes:\n * every outcome except retry → 200 (ack; body lists per-event outcomes)\n * any retry → 500 (nak: the sender must redeliver)\n * bad signature → 401 · wrong content type → 415 · bad JSON/envelope → 400 (term)\n */\nexport async function handleIngestRequest(\n request: Request,\n deps: {\n secret: string;\n process: (envelope: IdentityEnvelope) => Promise<Delivery>;\n expectedSource?: string;\n },\n): Promise<Response> {\n const json = (status: number, body: unknown) =>\n new Response(JSON.stringify(body), { status, headers: { \"Content-Type\": \"application/json\" } });\n\n if (request.method !== \"POST\") return json(405, { error: \"POST only\" });\n\n const contentType = (request.headers.get(\"Content-Type\") ?? \"\").split(\";\")[0].trim();\n const batch = contentType === \"application/cloudevents-batch+json\";\n if (!batch && contentType !== \"application/cloudevents+json\") {\n return json(415, {\n error: `Content-Type \"${contentType}\" is not a CloudEvents structured mode — send application/cloudevents+json (one event) or application/cloudevents-batch+json (array).`,\n });\n }\n\n const body = await request.text();\n const refusal = await verifyWebhook(deps.secret, body, request.headers);\n if (refusal !== null) return json(401, { error: refusal });\n\n let envelopes: IdentityEnvelope[];\n try {\n const parsed: unknown = JSON.parse(body);\n const raw = batch ? parsed : [parsed];\n if (!Array.isArray(raw)) throw new SyncContractError(\"batch content-type but the body is not an array\");\n envelopes = raw.map((e) => parseEnvelope(e, { expectedSource: deps.expectedSource }));\n } catch (e) {\n // Malformed is term, not retry: redelivering the same bytes cannot help.\n return json(400, { error: e instanceof Error ? e.message : String(e) });\n }\n\n const deliveries: Delivery[] = [];\n for (const envelope of envelopes) {\n deliveries.push(await deps.process(envelope));\n }\n const mustRetry = deliveries.some((d) => d.outcome === \"retry\");\n return json(mustRetry ? 500 : 200, { deliveries });\n}\n\nexport interface SnapshotRow {\n subject: string;\n data: Record<string, unknown>;\n}\n\nexport interface SnapshotPage {\n contractVersion: string;\n family: string;\n organizationId: string;\n /** Watermark: resume the stream from here AFTER the pass completes. */\n sequence: number;\n rows: SnapshotRow[];\n nextCursor: string | null;\n}\n\nexport interface ReconcileResult {\n organizationId: string;\n /** The highest watermark of the pass — hand this to the stream leg. */\n sequence: number;\n pages: number;\n rows: number;\n}\n\n/**\n * GET /api/sso/service/snapshot/{family} — cursor-paged keyset read of current\n * state (NOT a replay of history). Auth is the service credential; tenancy is\n * the credential's, never a parameter.\n */\n/**\n * Which tenant a snapshot pass covers. Without either, Platform serves the service instance's\n * OWNING organization — for a pool deployment only one of its tenants. Prefer `organizationId`\n * (the console organization id: `console_organization_id` on every payload and the response's\n * `organization_id`); `organizationSlug` is the directory API's spelling.\n */\nexport interface SnapshotTenantOptions {\n organizationId?: string;\n organizationSlug?: string;\n}\n\nexport function createSnapshotClient(config: {\n issuer: string;\n serviceSlug: string;\n serviceSecret: string;\n}) {\n const fetchPage = async (\n family: IdentityFamily,\n opts?: SnapshotTenantOptions & { cursor?: string; limit?: number },\n ): Promise<SnapshotPage> => {\n const url = new URL(`/api/sso/service/snapshot/${family}`, config.issuer);\n if (opts?.cursor) url.searchParams.set(\"cursor\", opts.cursor);\n if (opts?.limit) url.searchParams.set(\"limit\", String(opts.limit));\n // A pool consumer names the tenant (godx-jp/server-sdk#12): by console organization id —\n // the value every payload carries (godx-jp/id#2156) — or by slug (godx-jp/id#2007).\n // Platform refuses a pair that disagrees with 400, so passing both never picks silently.\n if (opts?.organizationId) url.searchParams.set(\"organization_id\", opts.organizationId);\n if (opts?.organizationSlug) url.searchParams.set(\"organization_slug\", opts.organizationSlug);\n\n const res = await fetch(url, {\n headers: {\n \"X-Service-Slug\": config.serviceSlug,\n \"X-Service-Secret\": config.serviceSecret,\n Accept: \"application/json\",\n },\n });\n if (!res.ok) {\n const body = await res.text();\n const hint =\n res.status === 410\n ? \"the cursor's watermark aged out of the stream — restart the pass from the beginning (no cursor).\"\n : res.status === 400\n ? \"the cursor is malformed or belongs to another family — cursors are opaque, echo next_cursor verbatim.\"\n : res.status === 403\n ? \"the credential lacks service_api_enabled, or the family is not snapshot-able for it.\"\n : \"check X-Service-Slug/X-Service-Secret and the platform logs (X-Request-Id).\";\n throw new GodxHttpError(res.status, body, `identity snapshot ${family} answered ${res.status} — ${hint}`);\n }\n const b = (await res.json()) as {\n contract_version: string;\n family: string;\n organization_id: string;\n sequence: number;\n rows?: SnapshotRow[];\n next_cursor?: string | null;\n };\n if (!KNOWN_CONTRACT_VERSIONS.includes(b.contract_version as (typeof KNOWN_CONTRACT_VERSIONS)[number])) {\n throw new SyncContractError(\n `snapshot answered contract_version ${JSON.stringify(b.contract_version)}; this SDK knows ${KNOWN_CONTRACT_VERSIONS.join(\", \")} — a consumer that does not recognise it must stop, not guess.`,\n );\n }\n return {\n contractVersion: b.contract_version,\n family: b.family,\n organizationId: b.organization_id,\n sequence: b.sequence,\n rows: b.rows ?? [],\n nextCursor: b.next_cursor ?? null,\n };\n };\n\n /** Page the family to completion, applying rows in order through the Projector. */\n const reconcile = async (\n family: IdentityFamily,\n projector: Projector,\n opts?: SnapshotTenantOptions & { limit?: number },\n ): Promise<ReconcileResult> => {\n let cursor: string | undefined;\n let pages = 0;\n let rows = 0;\n let sequence = 0;\n let organizationId = \"\";\n do {\n const page = await fetchPage(family, {\n cursor,\n limit: opts?.limit,\n organizationId: opts?.organizationId,\n organizationSlug: opts?.organizationSlug,\n });\n pages++;\n sequence = Math.max(sequence, page.sequence);\n organizationId = page.organizationId;\n for (const row of page.rows) {\n await projector.apply(parseSubject(row.subject), row.data, {\n organizationId: page.organizationId,\n sequence: page.sequence,\n });\n rows++;\n }\n cursor = page.nextCursor ?? undefined;\n } while (cursor !== undefined);\n return { organizationId, sequence, pages, rows };\n };\n\n return { fetchPage, reconcile };\n}\n"],"mappings":"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","names":["body"]}
|
|
1
|
+
{"version":3,"sources":["../src/sync.ts"],"sourcesContent":["/**\n * ./sync — the identity-feed contracts (docs/reference/identity-feed-protocol.md,\n * godx-jp/id, contract v2), mirrored from php-sdk's Sync module boundary.\n *\n * What ships today is the HTTP leg — snapshot reconciliation — because it is\n * the one a Cloudflare Worker can run (a JetStream durable needs a long-lived\n * process; run that on a Node host and feed the same Projector). The envelope\n * types and the Transport/Inbox seams are here so both legs share one shape.\n *\n * Contract rules encoded below, in the contract's own words:\n * - \"A consumer that does not recognise contract_version must stop, not guess.\"\n * - Snapshot first, THEN resume the stream from `sequence` — never the reverse.\n * - `data` is a full mirrored snapshot, not a diff; absent ≠ null.\n * - CloudEvents `id` is the idempotency key; `sequence` is the ordering fence.\n */\nimport { GodxHttpError, type HttpOptions, platformFetch } from \"./http\";\n\nexport type IdentityFamily =\n | \"organization\"\n | \"brand\"\n | \"branch\"\n | \"user\"\n | \"employee\"\n | \"service_user_access\"\n | \"org_service_role\"\n | \"role_assignment\"\n | \"organization_member\"\n | \"team\"\n | \"team_member\"\n | \"service_team_access\";\n\nexport const KNOWN_CONTRACT_VERSIONS = [\"v1\", \"v2\"] as const;\n\n/** CloudEvents 1.0 envelope as the relay publishes it (contract §4). */\nexport interface IdentityEnvelope {\n specversion: \"1.0\";\n /** UUIDv4 — THE idempotency key (same across multi-tenant fan-out copies). */\n id: string;\n /** Platform origin — pin per environment and verify. */\n source: string;\n /** jp.godx.identity.{family}.{action} */\n type: string;\n /** {family}/{id-the-consumer-holds} — exactly one \"/\", both halves filled. */\n subject: string;\n time: string;\n datacontenttype: \"application/json\";\n /** Globally monotonic ordering fence (identity_outbox.sequence). */\n sequence: number;\n data: Record<string, unknown>;\n}\n\nexport interface ParsedSubject {\n family: IdentityFamily | (string & {});\n id: string;\n}\n\nexport class SyncContractError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"SyncContractError\";\n }\n}\n\n/** Split a CloudEvents/snapshot `subject`. Rejects anything the contract rejects. */\nexport function parseSubject(subject: string): ParsedSubject {\n const parts = subject.split(\"/\");\n if (parts.length !== 2 || parts[0] === \"\" || parts[1] === \"\") {\n throw new SyncContractError(\n `identity feed subject \"${subject}\" is not \"{family}/{id}\" — exactly one \"/\" with both halves filled.`,\n );\n }\n return { family: parts[0], id: parts[1] };\n}\n\n/** Validate a raw payload into an IdentityEnvelope, with expected-vs-actual errors. */\nexport function parseEnvelope(raw: unknown, opts?: { expectedSource?: string }): IdentityEnvelope {\n const e = raw as Partial<IdentityEnvelope> | null;\n if (!e || typeof e !== \"object\") throw new SyncContractError(\"envelope is not an object\");\n if (e.specversion !== \"1.0\") {\n throw new SyncContractError(`envelope specversion is ${JSON.stringify(e.specversion)}, expected \"1.0\" — rejected outright, as the php-sdk does.`);\n }\n if (typeof e.id !== \"string\" || e.id === \"\") throw new SyncContractError(\"envelope has no CloudEvents id (the idempotency key)\");\n if (typeof e.type !== \"string\" || !e.type.startsWith(\"jp.godx.identity.\")) {\n throw new SyncContractError(`envelope type ${JSON.stringify(e.type)} does not start with \"jp.godx.identity.\"`);\n }\n if (typeof e.sequence !== \"number\") throw new SyncContractError(\"envelope has no numeric sequence (the ordering fence)\");\n if (typeof e.subject !== \"string\") throw new SyncContractError(\"envelope has no subject\");\n parseSubject(e.subject);\n if (opts?.expectedSource !== undefined && e.source !== opts.expectedSource) {\n throw new SyncContractError(\n `envelope source is ${JSON.stringify(e.source)}, expected ${JSON.stringify(opts.expectedSource)} — pin the platform origin per environment.`,\n );\n }\n return e as IdentityEnvelope;\n}\n\n/**\n * What a consumer implements to hold a projection — one code path for\n * snapshot rows and stream events, exactly as the contract intends\n * (\"rows[].subject is byte-identical to the CloudEvents subject\").\n */\nexport interface Projector {\n /** Upsert the full mirrored payload for this subject (data is never a diff). */\n apply(subject: ParsedSubject, data: Record<string, unknown>, context: ProjectorContext): Promise<void>;\n}\n\nexport interface ProjectorContext {\n organizationId: string;\n /** The watermark this row is consistent up to. */\n sequence: number;\n}\n\n/** The stream-leg seam (JetStream durable on a Node host). Not implemented here yet. */\nexport interface Transport {\n pull(handler: (envelope: IdentityEnvelope) => Promise<void>): Promise<void>;\n}\n\n/** Dedupe seam keyed on the CloudEvents id — the consumer's job, per contract §4. */\nexport interface Inbox {\n /** true when this id was already applied — skip it. */\n seen(eventId: string): Promise<boolean>;\n record(eventId: string, sequence: number): Promise<void>;\n}\n\n/**\n * Which (source, organization) pairs this consumer accepts — php-sdk's\n * TenantPolicy. Deny is a `terminated` outcome, never an exception: an event\n * for a tenant that is not yours is noise, not an incident.\n */\nexport interface TenantPolicy {\n allows(source: string, organizationId: string | null): boolean;\n}\n\nexport const acceptTenants = (source: string, organizationIds: readonly string[]): TenantPolicy => ({\n allows: (s, org) => s === source && (org === null || organizationIds.includes(org)),\n});\n\n/**\n * What happened to one delivery — mirrors php-sdk's Outcome and the contract's\n * nak-vs-term rule. The HTTP mapping (see handleIngestRequest) is:\n * applied/skipped_* → 2xx (ack) · retry → 5xx (nak, redeliver) ·\n * terminated → 200 with the outcome named (term: redelivery cannot help).\n */\nexport type Outcome =\n | \"applied\"\n | \"skipped_duplicate\"\n | \"skipped_stale\"\n | \"terminated_unknown_type\"\n | \"terminated_tenant_refused\"\n | \"retry\";\n\nexport interface Delivery {\n outcome: Outcome;\n eventId: string;\n /** Present on terminated/retry — the human-readable why. */\n reason?: string;\n}\n\n/**\n * The one pipeline both legs feed: validate → dedupe → tenant gate → project\n * → record. A snapshot pass uses the Projector directly; every EVENT goes\n * through here.\n */\nexport function createProcessor(deps: {\n projector: Projector;\n inbox: Inbox;\n tenantPolicy?: TenantPolicy;\n /** Families this consumer mirrors; events for others are terminated (contract §3 nak-vs-term). */\n families?: readonly IdentityFamily[];\n}) {\n return async (envelope: IdentityEnvelope): Promise<Delivery> => {\n const { id } = envelope;\n let subject: ParsedSubject;\n try {\n subject = parseSubject(envelope.subject);\n } catch (e) {\n return { outcome: \"terminated_unknown_type\", eventId: id, reason: (e as Error).message };\n }\n\n if (deps.families && !deps.families.includes(subject.family as IdentityFamily)) {\n // Unknown family → term, not nak: redelivering cannot make us know it.\n return {\n outcome: \"terminated_unknown_type\",\n eventId: id,\n reason: `family \"${subject.family}\" is not mirrored here (mirrored: ${deps.families.join(\", \")})`,\n };\n }\n\n const organizationId =\n typeof envelope.data[\"console_organization_id\"] === \"string\"\n ? (envelope.data[\"console_organization_id\"] as string)\n : null;\n if (deps.tenantPolicy && !deps.tenantPolicy.allows(envelope.source, organizationId)) {\n return {\n outcome: \"terminated_tenant_refused\",\n eventId: id,\n reason: `source=${envelope.source} organization=${organizationId ?? \"(none)\"} is not accepted by this consumer's tenant policy`,\n };\n }\n\n if (await deps.inbox.seen(id)) {\n return { outcome: \"skipped_duplicate\", eventId: id };\n }\n\n try {\n await deps.projector.apply(subject, envelope.data, {\n organizationId: organizationId ?? \"\",\n sequence: envelope.sequence,\n });\n await deps.inbox.record(id, envelope.sequence);\n return { outcome: \"applied\", eventId: id };\n } catch (e) {\n // Projection failure is transient until proven otherwise → nak/redeliver.\n return { outcome: \"retry\", eventId: id, reason: e instanceof Error ? e.message : String(e) };\n }\n };\n}\n\n// ---------------------------------------------------------------------------\n// Inbox on Cloudflare D1 (or anything with the same prepare/bind surface).\n// ---------------------------------------------------------------------------\n\n/** Structural subset of D1Database, so this module has no CF type dependency. */\ninterface D1Like {\n prepare(sql: string): {\n bind(...values: unknown[]): { first<T = unknown>(): Promise<T | null>; run(): Promise<unknown> };\n };\n}\n\n/** DDL for the inbox table — copy into a migration verbatim. */\nexport const D1_INBOX_DDL = `CREATE TABLE IF NOT EXISTS godx_sync_inbox (\n event_id TEXT PRIMARY KEY,\n sequence INTEGER NOT NULL,\n applied_at INTEGER NOT NULL\n);`;\n\nexport function createD1Inbox(db: D1Like, table = \"godx_sync_inbox\"): Inbox {\n return {\n async seen(eventId) {\n const row = await db.prepare(`SELECT 1 AS x FROM ${table} WHERE event_id = ?`).bind(eventId).first();\n return row !== null;\n },\n async record(eventId, sequence) {\n await db\n .prepare(`INSERT OR IGNORE INTO ${table} (event_id, sequence, applied_at) VALUES (?, ?, ?)`)\n .bind(eventId, sequence, Date.now())\n .run();\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n// HTTP ingest — the event leg a Worker CAN run.\n//\n// A JetStream durable needs a long-lived process, so a bridge (Node host, or\n// the platform itself one day) pulls the stream and POSTs each event here.\n// Two international standards, applied as written:\n//\n// - CloudEvents HTTP protocol binding, structured mode:\n// Content-Type application/cloudevents+json (one event) or\n// application/cloudevents-batch+json (an array).\n// - Standard Webhooks (https://www.standardwebhooks.com) authentication:\n// webhook-id / webhook-timestamp / webhook-signature headers, where\n// signature = v1,base64(HMAC-SHA256(secret, \"{id}.{timestamp}.{body}\")),\n// secret encoded as whsec_<base64>, timestamp tolerance ±5 minutes.\n// ---------------------------------------------------------------------------\n\nconst WEBHOOK_TOLERANCE_SECONDS = 5 * 60;\n\nasync function hmacKey(secret: string): Promise<CryptoKey> {\n const raw = secret.startsWith(\"whsec_\")\n ? Uint8Array.from(atob(secret.slice(6)), (ch) => ch.charCodeAt(0))\n : new TextEncoder().encode(secret);\n return crypto.subtle.importKey(\"raw\", raw, { name: \"HMAC\", hash: \"SHA-256\" }, false, [\n \"sign\",\n \"verify\",\n ]);\n}\n\n/** Produce Standard Webhooks headers for a payload — the bridge's half. */\nexport async function signWebhook(\n secret: string,\n body: string,\n opts?: { id?: string; timestamp?: number },\n): Promise<{ \"webhook-id\": string; \"webhook-timestamp\": string; \"webhook-signature\": string }> {\n const id = opts?.id ?? crypto.randomUUID();\n const timestamp = String(opts?.timestamp ?? Math.floor(Date.now() / 1000));\n const key = await hmacKey(secret);\n const mac = await crypto.subtle.sign(\"HMAC\", key, new TextEncoder().encode(`${id}.${timestamp}.${body}`));\n const signature = `v1,${btoa(String.fromCharCode(...new Uint8Array(mac)))}`;\n return { \"webhook-id\": id, \"webhook-timestamp\": timestamp, \"webhook-signature\": signature };\n}\n\n/**\n * Verify Standard Webhooks headers. Returns null when valid, otherwise the\n * human-readable refusal (never throws on bad input).\n */\nexport async function verifyWebhook(\n secret: string,\n body: string,\n headers: { get(name: string): string | null },\n): Promise<string | null> {\n const id = headers.get(\"webhook-id\");\n const timestamp = headers.get(\"webhook-timestamp\");\n const signatureHeader = headers.get(\"webhook-signature\");\n if (!id || !timestamp || !signatureHeader) {\n return \"missing webhook-id / webhook-timestamp / webhook-signature header (Standard Webhooks)\";\n }\n const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));\n if (!Number.isFinite(age) || age > WEBHOOK_TOLERANCE_SECONDS) {\n return `webhook-timestamp is ${timestamp} — outside the ±${WEBHOOK_TOLERANCE_SECONDS}s tolerance (replay guard)`;\n }\n const key = await hmacKey(secret);\n const expected = new TextEncoder().encode(`${id}.${timestamp}.${body}`);\n // The header may carry several space-separated signatures (key rotation).\n for (const part of signatureHeader.split(\" \")) {\n const [version, value] = part.split(\",\", 2);\n if (version !== \"v1\" || !value) continue;\n let mac: Uint8Array<ArrayBuffer>;\n try {\n mac = Uint8Array.from(atob(value), (ch) => ch.charCodeAt(0));\n } catch {\n continue;\n }\n // crypto.subtle.verify is constant-time — never compare digests as strings.\n if (await crypto.subtle.verify(\"HMAC\", key, mac, expected)) return null;\n }\n return \"no webhook-signature entry matched (wrong secret, or the body was altered in transit)\";\n}\n\nexport interface IngestResult {\n deliveries: Delivery[];\n response: Response;\n}\n\n/**\n * Handle one CloudEvents-over-HTTP delivery. Pure fetch API — mount it from\n * any framework in one line. HTTP mapping of outcomes:\n * every outcome except retry → 200 (ack; body lists per-event outcomes)\n * any retry → 500 (nak: the sender must redeliver)\n * bad signature → 401 · wrong content type → 415 · bad JSON/envelope → 400 (term)\n */\nexport async function handleIngestRequest(\n request: Request,\n deps: {\n secret: string;\n process: (envelope: IdentityEnvelope) => Promise<Delivery>;\n expectedSource?: string;\n },\n): Promise<Response> {\n const json = (status: number, body: unknown) =>\n new Response(JSON.stringify(body), { status, headers: { \"Content-Type\": \"application/json\" } });\n\n if (request.method !== \"POST\") return json(405, { error: \"POST only\" });\n\n const contentType = (request.headers.get(\"Content-Type\") ?? \"\").split(\";\")[0].trim();\n const batch = contentType === \"application/cloudevents-batch+json\";\n if (!batch && contentType !== \"application/cloudevents+json\") {\n return json(415, {\n error: `Content-Type \"${contentType}\" is not a CloudEvents structured mode — send application/cloudevents+json (one event) or application/cloudevents-batch+json (array).`,\n });\n }\n\n const body = await request.text();\n const refusal = await verifyWebhook(deps.secret, body, request.headers);\n if (refusal !== null) return json(401, { error: refusal });\n\n let envelopes: IdentityEnvelope[];\n try {\n const parsed: unknown = JSON.parse(body);\n const raw = batch ? parsed : [parsed];\n if (!Array.isArray(raw)) throw new SyncContractError(\"batch content-type but the body is not an array\");\n envelopes = raw.map((e) => parseEnvelope(e, { expectedSource: deps.expectedSource }));\n } catch (e) {\n // Malformed is term, not retry: redelivering the same bytes cannot help.\n return json(400, { error: e instanceof Error ? e.message : String(e) });\n }\n\n const deliveries: Delivery[] = [];\n for (const envelope of envelopes) {\n deliveries.push(await deps.process(envelope));\n }\n const mustRetry = deliveries.some((d) => d.outcome === \"retry\");\n return json(mustRetry ? 500 : 200, { deliveries });\n}\n\nexport interface SnapshotRow {\n subject: string;\n data: Record<string, unknown>;\n}\n\nexport interface SnapshotPage {\n contractVersion: string;\n family: string;\n organizationId: string;\n /** Watermark: resume the stream from here AFTER the pass completes. */\n sequence: number;\n rows: SnapshotRow[];\n nextCursor: string | null;\n}\n\nexport interface ReconcileResult {\n organizationId: string;\n /** The highest watermark of the pass — hand this to the stream leg. */\n sequence: number;\n pages: number;\n rows: number;\n}\n\n/**\n * GET /api/sso/service/snapshot/{family} — cursor-paged keyset read of current\n * state (NOT a replay of history). Auth is the service credential; tenancy is\n * the credential's, never a parameter.\n */\n/**\n * Which tenant a snapshot pass covers. Without either, Platform serves the service instance's\n * OWNING organization — for a pool deployment only one of its tenants. Prefer `organizationId`\n * (the console organization id: `console_organization_id` on every payload and the response's\n * `organization_id`); `organizationSlug` is the directory API's spelling.\n */\nexport interface SnapshotTenantOptions {\n organizationId?: string;\n organizationSlug?: string;\n}\n\nexport function createSnapshotClient(config: {\n issuer: string;\n serviceSlug: string;\n serviceSecret: string;\n /** Headers, deadline and fetch for every snapshot page. */\n http?: HttpOptions;\n}) {\n const fetchPage = async (\n family: IdentityFamily,\n opts?: SnapshotTenantOptions & { cursor?: string; limit?: number },\n ): Promise<SnapshotPage> => {\n const url = new URL(`/api/sso/service/snapshot/${family}`, config.issuer);\n if (opts?.cursor) url.searchParams.set(\"cursor\", opts.cursor);\n if (opts?.limit) url.searchParams.set(\"limit\", String(opts.limit));\n // A pool consumer names the tenant (godx-jp/server-sdk#12): by console organization id —\n // the value every payload carries (godx-jp/id#2156) — or by slug (godx-jp/id#2007).\n // Platform refuses a pair that disagrees with 400, so passing both never picks silently.\n if (opts?.organizationId) url.searchParams.set(\"organization_id\", opts.organizationId);\n if (opts?.organizationSlug) url.searchParams.set(\"organization_slug\", opts.organizationSlug);\n\n const res = await platformFetch(url, {\n headers: {\n \"X-Service-Slug\": config.serviceSlug,\n \"X-Service-Secret\": config.serviceSecret,\n Accept: \"application/json\",\n },\n }, config.http);\n if (!res.ok) {\n const body = await res.text();\n const hint =\n res.status === 410\n ? \"the cursor's watermark aged out of the stream — restart the pass from the beginning (no cursor).\"\n : res.status === 400\n ? \"the cursor is malformed or belongs to another family — cursors are opaque, echo next_cursor verbatim.\"\n : res.status === 403\n ? \"the credential lacks service_api_enabled, or the family is not snapshot-able for it.\"\n : \"check X-Service-Slug/X-Service-Secret and the platform logs (X-Request-Id).\";\n throw new GodxHttpError(res.status, body, `identity snapshot ${family} answered ${res.status} — ${hint}`);\n }\n const b = (await res.json()) as {\n contract_version: string;\n family: string;\n organization_id: string;\n sequence: number;\n rows?: SnapshotRow[];\n next_cursor?: string | null;\n };\n if (!KNOWN_CONTRACT_VERSIONS.includes(b.contract_version as (typeof KNOWN_CONTRACT_VERSIONS)[number])) {\n throw new SyncContractError(\n `snapshot answered contract_version ${JSON.stringify(b.contract_version)}; this SDK knows ${KNOWN_CONTRACT_VERSIONS.join(\", \")} — a consumer that does not recognise it must stop, not guess.`,\n );\n }\n return {\n contractVersion: b.contract_version,\n family: b.family,\n organizationId: b.organization_id,\n sequence: b.sequence,\n rows: b.rows ?? [],\n nextCursor: b.next_cursor ?? null,\n };\n };\n\n /** Page the family to completion, applying rows in order through the Projector. */\n const reconcile = async (\n family: IdentityFamily,\n projector: Projector,\n opts?: SnapshotTenantOptions & { limit?: number },\n ): Promise<ReconcileResult> => {\n let cursor: string | undefined;\n let pages = 0;\n let rows = 0;\n let sequence = 0;\n let organizationId = \"\";\n do {\n const page = await fetchPage(family, {\n cursor,\n limit: opts?.limit,\n organizationId: opts?.organizationId,\n organizationSlug: opts?.organizationSlug,\n });\n pages++;\n sequence = Math.max(sequence, page.sequence);\n organizationId = page.organizationId;\n for (const row of page.rows) {\n await projector.apply(parseSubject(row.subject), row.data, {\n organizationId: page.organizationId,\n sequence: page.sequence,\n });\n rows++;\n }\n cursor = page.nextCursor ?? undefined;\n } while (cursor !== undefined);\n return { organizationId, sequence, pages, rows };\n };\n\n return { fetchPage, reconcile };\n}\n"],"mappings":"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","names":["body"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@godxjp/server-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
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": {
|
|
@@ -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 /** 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":[]}
|
|
@@ -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 /** 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":";AAKA,SAAS,0BAA0B;AAiB5B,IAAM,gBAAN,cAA4B,MAAM;AAAA,EAC9B;AAAA,EACA;AAAA,EAET,YAAY,QAAgB,MAAc,SAAkB;AAC1D,UAAM,WAAW,qCAAqC,MAAM,EAAE;AAC9D,SAAK,OAAO;AACZ,SAAK,SAAS;AACd,SAAK,OAAO;AAAA,EACd;AACF;AAGA,IAAM,cAAc,oBAAI,IAA6B;AAE9C,SAAS,WAAW,WAAoC;AAC7D,MAAI,SAAS,YAAY,IAAI,SAAS;AACtC,MAAI,CAAC,QAAQ;AACX,cAAU,YAAY;AACpB,YAAM,MAAM,MAAM,MAAM,IAAI,IAAI,qCAAqC,SAAS,CAAC;AAC/E,UAAI,CAAC,IAAI,IAAI;AACX,oBAAY,OAAO,SAAS;AAC5B,cAAM,IAAI,cAAc,IAAI,QAAQ,MAAM,IAAI,KAAK,GAAG,uBAAuB;AAAA,MAC/E;AACA,YAAM,WAAY,MAAM,IAAI,KAAK;AACjC,aAAO,EAAE,UAAU,MAAM,mBAAmB,IAAI,IAAI,SAAS,QAAQ,CAAC,EAAE;AAAA,IAC1E,GAAG;AACH,gBAAY,IAAI,WAAW,MAAM;AACjC,WAAO,MAAM,MAAM,YAAY,OAAO,SAAS,CAAC;AAAA,EAClD;AACA,SAAO;AACT;AA+BO,IAAM,gBAAN,MAAoB;AAAA,EAChB;AAAA,EAET,YAAY,QAAyB;AACnC,SAAK,SAAS;AAAA,EAChB;AAAA,EAEA,MAAM,aAAa,QAOC;AAClB,UAAM,EAAE,SAAS,IAAI,MAAM,WAAW,KAAK,OAAO,MAAM;AACxD,UAAM,MAAM,IAAI,IAAI,SAAS,sBAAsB;AACnD,QAAI,SAAS,IAAI,gBAAgB;AAAA,MAC/B,cAAc,KAAK,OAAO;AAAA,MAC1B,WAAW,KAAK,OAAO;AAAA,MACvB,cAAc,KAAK,OAAO;AAAA,MAC1B,eAAe;AAAA,MACf,OAAO,OAAO,SAAS;AAAA,MACvB,OAAO,OAAO;AAAA,MACd,OAAO,OAAO;AAAA,MACd,gBAAgB,OAAO;AAAA,MACvB,uBAAuB;AAAA,MACvB,GAAI,OAAO,wBACP,EAAE,yBAAyB,OAAO,sBAAsB,IACxD,CAAC;AAAA,IACP,CAAC,EAAE,SAAS;AACZ,WAAO,IAAI,SAAS;AAAA,EACtB;AAAA,EAEA,MAAM,aAAa,QAAwE;AACzF,WAAO,KAAK,aAAa,kBAAkB;AAAA,MACzC,YAAY;AAAA,MACZ,cAAc,KAAK,OAAO;AAAA,MAC1B,MAAM,OAAO;AAAA,MACb,eAAe,OAAO;AAAA,IACxB,CAAC;AAAA,EACH;AAAA,EAEA,MAAM,QAAQ,cAA8C;AAC1D,UAAM,EAAE,SAAS,IAAI,MAAM,WAAW,KAAK,OAAO,MAAM;AACxD,WAAO,KAAK,SAAS,IAAI,IAAI,oBAAoB,SAAS,MAAM,EAAE,SAAS,GAAG;AAAA,MAC5E,YAAY;AAAA,MACZ,eAAe;AAAA;AAAA;AAAA;AAAA,MAIf,cAAc,KAAK,OAAO,YAAY,KAAK,OAAO;AAAA,IACpD,CAAC;AAAA,EACH;AAAA;AAAA,EAGA,MAAM,OAAO,OAA8B;AACzC,UAAM,EAAE,SAAS,IAAI,MAAM,WAAW,KAAK,OAAO,MAAM;AACxD,QAAI,CAAC,SAAS,oBAAqB;AACnC,UAAM,KAAK,SAAS,SAAS,qBAAqB,EAAE,MAAM,CAAC;AAAA,EAC7D;AAAA,EAEA,MAAM,SAAS,aAAgD;AAC7D,UAAM,EAAE,SAAS,IAAI,MAAM,WAAW,KAAK,OAAO,MAAM;AACxD,UAAM,MAAM,MAAM,MAAM,SAAS,mBAAmB;AAAA,MAClD,SAAS,EAAE,eAAe,UAAU,WAAW,GAAG;AAAA,IACpD,CAAC;AACD,QAAI,CAAC,IAAI,GAAI,OAAM,IAAI,cAAc,IAAI,QAAQ,MAAM,IAAI,KAAK,GAAG,iBAAiB;AACpF,WAAQ,MAAM,IAAI,KAAK;AAAA,EACzB;AAAA,EAEA,MAAc,aACZ,UACA,QACwB;AACxB,UAAM,EAAE,SAAS,IAAI,MAAM,WAAW,KAAK,OAAO,MAAM;AACxD,WAAO,KAAK,SAAS,SAAS,QAAQ,GAAG,MAAM;AAAA,EACjD;AAAA,EAEA,MAAc,SAAS,KAAa,QAAwD;AAC1F,UAAM,OAAO,IAAI,gBAAgB;AAAA,MAC/B,cAAc,KAAK,OAAO;AAAA,MAC1B,WAAW,KAAK,OAAO;AAAA,MACvB,GAAI,KAAK,OAAO,eAAe,EAAE,eAAe,KAAK,OAAO,aAAa,IAAI,CAAC;AAAA,MAC9E,GAAG;AAAA,IACL,CAAC;AACD,UAAM,MAAM,MAAM,MAAM,KAAK;AAAA,MAC3B,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,oCAAoC;AAAA,MAC/D;AAAA,IACF,CAAC;AACD,QAAI,CAAC,IAAI,GAAI,OAAM,IAAI,cAAc,IAAI,QAAQ,MAAM,IAAI,KAAK,GAAG,sBAAsB;AACzF,WAAQ,MAAM,IAAI,KAAK;AAAA,EACzB;AACF;AAMO,SAAS,iBAAiB,QAI9B;AACD,QAAM,MAAM,OAAU,MAAc,UAA8C;AAChF,UAAM,MAAM,IAAI,IAAI,MAAM,OAAO,MAAM;AACvC,QAAI,SAAS,IAAI,gBAAgB,KAAK,EAAE,SAAS;AACjD,UAAM,MAAM,MAAM,MAAM,KAAK;AAAA,MAC3B,SAAS;AAAA,QACP,kBAAkB,OAAO;AAAA,QACzB,oBAAoB,OAAO;AAAA,MAC7B;AAAA,IACF,CAAC;AACD,QAAI,CAAC,IAAI,GAAI,OAAM,IAAI,cAAc,IAAI,QAAQ,MAAM,IAAI,KAAK,GAAG,eAAe,IAAI,SAAS;AAC/F,WAAQ,MAAM,IAAI,KAAK;AAAA,EACzB;AAEA,SAAO;AAAA,IACL,OAAO,CAAC,MACN,IAAa,0BAA0B;AAAA,MACrC,mBAAmB,EAAE;AAAA,MACrB,GAAI,EAAE,UAAU,EAAE,UAAU,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,MACnD,GAAI,EAAE,OAAO,EAAE,MAAM,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC;AAAA,IAC3C,CAAC;AAAA,IACH,UAAU,CAAC,MACT,IAAa,6BAA6B,EAAE,mBAAmB,EAAE,iBAAiB,CAAC;AAAA,IACrF,QAAQ,CAAC,MACP,IAAa,2BAA2B,EAAE,mBAAmB,EAAE,iBAAiB,CAAC;AAAA,EACrF;AACF;","names":[]}
|