@pplethai/cli 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/bin.js +171 -22
  2. package/package.json +3 -2
  3. package/templates/golden-path/.dev.vars.example +19 -1
  4. package/templates/golden-path/CLAUDE.md +51 -13
  5. package/templates/golden-path/README.md +27 -2
  6. package/templates/golden-path/gitignore +3 -0
  7. package/templates/golden-path/overlay-mtime.mjs +30 -0
  8. package/templates/golden-path/package-lock.json +19 -1
  9. package/templates/golden-path/package.json +3 -2
  10. package/templates/golden-path/scripts/assert-dev-compiled-out.mjs +42 -0
  11. package/templates/golden-path/shared/api.ts +25 -2
  12. package/templates/golden-path/src/auth.ts +72 -7
  13. package/templates/golden-path/src/call-service.ts +140 -0
  14. package/templates/golden-path/src/dataset-query.ts +470 -0
  15. package/templates/golden-path/src/dataset-schema.ts +398 -0
  16. package/templates/golden-path/src/dev-call-service.ts +391 -0
  17. package/templates/golden-path/src/dev-personas.ts +49 -15
  18. package/templates/golden-path/src/dev-read-dataset.ts +429 -0
  19. package/templates/golden-path/src/dev-registry.ts +276 -0
  20. package/templates/golden-path/src/env.ts +13 -2
  21. package/templates/golden-path/src/identity-header.ts +17 -0
  22. package/templates/golden-path/src/index.ts +32 -1
  23. package/templates/golden-path/src/legal-identity.ts +47 -28
  24. package/templates/golden-path/src/levels.ts +132 -0
  25. package/templates/golden-path/src/read-dataset.ts +376 -0
  26. package/templates/golden-path/src/registry.ts +122 -0
  27. package/templates/golden-path/src/vite-env.d.ts +13 -0
  28. package/templates/golden-path/test/call-service.test.ts +178 -0
  29. package/templates/golden-path/test/dev-call-service.test.ts +279 -0
  30. package/templates/golden-path/test/dev-read-dataset.test.ts +270 -0
  31. package/templates/golden-path/test/dev-registry.test.ts +375 -0
  32. package/templates/golden-path/test/effective-sub.test.ts +101 -0
  33. package/templates/golden-path/test/fixtures/overlay/directory/branches/entry.md +85 -0
  34. package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/audience.json +3 -0
  35. package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/named.json +3 -0
  36. package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/offices.json +6 -0
  37. package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/roster.json +3 -0
  38. package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/summary.json +1 -0
  39. package/templates/golden-path/test/fixtures/overlay/platform/legal-identity/entry.md +8 -0
  40. package/templates/golden-path/test/fixtures/overlay/platform/legal-identity/mocks/responses.json +13 -0
  41. package/templates/golden-path/test/fixtures/overlay/shop/catalog/entry.md +8 -0
  42. package/templates/golden-path/test/fixtures/overlay/shop/catalog/mocks/responses.json +7 -0
  43. package/templates/golden-path/test/fixtures/overlay/shop/orders/entry.md +12 -0
  44. package/templates/golden-path/test/fixtures/overlay/shop/orders/mocks/responses.json +36 -0
  45. package/templates/golden-path/test/harness.ts +101 -16
  46. package/templates/golden-path/test/legal-identity.test.ts +91 -24
  47. package/templates/golden-path/test/membership.test.ts +105 -0
  48. package/templates/golden-path/test/read-dataset.test.ts +214 -0
  49. package/templates/golden-path/test/service-call.test.ts +166 -0
  50. package/templates/golden-path/vite.config.ts +2 -1
  51. package/templates/golden-path/vitest.config.ts +2 -0
