@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pplethai/cli",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,7 +26,8 @@
26
26
  "@types/node": "^22.10.0",
27
27
  "esbuild": "^0.28.1",
28
28
  "typescript": "^5.8.0",
29
- "vitest": "^4.1.10"
29
+ "vitest": "^4.1.10",
30
+ "yaml": "^2.9.1"
30
31
  },
31
32
  "engines": {
32
33
  "node": ">=22"
@@ -3,11 +3,29 @@
3
3
  # settings below. `vite dev` and `wrangler dev` load `.dev.vars` automatically.
4
4
 
5
5
  # Which mock identity to sign in as locally. Defaults to "hq-staff".
6
- # Built-in personas: hq-staff, province-coordinator, platform-admin, delegate
6
+ # Built-in personas: hq-staff, province-coordinator, platform-admin, delegate,
7
+ # member (a party member with no role), unverified, kyc-pending,
8
+ # kyc-verified-nonmember
7
9
  # (add your own in src/dev-personas.ts). Set to "none" to turn the bypass off
8
10
  # and exercise the real PPLE ID sign-in path locally.
11
+ # On /.pple/registry/…, "none" or an unknown name sends no x-pple-identity
12
+ # (the same as userToken: none). An unknown name does not fall back to
13
+ # hq-staff on that path; /api still does.
9
14
  # DEV_PERSONA="province-coordinator"
10
15
 
16
+ # Make offline callService return a platform refusal instead of a mock.
17
+ # A code applies to every entry. provider/name=code applies to one entry
18
+ # and wins over the global code. Separate several with commas.
19
+ # invalid_app_key, not_listed, not_granted, needs_beta, user_token_invalid,
20
+ # identity_unavailable, provider_unavailable, provider_timeout.
21
+ # An unknown code is ignored.
22
+ # DEV_REGISTRY_ERROR="needs_beta"
23
+ # DEV_REGISTRY_ERROR="not_granted,shop/orders=provider_timeout"
24
+
25
+ # Consumer slug reported on the emulated registry path. Defaults to
26
+ # "dev-consumer". The entry id is dev-provider/<name>.
27
+ # DEV_CONSUMER="brave-purple-lion"
28
+
11
29
  # Exercise the notification helper locally. These are normally injected by the
12
30
  # platform at deploy time.
13
31
  # NOTIFY_API_BASE="https://today.peoplesparty.or.th"
@@ -31,12 +31,14 @@ which attach the PPLE ID bearer token. Same origin, so paths are relative
31
31
  Every `/api/*` request carries a PPLE ID bearer token; the Worker resolves it
32
32
  to an `Identity` (`src/auth.ts`, shape in `shared/api.ts`) with two faces:
33
33
 
34
- - **`actor`** — the real human clicking (`sub`, `name`, `mobile`). For audit
35
- and display only.
36
- - **`effective`** — what they may do: `activeRole`, `eligibleRoles`,
37
- `provinces` (scope), `extraRoles` (capabilities like `platform:admin`).
38
- Under delegation `effective` holds the *supervisor's* values and
39
- `effective.isDelegated` is true.
34
+ - **`actor`** — the real human clicking (`sub`, `name`, `mobile`, `membership`).
35
+ For audit and display only. `membership` is `{ memberSince }` while the
36
+ party membership is active, and null otherwise.
37
+ - **`effective`** — what they may do: `sub` (the person whose authority is
38
+ in effect), `activeRole`, `eligibleRoles`, `provinces` (scope), `extraRoles`
39
+ (capabilities like `platform:admin`). Under delegation these are the
40
+ *supervisor's* values, `effective.sub` is that person's PPLE ID sub (or null
41
+ when they have none), and `effective.isDelegated` is true.
40
42
 
41
43
  Where the token comes from:
42
44
 
@@ -47,9 +49,44 @@ Where the token comes from:
47
49
  - Locally (`npm run dev`) there is no login: the Worker signs you in as a mock
48
50
  **persona** (`DEV_PERSONA`, default `hq-staff` — see `src/dev-personas.ts`).
49
51
  To test a province-scoped feature use `DEV_PERSONA=province-coordinator`;
50
- for delegation use `delegate`; for admin-only paths use `platform-admin`.
52
+ for delegation use `delegate`; for admin-only paths use `platform-admin`;
53
+ for a party member with no role use `member`.
51
54
  This bypass is gated on `import.meta.env.DEV`, so it is compiled out of the
52
55
  deployed Worker — there is nothing to set or unset for production.
56
+ - A Service handler lives on `registry` in `src/index.ts`. Locally, curl
57
+ `/.pple/registry/<name>/…`. The dev wrapper sets `x-pple-consumer`
58
+ (`DEV_CONSUMER`, default `dev-consumer`), `x-pple-entry`
59
+ (`dev-provider/<name>`), and `x-pple-identity` from the active Persona.
60
+ `DEV_PERSONA=none` or an unknown persona name omits the Identity (as
61
+ `userToken: none` does) and does not fall back to `hq-staff` on this path.
62
+ Read the caller with `serviceCall(request)`. Tests call `callRegistry` from
63
+ `test/harness.ts`. An entry with no handler throws `registry handler missing`.
64
+ The wrapper is compiled out of the deployed Worker.
65
+ - `callService` locally answers from `.pple/registry/<provider>/<name>/mocks/responses.json`
66
+ as the active persona (`x-pple-mock` names the entry). A `userToken: required`
67
+ entry called with no token is `user_token_invalid`. Nothing matched is
68
+ `no_mock` (404), dev-only, with a Thai message that names `pple registry`.
69
+ `DEV_REGISTRY_ERROR` (`<code>` or `<provider>/<name>=<code>`, commas) returns
70
+ that platform refusal instead. A persona added in `dev-personas.ts` only
71
+ hits cases that name no persona. `DEV_PERSONA=none` calls the real service.
72
+ Vitest uses the injected fetch and does not read the overlay. The overlay
73
+ glob keeps `.pple/registry` in its fixed base, and the whole path is
74
+ compiled out of the deployed Worker. `getLegalIdentity` still uses its own
75
+ offline answer.
76
+ - `readDataset(env, entry, objectType, options)` reads a Dataset Object Type.
77
+ A collection page is `{ objects, next, sweptAt, snapshot }`; a singleton or
78
+ an id lookup is `{ object, sweptAt, snapshot }`. Pass `filter`, `sort` and
79
+ `limit`; pass `id` to look one object up; pass `follow: true` to walk
80
+ `next`. The user token is `options.userToken` and is sent only when set.
81
+ Platform refusals are `x-pple-error` (`not_found`, `invalid_query`,
82
+ `level_not_met`, and the same codes as `callService`). Locally it reads
83
+ `.pple/registry/<provider>/<name>/mocks/<objectType>.json` through the same
84
+ query rules and `passesLevel` against the active Persona. `sweptAt` is the
85
+ mock file's write time. A level whose `subs` list is missing, including
86
+ `subs: swept`, admits nobody offline. `DEV_REGISTRY_ERROR` and `no_mock`
87
+ work as they do for `callService`. `DEV_PERSONA=none` calls the real
88
+ service. Vitest uses the injected fetch. This path is compiled out of the
89
+ deployed Worker.
53
90
 
54
91
  ## Conventions
55
92
 
@@ -62,6 +99,36 @@ Where the token comes from:
62
99
  - **Add a DB table** by writing a new migration and running
63
100
  `npm run db:migrate:local`.
64
101
 
102
+ ## KYC Status and Legal Identity
103
+
104
+ **KYC Status** is `actor.kyc`. Gate on `actor.kyc.verified`. It is never on
105
+ `effective`. After someone returns from `/kyc`, re-read userinfo — `?kyc=` is
106
+ a UI hint only. `kyc.verified` means PPLE ID knows a real person behind the
107
+ account; it does not prove the account holder is the cardholder, and an app
108
+ that needs that check adds its own.
109
+
110
+ A **Legal Identity** (name and citizen ID) is not in userinfo. Call
111
+ `getLegalIdentity(env, token)` only at the moment it is needed, such as
112
+ checkout, and copy it onto that record, such as the receipt. Not on page
113
+ views, and nowhere else — do not log, cache, or store it aside from that
114
+ record. `idCardNumber` may be `null` for a member without a KYC record; send
115
+ them through `/kyc` (`kycStartUrl`) if the app needs the number.
116
+
117
+ Under delegation the Legal Identity is the actor's. If delegated use should
118
+ be blocked, check `effective.isDelegated`.
119
+
120
+ Legal Identity is the Private Platform Service `platform/legal-identity`, so
121
+ the app needs a Use Grant: with the Builder's agreement, file
122
+ `pple request use platform/legal-identity --reason "…"`. Calls flow at Beta
123
+ or Live. `getLegalIdentity` reasons:
124
+ `not_configured` (bindings missing), `invalid_app_key`, `not_granted`,
125
+ `needs_beta` (the grant is held and the app is still Draft), `not_verified`,
126
+ `user_token_invalid`, `upstream_unavailable`.
127
+
128
+ Locally, try `DEV_PERSONA=unverified`, `kyc-pending`, or
129
+ `kyc-verified-nonmember`. Member Personas are verified via membership and the
130
+ offline helper returns `idCardNumber: null`.
131
+
65
132
  ## Before you finish
66
133
 
67
134
  Run these and make sure they pass:
@@ -34,8 +34,33 @@ npm run dev # http://localhost:5173
34
34
  full-stack dev server. No config files, no PPLE Today, no OAuth: you're signed
35
35
  in automatically as a mock **persona** (`hq-staff` by default). To develop as
36
36
  someone else, set `DEV_PERSONA` in `.dev.vars` (see `.dev.vars.example`) to
37
- `hq-staff`, `province-coordinator`, `platform-admin`, or `delegate` — or add
38
- your own in `src/dev-personas.ts`.
37
+ `hq-staff`, `province-coordinator`, `platform-admin`, `delegate`, or `member` —
38
+ or add your own in `src/dev-personas.ts`.
39
+
40
+ `callService` on `npm run dev` answers from
41
+ `.pple/registry/<provider>/<name>/mocks/responses.json` as that persona. A
42
+ missing entry or route is `no_mock` (404) and names `pple registry`. Set
43
+ `DEV_REGISTRY_ERROR` to rehearse a platform refusal such as `not_granted` or
44
+ `provider_timeout`. `DEV_PERSONA=none` calls the real service. This path is
45
+ compiled out of the deployed Worker.
46
+
47
+ `readDataset` on `npm run dev` reads
48
+ `.pple/registry/<provider>/<name>/mocks/<objectType>.json` the same way:
49
+ filters, sort and pages run locally, and the active persona has to pass the
50
+ Object Type's level. `sweptAt` is the time that mock file was written. A
51
+ level that only names people, or a swept list, admits nobody offline — those
52
+ lists are not in the overlay. `DEV_REGISTRY_ERROR` and `no_mock` work as they
53
+ do for `callService`. `DEV_PERSONA=none` reads the real Dataset. This path is
54
+ compiled out of the deployed Worker.
55
+
56
+ To exercise a Service this app provides, curl
57
+ `/.pple/registry/<name>/…` on the dev server. That path is delivered to
58
+ `registry[name]` with the active Persona's Identity and a fake Consumer
59
+ (`dev-consumer`, or `DEV_CONSUMER`). The entry id is `dev-provider/<name>`.
60
+ `DEV_PERSONA=none`, or a persona name that is not defined, sends no
61
+ Identity — the same as an entry that declares `userToken: none`. An unknown
62
+ name does not fall back to `hq-staff` on this path. The wrapper is compiled
63
+ out of the deployed Worker. Tests use `callRegistry` from `test/harness.ts`.
39
64
 
40
65
  Opened inside PPLE Today, the mini-app SDK signs the real user in from the
41
66
  launch URL; opened in a plain browser after deploy, the same SDK runs the
@@ -9,3 +9,6 @@ dist/
9
9
  .cursor/rules/pple-platform.mdc
10
10
  .pple/rules.md
11
11
  .pple/guidance.json
12
+ .pple/registry/
13
+ .pple/registry.json
14
+ .pple/registry-check.json
@@ -0,0 +1,30 @@
1
+ // Embeds an overlay file's mtime for offline readDataset (ADR-0030).
2
+ //
3
+ // The Worker cannot stat a file. Vite replaces `import.meta.glob` of `?mtime`
4
+ // with this module, so `sweptAt` is the time the overlay was written. The
5
+ // glob sits in an `import.meta.env.DEV` branch and is absent from the
6
+ // production build.
7
+
8
+ import fs from "node:fs";
9
+
10
+ export function overlayMtimePlugin() {
11
+ return {
12
+ name: "pple-overlay-mtime",
13
+ enforce: "pre",
14
+ load(id) {
15
+ const split = id.indexOf("?");
16
+ if (split === -1) return null;
17
+ const query = id.slice(split + 1);
18
+ const wants = query.split("&").some((part) => part === "mtime" || part.startsWith("mtime="));
19
+ if (!wants) return null;
20
+ const file = id.slice(0, split);
21
+ let iso = "";
22
+ try {
23
+ iso = fs.statSync(file).mtime.toISOString();
24
+ } catch {
25
+ iso = "";
26
+ }
27
+ return `export default ${JSON.stringify(iso)};`;
28
+ },
29
+ };
30
+ }
@@ -10,6 +10,7 @@
10
10
  "@pplethai/components": "^1.4.3",
11
11
  "@pplethai/pple-today-miniapp-sdk": "^0.1.4",
12
12
  "hono": "^4.12.30",
13
+ "lucide-react": "^0.469.0",
13
14
  "react": "^19.2.0",
14
15
  "react-dom": "^19.2.0",
15
16
  "react-hook-form": "^7.81.0",
@@ -25,7 +26,8 @@
25
26
  "typescript": "^5.8.0",
26
27
  "vite": "^8.1.5",
27
28
  "vitest": "^4.1.10",
28
- "wrangler": "^4.112.0"
29
+ "wrangler": "^4.112.0",
30
+ "yaml": "^2.9.1"
29
31
  }
