@pplethai/cli 0.6.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 +74 -7
  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 +42 -2
  12. package/templates/golden-path/src/auth.ts +99 -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 +105 -7
  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 +17 -0
  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 +180 -0
  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 +323 -0
  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
+ }
@@ -14,8 +14,25 @@ export interface Env {
14
14
  NOTIFY_API_BASE: string;
15
15
  NOTIFY_API_KEY?: string;
16
16
 
17
+ // Platform Services (ADR-0022). The App Key is a secret; the URL is plain.
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
+ PPLE_SERVICES_URL?: string;
21
+ PPLE_APP_KEY?: string;
22
+
17
23
  // Local development only: which mock persona to sign in as (src/dev-personas.ts).
18
24
  // Unset means the default persona; "none" turns the bypass off. Never set in
19
25
  // production — the dev bypass is compiled out of the deployed Worker anyway.
20
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;
21
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;
@@ -0,0 +1,180 @@
1
+ // Legal Identity (CONTEXT.md). A Builder App reads a person's name and
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
+ //
6
+ // Offline (`npm run dev` with a Persona) the answer is a fixed fake. Member
7
+ // Personas have no citizen ID, matching production. The one ID this module
8
+ // invents fails the Thai checksum, so it can never be mistaken for a real one.
9
+
10
+ import type { KycStatus } from "../shared/api";
11
+ import { callService, platformError, type PlatformErrorCode } from "./call-service";
12
+ import { resolveDevPersona } from "./dev-personas";
13
+
14
+ export interface LegalIdentity {
15
+ source: "membership" | "kyc";
16
+ name: string;
17
+ /** Null for a member who never did a national-ID check. */
18
+ idCardNumber: string | null;
19
+ }
20
+
21
+ export type LegalIdentityReason =
22
+ | "not_configured"
23
+ | "invalid_app_key"
24
+ | "not_granted"
25
+ | "needs_beta"
26
+ | "not_verified"
27
+ | "user_token_invalid"
28
+ | "upstream_unavailable";
29
+
30
+ export type LegalIdentityResult =
31
+ | { ok: true; legalIdentity: LegalIdentity }
32
+ | { ok: false; reason: LegalIdentityReason };
33
+
34
+ interface LegalIdentityEnv {
35
+ PPLE_SERVICES_URL?: string;
36
+ PPLE_APP_KEY?: string;
37
+ /** Which Persona is signed in. `"none"` uses the real Platform Service. */
38
+ DEV_PERSONA?: string;
39
+ }
40
+
41
+ /** The only name the offline path returns. */
42
+ export const OFFLINE_LEGAL_NAME = "บุคคลทดสอบ ออฟไลน์";
43
+
44
+ /**
45
+ * 13 digits that fail the Thai national-ID checksum (expected check digit 8).
46
+ * The offline path uses this and nothing that would pass.
47
+ */
48
+ export const OFFLINE_INVALID_ID = "1100000000000";
49
+
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];
54
+
55
+ /** True when `id` is 13 digits and its check digit matches. */
56
+ export function thaiIdChecksumValid(id: string): boolean {
57
+ if (!/^\d{13}$/.test(id)) return false;
58
+ let sum = 0;
59
+ for (let i = 0; i < 12; i++) sum += Number(id[i]) * (13 - i);
60
+ return (11 - (sum % 11)) % 10 === Number(id[12]);
61
+ }
62
+
63
+ /**
64
+ * The caller's Legal Identity, or why it isn't available.
65
+ *
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
+ */
73
+ export async function getLegalIdentity(
74
+ env: LegalIdentityEnv,
75
+ token: string,
76
+ fetchImpl?: typeof fetch,
77
+ ): Promise<LegalIdentityResult> {
78
+ // Same seam as authenticate: a Persona stands in for PPLE ID, and the
79
+ // block is dead-code-eliminated from the production build (ADR-0011).
80
+ if (import.meta.env.DEV && env.DEV_PERSONA !== "none") {
81
+ return offlineLegalIdentity(env.DEV_PERSONA);
82
+ }
83
+
84
+ if (!env.PPLE_SERVICES_URL || !env.PPLE_APP_KEY) {
85
+ return { ok: false, reason: "not_configured" };
86
+ }
87
+
88
+ let res: Response;
89
+ try {
90
+ res = await callService(
91
+ env,
92
+ "platform/legal-identity",
93
+ "",
94
+ { method: "POST" },
95
+ { userToken: token, ...(fetchImpl ? { fetchImpl } : {}) },
96
+ );
97
+ } catch {
98
+ return { ok: false, reason: "upstream_unavailable" };
99
+ }
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
+
107
+ const body = (await res.json().catch(() => ({}))) as {
108
+ error?: string;
109
+ source?: string;
110
+ name?: string;
111
+ idCardNumber?: string | null;
112
+ };
113
+ const provider = providerReason(body.error);
114
+ if (provider) return { ok: false, reason: provider };
115
+ if (!res.ok) return { ok: false, reason: "upstream_unavailable" };
116
+
117
+ if (typeof body.name !== "string" || body.name === "") {
118
+ return { ok: false, reason: "upstream_unavailable" };
119
+ }
120
+ const source = body.source === "membership" || body.source === "kyc" ? body.source : null;
121
+ if (!source) return { ok: false, reason: "upstream_unavailable" };
122
+ const idCardNumber = typeof body.idCardNumber === "string" ? body.idCardNumber : null;
123
+ return { ok: true, legalIdentity: { source, name: body.name, idCardNumber } };
124
+ }
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
+
142
+ function offlineLegalIdentity(personaName: string | undefined): LegalIdentityResult {
143
+ const persona = resolveDevPersona(personaName);
144
+ const kyc: KycStatus = persona.actor.kyc;
145
+ if (!kyc.verified || (kyc.source !== "membership" && kyc.source !== "kyc")) {
146
+ return { ok: false, reason: "not_verified" };
147
+ }
148
+ // Members have a name and no citizen ID until they do KYC. Anyone else gets
149
+ // the one invalid ID, and a checksum that accidentally passed is dropped.
150
+ let idCardNumber = kyc.source === "membership" ? null : OFFLINE_INVALID_ID;
151
+ if (idCardNumber && thaiIdChecksumValid(idCardNumber)) idCardNumber = null;
152
+ return {
153
+ ok: true,
154
+ legalIdentity: { source: kyc.source, name: OFFLINE_LEGAL_NAME, idCardNumber },
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Where a "start KYC" button sends the person. Locally that is straight back
160
+ * to `returnTo` with `?kyc=cancelled` — there is no PPLE ID to open. In
161
+ * production it is `{ssoBaseUrl}/kyc`, which is the shape pple-today owns
162
+ * (PPLEThai/pple-today#500).
163
+ */
164
+ export function kycStartUrl(input: {
165
+ ssoBaseUrl: string;
166
+ clientId: string;
167
+ returnTo: string;
168
+ dev?: boolean;
169
+ }): string {
170
+ const dev = input.dev ?? import.meta.env.DEV;
171
+ if (dev) {
172
+ const back = new URL(input.returnTo);
173
+ back.searchParams.set("kyc", "cancelled");
174
+ return back.toString();
175
+ }
176
+ const url = new URL("/kyc", input.ssoBaseUrl);
177
+ url.searchParams.set("client_id", input.clientId);
178
+ url.searchParams.set("return_to", input.returnTo);
179
+ return url.toString();
180
+ }
@@ -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
+ }