@@ -0,0 +1,276 @@
1
+ // Offline Provider development (ADR-0026). Active only where
2
+ // `import.meta.env.DEV` is true — `vite dev` and the template tests. The
3
+ // production build drops this module the same way it drops the Persona
4
+ // bypass (ADR-0011).
5
+ //
6
+ // `npm run dev` has no platform wrapper and no platform-call secret, so a
7
+ // Builder can curl `/.pple/registry/<name>/…` directly. This delivers that
8
+ // request to `registry[name]` the way the platform wrapper does: the tail
9
+ // path, the method and body, and the `x-pple-*` facts. Incoming `x-pple-*`
10
+ // headers are never trusted. Public traffic has them stripped before app
11
+ // code, so dev and production agree that a Builder cannot invent platform
12
+ // facts.
13
+ //
14
+ // Identity choice. An unset `DEV_PERSONA` uses the default Persona, as the
15
+ // rest of local dev does. `DEV_PERSONA=none` sends no `x-pple-identity`,
16
+ // which is what the platform does for an entry that declares
17
+ // `userToken: none`. A name that is not a Persona does the same, and does
18
+ // not fall back to the default: a registry call must not invent a caller.
19
+ // `/api` still falls back, so a typo does not lock the Builder out of the
20
+ // app. The Consumer is `DEV_CONSUMER`, or `dev-consumer`. The entry id is
21
+ // `dev-provider/<name>` — a local app has no App Slug until the platform
22
+ // creates it.
23
+ //
24
+ // A Dataset value is an object of page functions. `/.pple/registry/<name>/<objectType>`
25
+ // calls that function with `{ cursor }` and answers with its page. A value with
26
+ // no fetch-shaped handler, and a page map with no function for the segment,
27
+ // throws `registry handler missing`, the error the platform wrapper throws.
28
+ // `pple-services` turns that throw
29
+ // into `provider_unavailable` (502). Nothing here invents that response:
30
+ // locally there is no gateway in front of the handler, and the 502 is a
31
+ // Consumer-facing answer the Provider's code never sees. Other `/.pple/`
32
+ // paths (cron, the deploy probe) are not emulated; they reach app code with
33
+ // platform headers stripped.
34
+
35
+ import type { Identity } from "../shared/api";
36
+ import { DEV_PERSONAS, DEFAULT_DEV_PERSONA } from "./dev-personas";
37
+ import type { Env } from "./env";
38
+ import { encodeIdentityHeader } from "./identity-header";
39
+ import {
40
+ CONSUMER_HEADER,
41
+ ENTRY_HEADER,
42
+ IDENTITY_HEADER,
43
+ type DatasetPages,
44
+ type RegistryFetch,
45
+ type RegistryMap,
46
+ } from "./registry";
47
+
48
+ export const DEFAULT_DEV_CONSUMER = "dev-consumer";
49
+
50
+ /** Local stand-in for the App Slug. The entry id is `${DEV_PROVIDER_SLUG}/<name>`. */
51
+ export const DEV_PROVIDER_SLUG = "dev-provider";
52
+
53
+ const REGISTRY_PATH_PREFIX = "/.pple/registry/";
54
+
55
+ const HOP_BY_HOP = new Set([
56
+ "host",
57
+ "connection",
58
+ "keep-alive",
59
+ "transfer-encoding",
60
+ "te",
61
+ "trailer",
62
+ "upgrade",
63
+ ]);
64
+
65
+ export interface DevWorker {
66
+ fetch: RegistryFetch;
67
+ registry?: RegistryMap;
68
+ }
69
+
70
+ export interface CallRegistryOptions {
71
+ /**
72
+ * Persona for `x-pple-identity`. `"none"` or an unknown name sends none.
73
+ * Omit to use the env's `DEV_PERSONA`.
74
+ */
75
+ persona?: string;
76
+ /** Consumer slug. Omit to use `DEV_CONSUMER`, then `dev-consumer`. */
77
+ consumer?: string;
78
+ /**
79
+ * Method and body, which the platform preserves. `x-pple-*`, Authorization
80
+ * and Cookie here are dropped: the call sets the platform facts itself.
81
+ */
82
+ init?: RequestInit;
83
+ }
84
+
85
+ /** The Identity a dev registry call carries, or null for `userToken: none`. */
86
+ export function devRegistryIdentity(name: string | undefined): Identity | null {
87
+ const key = name?.trim() || DEFAULT_DEV_PERSONA;
88
+ if (key === "none") return null;
89
+ const persona = DEV_PERSONAS[key];
90
+ if (persona) return persona;
91
+ console.warn(
92
+ `Unknown DEV_PERSONA "${key}" on the emulated registry path; sending no Identity ` +
93
+ `(userToken: none). Valid personas: ${Object.keys(DEV_PERSONAS).join(", ")}.`,
94
+ );
95
+ return null;
96
+ }
97
+
98
+ export function devConsumerSlug(value: string | undefined): string {
99
+ const slug = value?.trim();
100
+ return slug ? slug : DEFAULT_DEV_CONSUMER;
101
+ }
102
+
103
+ /**
104
+ * Dev entry. Registry calls go to `user.registry`. Everything else is public
105
+ * traffic: `x-pple-*` is removed, then `user.fetch` runs.
106
+ */
107
+ export async function devWorkerFetch(
108
+ user: DevWorker,
109
+ request: Request,
110
+ env: Env,
111
+ ctx: ExecutionContext,
112
+ ): Promise<Response> {
113
+ const url = new URL(request.url);
114
+ const parsed = parseRegistryPath(url.pathname);
115
+ if (!parsed) return user.fetch(stripPlatformHeaders(request), env, ctx);
116
+ if (!parsed.name) return emptyRegistryResponse();
117
+
118
+ const pages = datasetPages(user.registry, parsed.name);
119
+ if (pages) return runDatasetPage(pages, parsed.tail, request, env, ctx);
120
+
121
+ const headers = handlerHeaders(
122
+ request.headers,
123
+ registryFacts(parsed.name, devConsumerSlug(env.DEV_CONSUMER), devRegistryIdentity(env.DEV_PERSONA)),
124
+ );
125
+ const target = new URL(request.url);
126
+ target.pathname = parsed.tail;
127
+ return handlerFor(user.registry, parsed.name)(
128
+ forwardedRequest(target, request.method, headers, request.body),
129
+ env,
130
+ ctx,
131
+ );
132
+ }
133
+
134
+ /**
135
+ * Call `registry[name]` the way the platform wrapper does. See the file
136
+ * comment for a missing handler: this throws `registry handler missing`.
137
+ */
138
+ export async function callRegistry(
139
+ user: DevWorker,
140
+ env: Env,
141
+ ctx: ExecutionContext,
142
+ name: string,
143
+ path: string,
144
+ options: CallRegistryOptions = {},
145
+ ): Promise<Response> {
146
+ if (!name) return emptyRegistryResponse();
147
+ const personaName = options.persona !== undefined ? options.persona : env.DEV_PERSONA;
148
+ const consumerName = options.consumer !== undefined ? options.consumer : env.DEV_CONSUMER;
149
+ const headers = handlerHeaders(
150
+ new Headers(options.init?.headers),
151
+ registryFacts(name, devConsumerSlug(consumerName), devRegistryIdentity(personaName)),
152
+ );
153
+ const method = options.init?.method ?? "GET";
154
+ const body = options.init?.body ?? null;
155
+ // No incoming Host: the pathname and query are the tail the platform sets.
156
+ const request = forwardedRequest(tailUrl(path), method, headers, body);
157
+ const pages = datasetPages(user.registry, name);
158
+ if (pages) return runDatasetPage(pages, new URL(request.url).pathname, request, env, ctx);
159
+ return handlerFor(user.registry, name)(request, env, ctx);
160
+ }
161
+
162
+ function registryFacts(
163
+ name: string,
164
+ consumer: string,
165
+ identity: Identity | null,
166
+ ): { consumer: string; entry: string; identity: Identity | null } {
167
+ return { consumer, entry: `${DEV_PROVIDER_SLUG}/${name}`, identity };
168
+ }
169
+
170
+ /** What the handler sees. Matches pple-services' header filter, minus the secret. */
171
+ function handlerHeaders(
172
+ incoming: Headers,
173
+ facts: { consumer: string; entry: string; identity: Identity | null },
174
+ ): Headers {
175
+ const headers = new Headers();
176
+ for (const [key, value] of incoming) {
177
+ const lower = key.toLowerCase();
178
+ if (
179
+ lower === "authorization" ||
180
+ lower === "cookie" ||
181
+ lower.startsWith("x-pple-") ||
182
+ HOP_BY_HOP.has(lower)
183
+ ) {
184
+ continue;
185
+ }
186
+ headers.set(key, value);
187
+ }
188
+ headers.set(CONSUMER_HEADER, facts.consumer);
189
+ headers.set(ENTRY_HEADER, facts.entry);
190
+ if (facts.identity) headers.set(IDENTITY_HEADER, encodeIdentityHeader(facts.identity));
191
+ return headers;
192
+ }
193
+
194
+ function datasetPages(registry: RegistryMap | undefined, name: string): DatasetPages | null {
195
+ const value = registry?.[name];
196
+ if (typeof value !== "object" || value === null || typeof value.fetch === "function") return null;
197
+ return value as DatasetPages;
198
+ }
199
+
200
+ /**
201
+ * A Dataset page function is `{ cursor }` in, a page out. The path's single
202
+ * segment is the Object Type. No segment, or no function by that name, is
203
+ * the same missing-handler failure as a Service with nothing fetch-shaped.
204
+ */
205
+ async function runDatasetPage(
206
+ pages: DatasetPages,
207
+ tail: string,
208
+ request: Request,
209
+ env: Env,
210
+ ctx: ExecutionContext,
211
+ ): Promise<Response> {
212
+ const objectType = tail.startsWith("/") ? tail.slice(1) : tail;
213
+ const fn = objectType.includes("/") || objectType === "" ? undefined : pages[objectType];
214
+ if (typeof fn !== "function") throw new Error("registry handler missing");
215
+ let cursor: string | null = null;
216
+ if (request.method !== "GET" && request.method !== "HEAD" && request.body) {
217
+ try {
218
+ const body = (await request.json()) as { cursor?: unknown };
219
+ if (body && typeof body.cursor === "string") cursor = body.cursor;
220
+ } catch {
221
+ cursor = null;
222
+ }
223
+ }
224
+ return Response.json(await fn({ cursor }, env, ctx));
225
+ }
226
+
227
+ function handlerFor(registry: RegistryMap | undefined, name: string): RegistryFetch {
228
+ const value = registry?.[name];
229
+ if (typeof value === "function") return value;
230
+ if (value && typeof value === "object" && typeof value.fetch === "function") {
231
+ const app = value as { fetch: RegistryFetch };
232
+ return (request, env, ctx) => app.fetch(request, env, ctx);
233
+ }
234
+ throw new Error("registry handler missing");
235
+ }
236
+
237
+ function parseRegistryPath(pathname: string): { name: string; tail: string } | null {
238
+ if (pathname !== "/.pple/registry" && !pathname.startsWith(REGISTRY_PATH_PREFIX)) return null;
239
+ const restPath = pathname.startsWith(REGISTRY_PATH_PREFIX)
240
+ ? pathname.slice(REGISTRY_PATH_PREFIX.length)
241
+ : "";
242
+ const slash = restPath.indexOf("/");
243
+ const name = slash === -1 ? restPath : restPath.slice(0, slash);
244
+ const tail = slash === -1 ? "/" : restPath.slice(slash) || "/";
245
+ return { name, tail };
246
+ }
247
+
248
+ function tailUrl(path: string): URL {
249
+ const raw = path.startsWith("/") ? path : `/${path}`;
250
+ return new URL(raw, "https://provider.invalid");
251
+ }
252
+
253
+ function forwardedRequest(url: URL, method: string, headers: Headers, body: BodyInit | null): Request {
254
+ const init: RequestInit & { duplex?: "half" } = { method, headers };
255
+ if (method !== "GET" && method !== "HEAD" && body != null) {
256
+ init.body = body;
257
+ init.duplex = "half";
258
+ }
259
+ return new Request(url, init);
260
+ }
261
+
262
+ function stripPlatformHeaders(request: Request): Request {
263
+ const headers = new Headers(request.headers);
264
+ let changed = false;
265
+ for (const key of [...headers.keys()]) {
266
+ if (key.toLowerCase().startsWith("x-pple-")) {
267
+ headers.delete(key);
268
+ changed = true;
269
+ }
270
+ }
271
+ return changed ? new Request(request, { headers }) : request;
272
+ }
273
+
274
+ function emptyRegistryResponse(): Response {
275
+ return new Response(null, { status: 404, headers: { "cache-control": "no-store" } });
276
+ }
@@ -15,8 +15,8 @@ export interface Env {
15
15
  NOTIFY_API_KEY?: string;
16
16
 
17
17
  // Platform Services (ADR-0022). The App Key is a secret; the URL is plain.
18
- // Both are empty until the platform binds them. Legal Identity reads them
19
- // in src/legal-identity.ts.
18
+ // Both are empty until the platform binds them. `callService` reads them
19
+ // (src/call-service.ts); the Builder does not put the App Key on a request.
20
20
  PPLE_SERVICES_URL?: string;
21
21
  PPLE_APP_KEY?: string;
22
22
 
@@ -24,4 +24,15 @@ export interface Env {
24
24
  // Unset means the default persona; "none" turns the bypass off. Never set in
25
25
  // production — the dev bypass is compiled out of the deployed Worker anyway.
26
26
  DEV_PERSONA?: string;
27
+
28
+ // Local development only: make offline `callService` and `readDataset`
29
+ // return a platform refusal instead of a mock (`not_granted`,
30
+ // `level_not_met`, …). A bare code applies to every entry;
31
+ // `<provider>/<name>=<code>` wins for that entry. Comma-separated.
32
+ // Compiled out with the offline path.
33
+ DEV_REGISTRY_ERROR?: string;
34
+
35
+ // Local development only: the Consumer slug on the emulated registry path.
36
+ // Unset means `dev-consumer`. Compiled out with the dev wrapper.
37
+ DEV_CONSUMER?: string;
27
38
  }
@@ -0,0 +1,17 @@
1
+ // How a Provider test and the dev server build `x-pple-identity` (ADR-0024).
2
+ //
3
+ // Base64url (RFC 4648 §5, no padding) of the UTF-8 JSON `{ actor, effective }`.
4
+ // A Thai name is not a reliable raw header value. `serviceCall` decodes this
5
+ // exact encoding. The platform's copy is
6
+ // `workers/src/services/identity-header.ts`; a test keeps the two strings
7
+ // equal. One function, so the dev wrapper and `callRegistry` cannot drift.
8
+
9
+ import type { Identity } from "../shared/api";
10
+
11
+ /** Base64url of the Identity JSON. ASCII, so it survives an HTTP header. */
12
+ export function encodeIdentityHeader(identity: Identity): string {
13
+ const bytes = new TextEncoder().encode(JSON.stringify(identity));
14
+ let binary = "";
15
+ for (const byte of bytes) binary += String.fromCharCode(byte);
16
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
17
+ }
@@ -9,8 +9,10 @@ import { CreateNoteSchema, NotificationSchema } from "../shared/api";
9
9
  import { authenticate } from "./auth";
10
10
  import { addNote, listNotes } from "./db";
11
11
  import type { Env } from "./env";
12
+ import { devWorkerFetch } from "./dev-registry";
12
13
  import { getFile, listFiles, putFile } from "./files";
13
14
  import { sendNotification } from "./notify";
15
+ import type { PpleWorker, RegistryFetch, RegistryMap } from "./registry";
14
16
 
15
17
  /** Test seam: real fetch by default, an injected fake in tests. */
16
18
  export interface AppOverrides {
@@ -113,4 +115,33 @@ export function createApp(overrides: AppOverrides = {}) {
113
115
  return app;
114
116
  }
115
117
 
116
- export default createApp();
118
+ const api = createApp();
119
+
120
+ const registry: RegistryMap = {};
121
+
122
+ const appFetch: RegistryFetch = (request, env, ctx) => api.fetch(request, env, ctx);
123
+
124
+ /**
125
+ * The Worker entry. `registry` is where a Service handler or a Dataset's page
126
+ * functions live, keyed by the entry name. A Hono app plugs straight in
127
+ * (`registry: { "<name>": app }`). A Dataset is an object of page functions
128
+ * (`registry: { "<name>": { "<objectType>": ({ cursor }, env, ctx) => page } }`).
129
+ * The platform calls a Service at `/.pple/registry/<name>/…` and a Dataset at
130
+ * `/.pple/registry/<name>/<objectType>`. Read a Service caller with
131
+ * `serviceCall(request)` from `./registry`.
132
+ *
133
+ * Under `vite dev` the same path is emulated locally. `import.meta.env.DEV`
134
+ * is false in `vite build`, so that branch and `dev-registry.ts` are absent
135
+ * from the deployed Worker, the same way the Persona bypass is (ADR-0011).
136
+ */
137
+ const worker: PpleWorker = {
138
+ fetch(request, env, ctx) {
139
+ if (import.meta.env.DEV) {
140
+ return devWorkerFetch({ fetch: appFetch, registry }, request, env, ctx);
141
+ }
142
+ return appFetch(request, env, ctx);
143
+ },
144
+ registry,
145
+ };
146
+
147
+ export default worker;
@@ -1,13 +1,14 @@
1
1
  // Legal Identity (CONTEXT.md). A Builder App reads a person's name and
2
- // citizen ID through Platform Services, only under a Legal Identity Grant,
3
- // only at the moment it needs them. This module fetches that record. It does
4
- // not store it.
2
+ // citizen ID from the Private Platform Service `platform/legal-identity`,
3
+ // only under a Use Grant of that entry, only at the moment it needs them.
4
+ // This module fetches that record. It does not store it.
5
5
  //
6
6
  // Offline (`npm run dev` with a Persona) the answer is a fixed fake. Member
7
7
  // Personas have no citizen ID, matching production. The one ID this module
8
8
  // invents fails the Thai checksum, so it can never be mistaken for a real one.
9
9
 
10
10
  import type { KycStatus } from "../shared/api";
11
+ import { callService, platformError, type PlatformErrorCode } from "./call-service";
11
12
  import { resolveDevPersona } from "./dev-personas";
12
13
 
13
14
  export interface LegalIdentity {
@@ -21,9 +22,9 @@ export type LegalIdentityReason =
21
22
  | "not_configured"
22
23
  | "invalid_app_key"
23
24
  | "not_granted"
25
+ | "needs_beta"
24
26
  | "not_verified"
25
27
  | "user_token_invalid"
26
- | "identity_not_enabled"
27
28
  | "upstream_unavailable";
28
29
 
29
30
  export type LegalIdentityResult =
@@ -46,14 +47,10 @@ export const OFFLINE_LEGAL_NAME = "บุคคลทดสอบ ออฟไ
46
47
  */
47
48
  export const OFFLINE_INVALID_ID = "1100000000000";
48
49
 
49
- const REASONS: readonly LegalIdentityReason[] = [
50
- "invalid_app_key",
51
- "not_granted",
52
- "not_verified",
53
- "user_token_invalid",
54
- "identity_not_enabled",
55
- "upstream_unavailable",
56
- ];
50
+ /** What this Service itself answers, with no `x-pple-error` header. */
51
+ const PROVIDER_REASONS = ["not_verified", "user_token_invalid", "upstream_unavailable"] as const;
52
+
53
+ type ProviderReason = (typeof PROVIDER_REASONS)[number];
57
54
 
58
55
  /** True when `id` is 13 digits and its check digit matches. */
59
56
  export function thaiIdChecksumValid(id: string): boolean {
@@ -66,14 +63,17 @@ export function thaiIdChecksumValid(id: string): boolean {
66
63
  /**
67
64
  * The caller's Legal Identity, or why it isn't available.
68
65
  *
69
- * `not_configured` means PPLE_SERVICES_URL or PPLE_APP_KEY is missing — the
70
- * same idea as a notification send before its key is bound. The other reasons
71
- * are what `pple-services` answers.
66
+ * Offline (`npm run dev` with a Persona) this never calls out. Otherwise it
67
+ * wraps `callService(env, "platform/legal-identity", "", { method: "POST" },
68
+ * { userToken })`. `not_configured` means PPLE_SERVICES_URL or PPLE_APP_KEY
69
+ * is missing. `needs_beta` means the Use Grant is held and the app is still
70
+ * Draft. `not_listed`, `identity_unavailable`, `provider_unavailable` and
71
+ * `provider_timeout` are `upstream_unavailable`.
72
72
  */
73
73
  export async function getLegalIdentity(
74
74
  env: LegalIdentityEnv,
75
75
  token: string,
76
- fetchImpl: typeof fetch = globalThis.fetch.bind(globalThis),
76
+ fetchImpl?: typeof fetch,
77
77
  ): Promise<LegalIdentityResult> {
78
78
  // Same seam as authenticate: a Persona stands in for PPLE ID, and the
79
79
  // block is dead-code-eliminated from the production build (ADR-0011).
@@ -87,28 +87,31 @@ export async function getLegalIdentity(
87
87
 
88
88
  let res: Response;
89
89
  try {
90
- res = await fetchImpl(`${env.PPLE_SERVICES_URL.replace(/\/$/, "")}/legal-identity`, {
91
- method: "POST",
92
- headers: {
93
- authorization: `Bearer ${env.PPLE_APP_KEY}`,
94
- "content-type": "application/json",
95
- },
96
- body: JSON.stringify({ token }),
97
- });
90
+ res = await callService(
91
+ env,
92
+ "platform/legal-identity",
93
+ "",
94
+ { method: "POST" },
95
+ { userToken: token, ...(fetchImpl ? { fetchImpl } : {}) },
96
+ );
98
97
  } catch {
99
98
  return { ok: false, reason: "upstream_unavailable" };
100
99
  }
101
100
 
101
+ // A platform refusal carries `x-pple-error`. This Service's own answers
102
+ // do not, so a body `error` is trusted only when the header is absent.
103
+ if (res.headers.get("x-pple-error")) {
104
+ return { ok: false, reason: platformReason(platformError(res)) };
105
+ }
106
+
102
107
  const body = (await res.json().catch(() => ({}))) as {
103
108
  error?: string;
104
109
  source?: string;
105
110
  name?: string;
106
111
  idCardNumber?: string | null;
107
112
  };
108
- // The error string wins over the status: 401 is both an App Key rejection
109
- // and a user-token rejection, and only the body says which.
110
- const reason = REASONS.find((r) => r === body.error);
111
- if (reason) return { ok: false, reason };
113
+ const provider = providerReason(body.error);
114
+ if (provider) return { ok: false, reason: provider };
112
115
  if (!res.ok) return { ok: false, reason: "upstream_unavailable" };
113
116
 
114
117
  if (typeof body.name !== "string" || body.name === "") {
@@ -120,6 +123,22 @@ export async function getLegalIdentity(
120
123
  return { ok: true, legalIdentity: { source, name: body.name, idCardNumber } };
121
124
  }
122
125
 
126
+ function platformReason(code: PlatformErrorCode | null): LegalIdentityReason {
127
+ switch (code) {
128
+ case "invalid_app_key":
129
+ case "not_granted":
130
+ case "needs_beta":
131
+ case "user_token_invalid":
132
+ return code;
133
+ default:
134
+ return "upstream_unavailable";
135
+ }
136
+ }
137
+
138
+ function providerReason(error: string | undefined): ProviderReason | null {
139
+ return PROVIDER_REASONS.find((reason) => reason === error) ?? null;
140
+ }
141
+
123
142
  function offlineLegalIdentity(personaName: string | undefined): LegalIdentityResult {
124
143
  const persona = resolveDevPersona(personaName);
125
144
  const kyc: KycStatus = persona.actor.kyc;
@@ -0,0 +1,132 @@
1
+ // Whether an Identity passes a Dataset Access Level (ADR-0028).
2
+ //
3
+ // A copy of workers/src/shared/registry/levels.ts. This file is copied into
4
+ // a Builder App, which cannot import the platform package. Offline reads
5
+ // (#270) use this copy so a Persona sees the same refusals as production.
6
+ // cli/test/registry-levels.test.ts keeps the two in step.
7
+
8
+ export type AccessLevel =
9
+ | "public"
10
+ | "member"
11
+ | "internal"
12
+ | {
13
+ roles?: readonly string[];
14
+ subs?: readonly string[] | "swept";
15
+ };
16
+
17
+ /** The Identity facts a level reads. Other Identity fields are ignored. */
18
+ export interface LevelIdentity {
19
+ actor: {
20
+ sub: string;
21
+ /** Non-null only while PPLE ID reports an active party membership. */
22
+ membership: { memberSince: string } | null;
23
+ };
24
+ effective: {
25
+ isDelegated: boolean;
26
+ /**
27
+ * PPLE ID sub of the person whose authority is in effect.
28
+ * The Actor's own sub when nobody is delegating. Under Delegation, the
29
+ * supervisor's Zitadel sub, or null when that id is missing. Null matches
30
+ * no custom `subs`.
31
+ */
32
+ sub: string | null;
33
+ activeRole: string;
34
+ extraRoles: readonly string[];
35
+ };
36
+ }
37
+
38
+ export interface PassesLevelOptions {
39
+ /**
40
+ * The snapshot's subs when the level says `subs: "swept"`.
41
+ * The caller passes the list (#267, #269). This function never loads it.
42
+ * Absent means empty, so swept subs admit nobody until a sweep has landed.
43
+ */
44
+ sweptSubs?: readonly string[];
45
+ }
46
+
47
+ /**
48
+ * Live visibility stores `pple-ad:` plus the bare Active Role or Extra Role,
49
+ * the vocabulary `GET /role-options` returns. A custom level uses that
50
+ * same vocabulary, matched by exact string against that prefixed union.
51
+ * Eligible Roles are not part of the union.
52
+ */
53
+ const ROLE_PREFIX = "pple-ad:";
54
+
55
+ function bareRole(role: string): string {
56
+ return role.startsWith(ROLE_PREFIX) ? role.slice(ROLE_PREFIX.length) : role;
57
+ }
58
+
59
+ function viewerRoles(identity: LevelIdentity): Set<string> {
60
+ const roles = new Set<string>();
61
+ const add = (role: unknown) => {
62
+ if (typeof role !== "string" || role === "") return;
63
+ roles.add(`${ROLE_PREFIX}${bareRole(role)}`);
64
+ };
65
+ add(identity.effective.activeRole);
66
+ const extras = identity.effective.extraRoles;
67
+ if (Array.isArray(extras)) {
68
+ for (const extra of extras) add(extra);
69
+ }
70
+ return roles;
71
+ }
72
+
73
+ function passesInternal(identity: LevelIdentity): boolean {
74
+ return typeof identity.effective.activeRole === "string" && identity.effective.activeRole !== "";
75
+ }
76
+
77
+ function passesMember(identity: LevelIdentity): boolean {
78
+ // A delegate is read with the supervisor's authority at every level.
79
+ // A null membership (the lookup failed, or there is none) does not pass on its own.
80
+ if (identity.effective.isDelegated === true) return true;
81
+ if (identity.actor.membership !== null && identity.actor.membership !== undefined) return true;
82
+ return passesInternal(identity);
83
+ }
84
+
85
+ /**
86
+ * Custom `subs` match the Actor's `sub`. Under Delegation they also match
87
+ * `effective.sub`, the supervisor's PPLE ID sub.
88
+ *
89
+ * A null or empty Effective sub matches nothing: a supervisor without a
90
+ * Zitadel id does not pass a level that names them. A session that is not
91
+ * delegated matches the Actor only, even if `effective.sub` says someone else.
92
+ */
93
+ function subsAdmit(identity: LevelIdentity, subs: readonly string[]): boolean {
94
+ if (typeof identity.actor.sub === "string" && identity.actor.sub !== "" && subs.includes(identity.actor.sub)) {
95
+ return true;
96
+ }
97
+ if (identity.effective.isDelegated !== true) return false;
98
+ const supervisor = identity.effective.sub;
99
+ if (typeof supervisor !== "string" || supervisor === "") return false;
100
+ return subs.includes(supervisor);
101
+ }
102
+
103
+ function subsMatch(
104
+ identity: LevelIdentity,
105
+ subs: unknown,
106
+ sweptSubs: readonly string[] | undefined,
107
+ ): boolean {
108
+ const list = subs === "swept" ? sweptSubs : subs;
109
+ if (!Array.isArray(list)) return false;
110
+ const names = list.filter((item): item is string => typeof item === "string");
111
+ return subsAdmit(identity, names);
112
+ }
113
+
114
+ function rolesMatch(identity: LevelIdentity, roles: unknown): boolean {
115
+ if (!Array.isArray(roles)) return false;
116
+ const viewer = viewerRoles(identity);
117
+ return roles.some((role) => typeof role === "string" && viewer.has(role));
118
+ }
119
+
120
+ export function passesLevel(
121
+ identity: LevelIdentity | null,
122
+ level: AccessLevel,
123
+ options?: PassesLevelOptions,
124
+ ): boolean {
125
+ if (level === "public") return true;
126
+ if (identity === null || identity === undefined) return false;
127
+ if (level === "member") return passesMember(identity);
128
+ if (level === "internal") return passesInternal(identity);
129
+ if (typeof level !== "object") return false;
130
+ if (rolesMatch(identity, level.roles)) return true;
131
+ return subsMatch(identity, level.subs, options?.sweptSubs);
132
+ }