@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,391 @@
1
+ // Offline Consumer calls (ADR-0026). Active only where `import.meta.env.DEV`
2
+ // is true and `DEV_PERSONA` is not `"none"` — `vite dev`, not vitest and not
3
+ // the production build. `callService` reaches this module only from that
4
+ // branch, so `vite build` drops it the same way it drops the Persona bypass
5
+ // (ADR-0011).
6
+ //
7
+ // The overlay is `.pple/registry/<provider>/<name>/`, written by the CLI.
8
+ // Vite's `import.meta.glob` skips a dot-folder unless the dot sits in the
9
+ // pattern's fixed base, so the glob below keeps `.pple/registry` in that
10
+ // base. CI has no overlay; tests pass a fixture glob to `answerFromOverlay`
11
+ // and never read `.pple/`.
12
+ //
13
+ // Offline the app is granted, at least Beta, and the entry is listed.
14
+ // Platform refusals come only from `DEV_REGISTRY_ERROR`. `getLegalIdentity`
15
+ // does not use this module: its built-in Persona answer stays in front of
16
+ // `callService`.
17
+
18
+ import type { PlatformErrorCode } from "./call-service";
19
+ import { DEV_PERSONAS, DEFAULT_DEV_PERSONA, resolveDevPersona } from "./dev-personas";
20
+
21
+ /** Statuses for every platform code. Keyed by the union, so a new code fails typecheck here. */
22
+ const PLATFORM_ERROR_STATUS: Record<PlatformErrorCode, number> = {
23
+ invalid_app_key: 401,
24
+ not_listed: 404,
25
+ not_granted: 403,
26
+ needs_beta: 403,
27
+ user_token_invalid: 401,
28
+ identity_unavailable: 503,
29
+ provider_unavailable: 502,
30
+ provider_timeout: 504,
31
+ method_not_allowed: 405,
32
+ not_found: 404,
33
+ invalid_query: 400,
34
+ level_not_met: 403,
35
+ };
36
+
37
+ const MOCK_HEADER = "x-pple-mock";
38
+ const ERROR_HEADER = "x-pple-error";
39
+
40
+ /** Statuses that must not carry a body. A mock that sets one still answers. */
41
+ const NULL_BODY = new Set([101, 204, 205, 304]);
42
+
43
+ export interface MockCase {
44
+ method?: string;
45
+ path?: string;
46
+ persona?: string | readonly string[];
47
+ status?: number;
48
+ headers?: Readonly<Record<string, string>>;
49
+ body?: unknown;
50
+ }
51
+
52
+ export interface OverlayCall {
53
+ entry: string;
54
+ /** Path tail `callService` received, query included. The query is not matched. */
55
+ path: string;
56
+ method?: string;
57
+ userToken?: string;
58
+ /** Active Persona name. A name the Builder added matches only unscoped cases. */
59
+ persona: string;
60
+ devRegistryError?: string;
61
+ }
62
+
63
+ interface IndexedEntry {
64
+ userToken: "required" | "none" | null;
65
+ cases: unknown[];
66
+ }
67
+
68
+ /**
69
+ * The first case that matches, or null. `method` and `persona` are optional
70
+ * on a case and then match anything. An omitted `path` matches any path — a
71
+ * root route has to say `"/"` or it would match every tail. `"/"` is the
72
+ * empty tail. `:param` is one segment. A final `*` segment matches one or
73
+ * more remaining segments. The query is ignored.
74
+ */
75
+ export function matchMockCase(
76
+ cases: readonly unknown[],
77
+ method: string,
78
+ path: string,
79
+ persona: string,
80
+ ): MockCase | null {
81
+ for (const item of cases) {
82
+ if (item === null || typeof item !== "object" || Array.isArray(item)) continue;
83
+ const record = item as Record<string, unknown>;
84
+ if (!methodMatches("method" in record ? record.method : undefined, method)) continue;
85
+ if (!pathMatches("path" in record ? record.path : undefined, path)) continue;
86
+ if (!personaMatches("persona" in record ? record.persona : undefined, persona)) continue;
87
+ return record as MockCase;
88
+ }
89
+ return null;
90
+ }
91
+
92
+ /**
93
+ * The response a Builder observes for one offline call. `files` is an
94
+ * `import.meta.glob` result (path → raw text), either the overlay or a
95
+ * fixture.
96
+ */
97
+ export function answerFromOverlay(
98
+ files: Readonly<Record<string, unknown>>,
99
+ call: OverlayCall,
100
+ ): Response {
101
+ const forced = forcedError(call.devRegistryError, call.entry);
102
+ if (forced) return platformRefusal(forced);
103
+
104
+ const entry = indexOverlay(files).get(call.entry);
105
+ if (!entry) return noMock(missingEntryMessage(call.entry));
106
+
107
+ if (entry.userToken === "required" && !hasToken(call.userToken)) {
108
+ return platformRefusal("user_token_invalid");
109
+ }
110
+
111
+ const method = call.method?.trim() || "GET";
112
+ const matched = matchMockCase(entry.cases, method, call.path, call.persona);
113
+ if (!matched) return noMock(missingRouteMessage(call.entry, method, call.path));
114
+ return mockResponse(call.entry, matched);
115
+ }
116
+
117
+ /** `npm run dev` entry. Reads the overlay glob and the active Persona. */
118
+ export function answerOfflineCall(
119
+ env: { DEV_PERSONA?: string; DEV_REGISTRY_ERROR?: string },
120
+ entry: string,
121
+ path: string,
122
+ init: RequestInit = {},
123
+ options: { userToken?: string } = {},
124
+ ): Response {
125
+ return answerFromOverlay(overlayFiles(), {
126
+ entry,
127
+ path,
128
+ method: init.method,
129
+ userToken: options.userToken,
130
+ persona: activePersonaName(env.DEV_PERSONA),
131
+ devRegistryError: env.DEV_REGISTRY_ERROR,
132
+ });
133
+ }
134
+
135
+ function overlayFiles(): Record<string, unknown> {
136
+ // Production inlines DEV as false and drops the glob, including any
137
+ // overlay files that happen to be on disk at build time. The dot-folder
138
+ // is in the fixed base (ADR-0026).
139
+ if (!import.meta.env.DEV) return {};
140
+ return import.meta.glob("../.pple/registry/**/*", {
141
+ eager: true,
142
+ query: "?raw",
143
+ import: "default",
144
+ });
145
+ }
146
+
147
+ function activePersonaName(configured: string | undefined): string {
148
+ const key = configured?.trim() || DEFAULT_DEV_PERSONA;
149
+ if (Object.hasOwn(DEV_PERSONAS, key)) return key;
150
+ // Unknown names warn and sign in as the default, same as the rest of local dev.
151
+ resolveDevPersona(configured);
152
+ return DEFAULT_DEV_PERSONA;
153
+ }
154
+
155
+ function indexOverlay(files: Readonly<Record<string, unknown>>): Map<string, IndexedEntry> {
156
+ const found = new Map<string, { entryMd?: string; responses?: string }>();
157
+ for (const [file, value] of Object.entries(files)) {
158
+ const text = asText(value);
159
+ if (text === null) continue;
160
+ const path = file.split("?")[0]?.replaceAll("\\", "/") ?? file;
161
+ const entryMd = path.match(/(?:^|\/)([^/]+)\/([^/]+)\/entry\.md$/);
162
+ if (entryMd?.[1] && entryMd[2]) {
163
+ const id = `${entryMd[1]}/${entryMd[2]}`;
164
+ const row = found.get(id) ?? {};
165
+ row.entryMd = text;
166
+ found.set(id, row);
167
+ continue;
168
+ }
169
+ const responses = path.match(/(?:^|\/)([^/]+)\/([^/]+)\/mocks\/responses\.json$/);
170
+ if (responses?.[1] && responses[2]) {
171
+ const id = `${responses[1]}/${responses[2]}`;
172
+ const row = found.get(id) ?? {};
173
+ row.responses = text;
174
+ found.set(id, row);
175
+ }
176
+ }
177
+
178
+ const entries = new Map<string, IndexedEntry>();
179
+ for (const [id, row] of found) {
180
+ entries.set(id, {
181
+ userToken: row.entryMd ? declaredUserToken(row.entryMd) : null,
182
+ cases: row.responses ? parseCases(id, row.responses) : [],
183
+ });
184
+ }
185
+ return entries;
186
+ }
187
+
188
+ function asText(value: unknown): string | null {
189
+ if (typeof value === "string") return value;
190
+ if (value !== null && typeof value === "object" && "default" in value) {
191
+ const inner = (value as { default: unknown }).default;
192
+ if (typeof inner === "string") return inner;
193
+ }
194
+ return null;
195
+ }
196
+
197
+ function declaredUserToken(entryMd: string): "required" | "none" | null {
198
+ const fence = entryMd.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
199
+ const front = fence?.[1];
200
+ if (!front) return null;
201
+ for (const line of front.split(/\r?\n/)) {
202
+ const match = /^userToken:\s*(.*?)\s*$/.exec(line);
203
+ if (!match) continue;
204
+ const value = (match[1] ?? "").replace(/^["']|["']$/g, "");
205
+ if (value === "required" || value === "none") return value;
206
+ return null;
207
+ }
208
+ return null;
209
+ }
210
+
211
+ function parseCases(entry: string, text: string): unknown[] {
212
+ try {
213
+ const parsed = JSON.parse(text) as unknown;
214
+ if (Array.isArray(parsed)) return parsed;
215
+ } catch {
216
+ // Fall through to the warning. A broken file is "no case matched".
217
+ }
218
+ console.warn(
219
+ `callService could not read .pple/registry/${entry}/mocks/responses.json. Run pple registry to refresh it.`,
220
+ );
221
+ return [];
222
+ }
223
+
224
+ function hasToken(userToken: string | undefined): boolean {
225
+ return userToken !== undefined && userToken.trim() !== "";
226
+ }
227
+
228
+ /**
229
+ * A per-entry `<provider>/<name>=<code>` wins over a bare `<code>`. An
230
+ * unknown code is ignored, so a bad per-entry rule falls through to the
231
+ * global one. Checked before the missing-token refusal: `not_granted` and
232
+ * `needs_beta` are answers the platform gives before it looks at the token.
233
+ */
234
+ export function forcedError(spec: string | undefined, entry: string): PlatformErrorCode | null {
235
+ if (spec === undefined || spec.trim() === "") return null;
236
+ const rules: { entry: string | null; code: string }[] = [];
237
+ for (const part of spec.split(",")) {
238
+ const item = part.trim();
239
+ if (item === "") continue;
240
+ const eq = item.indexOf("=");
241
+ if (eq === -1) {
242
+ rules.push({ entry: null, code: item });
243
+ continue;
244
+ }
245
+ const id = item.slice(0, eq).trim();
246
+ const code = item.slice(eq + 1).trim();
247
+ if (id === "" || code === "") {
248
+ console.warn(`DEV_REGISTRY_ERROR ignores "${item}".`);
249
+ continue;
250
+ }
251
+ rules.push({ entry: id, code });
252
+ }
253
+
254
+ const known = rules.filter((rule) => {
255
+ if (isPlatformCode(rule.code)) return true;
256
+ console.warn(`DEV_REGISTRY_ERROR ignores unknown code "${rule.code}".`);
257
+ return false;
258
+ });
259
+ const specific = known.find((rule) => rule.entry === entry);
260
+ if (specific) return specific.code as PlatformErrorCode;
261
+ const global = known.find((rule) => rule.entry === null);
262
+ return global ? (global.code as PlatformErrorCode) : null;
263
+ }
264
+
265
+ function isPlatformCode(code: string): code is PlatformErrorCode {
266
+ return Object.prototype.hasOwnProperty.call(PLATFORM_ERROR_STATUS, code);
267
+ }
268
+
269
+ export function platformRefusal(code: PlatformErrorCode): Response {
270
+ return Response.json(
271
+ { error: code },
272
+ { status: PLATFORM_ERROR_STATUS[code], headers: { [ERROR_HEADER]: code } },
273
+ );
274
+ }
275
+
276
+ function noMock(message: string): Response {
277
+ return Response.json(
278
+ { error: "no_mock", message },
279
+ { status: 404, headers: { [ERROR_HEADER]: "no_mock" } },
280
+ );
281
+ }
282
+
283
+ function missingEntryMessage(entry: string): string {
284
+ return `ไม่มี ${entry} ใน .pple/registry/ — รัน pple registry เพื่อดึงข้อมูลจำลอง`;
285
+ }
286
+
287
+ function missingRouteMessage(entry: string, method: string, path: string): string {
288
+ const tail = path === "" ? "/" : path;
289
+ return `${entry} ไม่มีข้อมูลจำลองสำหรับ ${method.toUpperCase()} ${tail} — รัน pple registry เพื่อรีเฟรช`;
290
+ }
291
+
292
+ function mockResponse(entry: string, matched: MockCase): Response {
293
+ const headers = new Headers();
294
+ const raw = matched.headers;
295
+ if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
296
+ for (const [name, value] of Object.entries(raw)) {
297
+ if (typeof value !== "string") continue;
298
+ if (name.toLowerCase().startsWith("x-pple-")) continue;
299
+ headers.set(name, value);
300
+ }
301
+ }
302
+ headers.set(MOCK_HEADER, entry);
303
+
304
+ const status = httpStatus(matched.status);
305
+ if (NULL_BODY.has(status)) return new Response(null, { status, headers });
306
+
307
+ const body = matched.body;
308
+ if (body === undefined) return new Response(null, { status, headers });
309
+ // A string is text (issue #230). Every other JSON value is a JSON body.
310
+ if (typeof body === "string") {
311
+ if (!headers.has("content-type")) headers.set("content-type", "text/plain;charset=UTF-8");
312
+ return new Response(body, { status, headers });
313
+ }
314
+ if (!headers.has("content-type")) headers.set("content-type", "application/json");
315
+ return new Response(JSON.stringify(body), { status, headers });
316
+ }
317
+
318
+ function httpStatus(value: unknown): number {
319
+ if (typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599) {
320
+ return value;
321
+ }
322
+ return 200;
323
+ }
324
+
325
+ const METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]);
326
+ const LITERAL_SEGMENT = /^[A-Za-z0-9._~-]+$/;
327
+ const PARAM_SEGMENT = /^:[A-Za-z_][A-Za-z0-9_-]*$/;
328
+
329
+ function methodMatches(pattern: unknown, method: string): boolean {
330
+ if (pattern === undefined) return true;
331
+ if (typeof pattern !== "string" || !METHODS.has(pattern.toUpperCase())) return false;
332
+ return pattern.toUpperCase() === method.toUpperCase();
333
+ }
334
+
335
+ function personaMatches(pattern: unknown, persona: string): boolean {
336
+ if (pattern === undefined) return true;
337
+ if (typeof pattern === "string") return pattern === persona;
338
+ if (!Array.isArray(pattern) || pattern.length === 0) return false;
339
+ if (!pattern.every((item) => typeof item === "string" && item !== "")) return false;
340
+ return pattern.includes(persona);
341
+ }
342
+
343
+ function pathMatches(pattern: unknown, requestPath: string): boolean {
344
+ if (pattern === undefined) return true;
345
+ if (typeof pattern !== "string") return false;
346
+ const wanted = patternSegments(pattern);
347
+ if (wanted === null) return false;
348
+ const got = requestSegments(requestPath);
349
+ const wildcard = wanted.at(-1) === "*";
350
+ const fixed = wildcard ? wanted.slice(0, -1) : wanted;
351
+ if (wildcard ? got.length <= fixed.length : got.length !== fixed.length) return false;
352
+ for (let i = 0; i < fixed.length; i++) {
353
+ const want = fixed[i];
354
+ const have = got[i];
355
+ if (want === undefined || have === undefined || have === "") return false;
356
+ if (want.startsWith(":")) continue;
357
+ if (want !== have) return false;
358
+ }
359
+ return !wildcard || got.slice(fixed.length).every((segment) => segment !== "");
360
+ }
361
+
362
+ /** Same path language as the entry validator. `"/"` is the empty tail. Null means the pattern is not that language. */
363
+ function patternSegments(path: string): string[] | null {
364
+ if (path === "/") return [];
365
+ if (path.includes("?") || path.includes("#") || path.includes("\\") || path.includes(" ")) return null;
366
+ const body = path.startsWith("/") ? path.slice(1) : path;
367
+ if (body === "" || body.endsWith("/")) return null;
368
+ const segments = body.split("/");
369
+ for (let i = 0; i < segments.length; i++) {
370
+ const segment = segments[i];
371
+ if (segment === undefined || segment === "") return null;
372
+ if (segment === "*") {
373
+ if (i !== segments.length - 1) return null;
374
+ continue;
375
+ }
376
+ if (segment.includes("*")) return null;
377
+ if (segment.startsWith(":")) {
378
+ if (!PARAM_SEGMENT.test(segment)) return null;
379
+ continue;
380
+ }
381
+ if (!LITERAL_SEGMENT.test(segment)) return null;
382
+ }
383
+ return segments;
384
+ }
385
+
386
+ function requestSegments(path: string): string[] {
387
+ const head = path.split("?")[0]?.split("#")[0] ?? "";
388
+ if (head === "" || head === "/") return [];
389
+ const body = head.startsWith("/") ? head.slice(1) : head;
390
+ return body.split("/");
391
+ }
@@ -5,22 +5,53 @@
5
5
  // works against a persona works against a real PPLE ID user in production.