30
32
  },
31
33
  "node_modules/@cloudflare/kv-asset-handler": {
@@ -4246,6 +4248,22 @@
4246
4248
  }
4247
4249
  }
4248
4250
  },
4251
+ "node_modules/yaml": {
4252
+ "version": "2.9.1",
4253
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
4254
+ "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
4255
+ "dev": true,
4256
+ "license": "ISC",
4257
+ "bin": {
4258
+ "yaml": "bin.mjs"
4259
+ },
4260
+ "engines": {
4261
+ "node": ">= 14.6"
4262
+ },
4263
+ "funding": {
4264
+ "url": "https://github.com/sponsors/eemeli"
4265
+ }
4266
+ },
4249
4267
  "node_modules/youch": {
4250
4268
  "version": "4.1.0-beta.10",
4251
4269
  "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz",
@@ -6,7 +6,7 @@
6
6
  "scripts": {
7
7
  "predev": "wrangler d1 migrations apply DB --local",
8
8
  "dev": "vite dev",
9
- "build": "vite build",
9
+ "build": "vite build && node scripts/assert-dev-compiled-out.mjs",
10
10
  "preview": "vite preview",
11
11
  "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.worker.json",
12
12
  "test": "vitest run",
@@ -35,6 +35,7 @@
35
35
  "typescript": "^5.8.0",
36
36
  "vite": "^8.1.5",
37
37
  "vitest": "^4.1.10",
38
- "wrangler": "^4.112.0"
38
+ "wrangler": "^4.112.0",
39
+ "yaml": "^2.9.1"
39
40
  }
40
41
  }