6
6
  //
7
7
  // This whole module is compiled out of the deployed Worker: it is only reached
8
- // from the `import.meta.env.DEV` branch in src/auth.ts, which `vite build`
9
- // eliminates (docs/adr/0011). Nothing here can run in production.
8
+ // from `import.meta.env.DEV` branches (the Persona bypass in src/auth.ts, the
9
+ // offline Legal Identity answer, offline callService, offline readDataset,
10
+ // and the registry dev wrapper), which
11
+ // `vite build` eliminates (docs/adr/0011). Nothing here can run in production.
10
12
  //
11
13
  // The rule your app code must follow (docs/adr/0012): gate authorization on
12
14
  // `effective`, use `actor` for audit/display only, and honor
13
15
  // `effective.isDelegated`.
14
- import type { Identity } from "../shared/api";
16
+ import type { Identity, KycStatus } from "../shared/api";
15
17
 
16
18
  export const DEFAULT_DEV_PERSONA = "hq-staff";
17
19
 
20
+ /** An active member: verified through membership, not a national-ID check. */
21
+ function memberKyc(verifiedAt: string): KycStatus {
22
+ return { status: "verified", verified: true, verifiedAt, source: "membership" };
23
+ }
24
+
25
+ /** `YYYY-MM-DD` from the same day as this Persona's KYC date. */
26
+ function activeMembership(memberSince: string): Identity["actor"]["membership"] {
27
+ return { memberSince };
28
+ }
29
+
30
+ /** A person acting as themselves: effective.sub is their own actor sub. */
31
+ function own(sub: string): Identity["effective"] {
32
+ return {
33
+ isDelegated: false,
34
+ sub,
35
+ activeRole: "",
36
+ eligibleRoles: [],
37
+ provinces: [],
38
+ extraRoles: [],
39
+ };
40
+ }
41
+
18
42
  export const DEV_PERSONAS: Record<string, Identity> = {
19
43
  // Party HQ staff: every province in scope, no special capabilities. Default.
20
44
  "hq-staff": {
21
- actor: { sub: "dev-hq-staff", name: "Local Dev (HQ)", mobile: "0800000001" },
45
+ actor: {
46
+ sub: "dev-hq-staff",
47
+ name: "Local Dev (HQ)",
48
+ mobile: "0800000001",
49
+ kyc: memberKyc("2024-01-01T00:00:00.000Z"),
50
+ membership: activeMembership("2024-01-01"),
51
+ },
22
52
  effective: {
23
53
  isDelegated: false,
54
+ sub: "dev-hq-staff",
24
55
  activeRole: "hq",
25
56
  eligibleRoles: ["hq"],
26
57
  provinces: ["all"],
@@ -34,9 +65,12 @@ export const DEV_PERSONAS: Record<string, Identity> = {
34
65
  sub: "dev-province-coordinator",
35
66
  name: "Local Dev (จังหวัด)",
36
67
  mobile: "0800000002",
68
+ kyc: memberKyc("2024-02-01T00:00:00.000Z"),
69
+ membership: activeMembership("2024-02-01"),
37
70
  },
38
71
  effective: {
39
72
  isDelegated: false,
73
+ sub: "dev-province-coordinator",
40
74
  activeRole: "province",
41
75
  eligibleRoles: ["province"],
42
76
  provinces: ["เชียงใหม่"],
@@ -50,9 +84,12 @@ export const DEV_PERSONAS: Record<string, Identity> = {
50
84
  sub: "dev-platform-admin",
51
85
  name: "Local Dev (Admin)",
52
86
  mobile: "0800000003",
87
+ kyc: memberKyc("2024-03-01T00:00:00.000Z"),
88
+ membership: activeMembership("2024-03-01"),
53
89
  },
54
90
  effective: {
55
91
  isDelegated: false,
92
+ sub: "dev-platform-admin",
56
93
  activeRole: "hq",
57
94
  eligibleRoles: ["hq"],
58
95
  provinces: ["all"],
@@ -60,18 +97,79 @@ export const DEV_PERSONAS: Record<string, Identity> = {
60
97
  },
61
98
  },
62
99
 
63
- // A delegate acting for a supervisor: effective.* are the SUPERVISOR's role
64
- // and scope, actor.* is the delegate human, and isDelegated is true.
100
+ // A delegate acting for the province coordinator: effective.* are the
101
+ // SUPERVISOR's role, scope, and sub. actor.* is the delegate human, and
102
+ // isDelegated is true. KYC Status is the delegate's own.
65
103
  delegate: {
66
- actor: { sub: "dev-delegate", name: "Local Dev (ผู้รับมอบ)", mobile: "0800000004" },
104
+ actor: {
105
+ sub: "dev-delegate",
106
+ name: "Local Dev (ผู้รับมอบ)",
107
+ mobile: "0800000004",
108
+ kyc: memberKyc("2024-04-01T00:00:00.000Z"),
109
+ membership: activeMembership("2024-04-01"),
110
+ },
67
111
  effective: {
68
112
  isDelegated: true,
113
+ sub: "dev-province-coordinator",
69
114
  activeRole: "province",
70
115
  eligibleRoles: ["province"],
71
116
  provinces: ["เชียงใหม่"],
72
117
  extraRoles: [],
73
118
  },
74
119
  },
120
+
121
+ // No KYC Status at all.
122
+ unverified: {
123
+ actor: {
124
+ sub: "dev-unverified",
125
+ name: "Local Dev (ยังไม่ยืนยัน)",
126
+ mobile: "0800000005",
127
+ kyc: { status: "none", verified: false, verifiedAt: null, source: null },
128
+ membership: null,
129
+ },
130
+ effective: own("dev-unverified"),
131
+ },
132
+
133
+ // Submitted a national ID and waiting on review.
134
+ "kyc-pending": {
135
+ actor: {
136
+ sub: "dev-kyc-pending",
137
+ name: "Local Dev (รอตรวจ)",
138
+ mobile: "0800000006",
139
+ kyc: { status: "pending_review", verified: false, verifiedAt: null, source: null },
140
+ membership: null,
141
+ },
142
+ effective: own("dev-kyc-pending"),
143
+ },
144
+
145
+ // Verified with a national ID, and not a party member.
146
+ "kyc-verified-nonmember": {
147
+ actor: {
148
+ sub: "dev-kyc-verified",
149
+ name: "Local Dev (ยืนยันด้วยบัตร)",
150
+ mobile: "0800000007",
151
+ kyc: {
152
+ status: "verified",
153
+ verified: true,
154
+ verifiedAt: "2024-05-01T00:00:00.000Z",
155
+ source: "kyc",
156
+ },
157
+ membership: null,
158
+ },
159
+ effective: own("dev-kyc-verified"),
160
+ },
161
+
162
+ // A party member with no Role. member and internal differ offline (ADR-0028).
163
+ member: {
164
+ actor: {
165
+ sub: "dev-member",
166
+ name: "Local Dev (สมาชิก)",
167
+ mobile: "0800000008",
168
+ kyc: memberKyc("2024-06-01T00:00:00.000Z"),
169
+ membership: activeMembership("2024-06-01"),
170
+ },
171
+ effective: own("dev-member"),
172
+ },
75
173
  };
76
174
 
77
175
  /** Resolve the configured persona; an unknown name warns and falls back to the