@@ -0,0 +1,42 @@
1
+ // The deployed Worker is dist/pple_app/index.js. Vite inlines
2
+ // `import.meta.env.DEV` to false and drops the Persona bypass and the
3
+ // offline registry wrapper (ADR-0011). Strings that exist only in those
4
+ // modules must not survive. The client bundle is a different compile.
5
+
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const bundle = path.resolve(
11
+ path.dirname(fileURLToPath(import.meta.url)),
12
+ "../dist/pple_app/index.js",
13
+ );
14
+
15
+ if (!fs.existsSync(bundle)) {
16
+ console.error(`production Worker bundle not found at ${bundle}`);
17
+ process.exit(1);
18
+ }
19
+
20
+ const source = fs.readFileSync(bundle, "utf8").replace(/\/\/# sourceMappingURL[\s\S]*$/, "");
21
+
22
+ const forbidden = [
23
+ ["dev-hq-staff", "Persona bypass"],
24
+ ["Local Dev (HQ)", "Persona bypass"],
25
+ ["บุคคลทดสอบ ออฟไลน์", "offline Legal Identity"],
26
+ ["dev-consumer", "registry dev wrapper"],
27
+ ["DEV_CONSUMER", "registry dev wrapper"],
28
+ ["dev-provider", "registry dev wrapper"],
29
+ ["registry handler missing", "registry dev wrapper"],
30
+ ["x-pple-mock", "offline callService"],
31
+ ["../.pple/registry", "offline callService overlay glob"],
32
+ ["ไม่มีข้อมูลจำลอง", "offline callService no_mock"],
33
+ ];
34
+
35
+ const found = forbidden.filter(([needle]) => source.includes(needle));
36
+ if (found.length > 0) {
37
+ console.error(`production Worker still contains dev-only code (${bundle}):`);
38
+ for (const [needle, why] of found) console.error(` ${JSON.stringify(needle)} (${why})`);
39
+ process.exit(1);
40
+ }
41
+
42
+ console.log("production Worker has no dev-only registry or Persona code");
@@ -8,22 +8,62 @@ import { z } from "zod";
8
8
  * The signed-in caller, as the Worker resolves it from the PPLE ID token
9
9
  * (src/auth.ts). Two faces:
10
10
  *
11
- * - `actor` — the real human clicking. Use for audit and display only.
12
- * - `effective` — what they may do. Gate every authorization decision on this,
11
+ * - `actor` — the real human clicking (`sub`, name, mobile, KYC Status,
12
+ * party membership). Use for audit and display only.
13
+ * - `effective` — what they may do, including `sub` of the person whose
14
+ * authority is in effect. Gate every authorization decision on this,
13
15
  * never on `actor`. Under Delegation these are the supervisor's values and
14
16
  * `isDelegated` is true. See docs/adr/0012 and CONTEXT.md.
15
17
  */
18
+ /**
19
+ * KYC Status (CONTEXT.md). Whether PPLE ID knows a real person behind this
20
+ * account. It carries no name and no citizen ID, and it belongs to the human,
21
+ * so it sits on the actor — a delegate does not inherit a supervisor's status.
22
+ * `verified` does not prove the account holder is the cardholder.
23
+ */
24
+ export const KycStatusSchema = z.object({
25
+ status: z.enum(["none", "pending_review", "verified", "rejected"]),
26
+ verified: z.boolean(),
27
+ verifiedAt: z.string().nullable(),
28
+ /** `membership` for an active member, `kyc` for a national-ID check, or null. */
29
+ source: z.enum(["membership", "kyc"]).nullable(),
30
+ });
31
+ export type KycStatus = z.infer<typeof KycStatusSchema>;
32
+
33
+ /**
34
+ * Active party membership as the Identity carries it. `memberSince` is the
35
+ * ISO date (`YYYY-MM-DD`) PPLE ID reports. The actor field is null when the
36
+ * person is not an active member, or the lookup failed.
37
+ */
38
+ export const MembershipSchema = z.object({
39
+ memberSince: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
40
+ });
41
+ export type Membership = z.infer<typeof MembershipSchema>;
42
+
16
43
  export const ActorSchema = z.object({
17
44
  sub: z.string(),
18
45
  name: z.string().nullable(),
19
46
  /** Thai mobile number, the join key across PPLE systems. May be null. */
20
47
  mobile: z.string().nullable(),
48
+ /** Never copied onto `effective`. See KycStatusSchema. */
49
+ kyc: KycStatusSchema,
50
+ /**
51
+ * The human's party membership. Never copied onto `effective`: a delegate
52
+ * does not inherit a supervisor's membership. A lookup error is null.
53
+ */
54
+ membership: MembershipSchema.nullable(),
21
55
  });
22
56
  export type Actor = z.infer<typeof ActorSchema>;
23
57
 
24
58
  export const EffectiveSchema = z.object({
25
59
  /** True ⇒ the values below are borrowed from a supervisor (see Actor). */
26
60
  isDelegated: z.boolean(),
61
+ /**
62
+ * PPLE ID sub of the person whose authority is in effect. The actor's own
63
+ * sub when nobody is delegating. Under Delegation, the supervisor's Zitadel
64
+ * sub, or null when PPLE ID has none. Null matches no custom `subs`.
65
+ */
66
+ sub: z.string().min(1).nullable(),
27
67
  /** The Role in play now (one of `eligibleRoles`), e.g. "hq", "province". */
28
68
  activeRole: z.string(),
29
69
  eligibleRoles: z.array(z.string()),
@@ -10,7 +10,8 @@
10
10
  // • actor — the real human clicking (audit/display only)
11
11
  // • effective — what they may do (gate authorization on this)
12
12
  // Under Delegation, effective.* are the supervisor's and effective.isDelegated
13
- // is true, while actor stays the delegate. See docs/adr/0012.
13
+ // is true, while actor stays the delegate. effective.sub is the PPLE ID sub
14
+ // of the person whose authority is in effect. See docs/adr/0012.
14
15
  import type { Identity } from "../shared/api";
15
16
  import { resolveDevPersona } from "./dev-personas";
16
17
 
@@ -74,6 +75,11 @@ interface RawPerson {
74
75
  id?: number;
75
76
  role?: string;
76
77
  mobile?: string;
78
+ /**
79
+ * The person's Zitadel user id, which is their PPLE ID sub. Read only for
80
+ * the supervisor under Delegation. Never `sso_user_id`.
81
+ */
82
+ zitadel_user_id?: unknown;
77
83
  metadata?: { ad?: { extra_roles?: unknown; is_province_coordinator?: unknown } };
78
84
  }
79
85
 
@@ -85,12 +91,74 @@ interface RawAd {
85
91
  eligibleProvinces?: unknown;
86
92
  }
87
93
 
94
+ interface RawKyc {
95
+ status?: string;
96
+ verified?: boolean;
97
+ verifiedAt?: string | null;
98
+ source?: string | null;
99
+ }
100
+
88
101
  interface RawUserInfo {
89
102
  sub?: string;
90
103
  name?: string;
104
+ /** KYC Status of the human who logged in. Absent until PPLE ID sends it. */
105
+ kyc?: RawKyc;
106
+ /**
107
+ * Party membership of the logged-in human, beside `ad` and `kyc`.
108
+ * PPLE ID sends an object or null. See membershipOf.
109
+ */
110
+ membership?: unknown;
91
111
  ad?: RawAd;
92
112
  }
93
113
 
114
+ /** `YYYY-MM-DD`, the shape of userinfo `membership.memberSince`. */
115
+ const MEMBER_SINCE = /^\d{4}-\d{2}-\d{2}$/;
116
+
117
+ /**
118
+ * Reduce userinfo `membership` to `{ memberSince }` or null.
119
+ *
120
+ * PPLE ID sends `{ type, memberSince, registeredAddress }` only while the
121
+ * membership is legally active, looked up by the logged-in human's phone.
122
+ * A lookup error arrives as null. The Identity keeps the date and drops the
123
+ * address. A missing or odd value is null, so it cannot pass `member`.
124
+ */
125
+ function membershipOf(value: unknown): Identity["actor"]["membership"] {
126
+ if (value === null || value === undefined || typeof value !== "object") return null;
127
+ const memberSince = (value as { memberSince?: unknown }).memberSince;
128
+ if (typeof memberSince !== "string" || !MEMBER_SINCE.test(memberSince)) return null;
129
+ return { memberSince };
130
+ }
131
+
132
+ /** Zitadel user ids are decimal strings. Anything else is not a PPLE ID sub. */
133
+ const ZITADEL_SUB = /^\d+$/;
134
+
135
+ /**
136
+ * `effective.sub` (ADR-0028, #266).
137
+ *
138
+ * Not delegated (`isDelegated` false): the Actor's `sub` from the token. It
139
+ * is never read from `ad`. PPLE ID falls back silently to the logged-in row
140
+ * when the supervisor row is missing, and that reads as not delegated.
141
+ *
142
+ * Delegated: `ad.user` is the supervisor only when the logged-in human's role
143
+ * is `delegate`. The supervisor's sub is `ad.user.zitadel_user_id`.
144
+ * `sso_user_id` is legacy (a Supabase UUID or null) and is never read. Any
145
+ * other delegated shape, or a missing, null, or non-Zitadel id, is null. It
146
+ * is never the Actor's sub, because `effective` is someone else's authority
147
+ * here. Null matches no custom `subs`.
148
+ */
149
+ function effectiveSub(
150
+ actorSub: string,
151
+ isDelegated: boolean,
152
+ user: RawPerson | undefined,
153
+ actual: RawPerson | undefined,
154
+ ): string | null {
155
+ if (!isDelegated) return actorSub;
156
+ if (actual?.role !== "delegate") return null;
157
+ const zitadel = user?.zitadel_user_id;
158
+ if (typeof zitadel !== "string" || !ZITADEL_SUB.test(zitadel)) return null;
159
+ return zitadel;
160
+ }
161
+
94
162
  function stringArray(value: unknown): string[] {
95
163
  return Array.isArray(value)
96
164
  ? value.filter((v): v is string => typeof v === "string")
@@ -108,26 +176,50 @@ function extraRolesOf(person: RawPerson | undefined): string[] {
108
176
  return roles;
109
177
  }
110
178
 
179
+ const KYC_STATUSES = ["none", "pending_review", "verified", "rejected"] as const;
180
+ const KYC_SOURCES = ["membership", "kyc"] as const;
181
+
182
+ /** A missing or odd `kyc` is "PPLE ID has not verified this person", not an error. */
183
+ function kycOf(raw: RawKyc | undefined): Identity["actor"]["kyc"] {
184
+ const status = KYC_STATUSES.find((s) => s === raw?.status) ?? "none";
185
+ const source = KYC_SOURCES.find((s) => s === raw?.source) ?? null;
186
+ return {
187
+ status,
188
+ verified: raw?.verified === true,
189
+ verifiedAt: typeof raw?.verifiedAt === "string" ? raw.verifiedAt : null,
190
+ source,
191
+ };
192
+ }
193
+
111
194
  function toIdentity(body: RawUserInfo): Identity {
112
195
  const ad = body.ad;
113
196
  const effectivePerson = ad?.user;
114
197
  // When nobody is delegating, `actualPpleUser` mirrors `user`.
115
198
  const actualPerson = ad?.actualPpleUser ?? effectivePerson;
199
+ const isDelegated =
200
+ effectivePerson?.id !== undefined &&
201
+ actualPerson?.id !== undefined &&
202
+ effectivePerson.id !== actualPerson.id;
116
203
  const activeRole = ad?.activeRole ?? effectivePerson?.role ?? "";
117
204
  const eligibleRoles = stringArray(ad?.eligibleRoles);
205
+ // Top-level `sub` is the OIDC subject of the human who logged in.
206
+ const actorSub = body.sub as string;
118
207
 
119
208
  return {
120
209
  actor: {
121
- // Top-level `sub` is the OIDC subject of the human who logged in.
122
- sub: body.sub as string,
210
+ sub: actorSub,
123
211
  name: body.name ?? null,
124
212
  mobile: actualPerson?.mobile ?? null,
213
+ // The human's own KYC Status. A delegated session still reports the
214
+ // actor's, never the supervisor's, and effective never carries it.
215
+ kyc: kycOf(body.kyc),
216
+ // The human's own membership, likewise. Top-level userinfo, not `ad.user`
217
+ // (under Delegation `ad.user` is the supervisor).
218
+ membership: membershipOf(body.membership),
125
219
  },
126
220
  effective: {
127
- isDelegated:
128
- effectivePerson?.id !== undefined &&
129
- actualPerson?.id !== undefined &&
130
- effectivePerson.id !== actualPerson.id,
221
+ isDelegated,
222
+ sub: effectiveSub(actorSub, isDelegated, effectivePerson, ad?.actualPpleUser),
131
223
  activeRole,
132
224
  eligibleRoles:
133
225
  eligibleRoles.length > 0 ? eligibleRoles : activeRole ? [activeRole] : [],