@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pplethai/cli",
3
- "version": "0.7.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
 
@@ -80,12 +117,13 @@ them through `/kyc` (`kycStartUrl`) if the app needs the number.
80
117
  Under delegation the Legal Identity is the actor's. If delegated use should
81
118
  be blocked, check `effective.isDelegated`.
82
119
 
83
- A **Legal Identity Grant** comes from a Platform Admin, and the app must be
84
- Beta or Live. Ask a Platform Admin. `getLegalIdentity` reasons:
85
- `not_configured` (bindings missing), `invalid_app_key`, `not_granted` (no
86
- grant, or Draft or retired), `not_verified`, `user_token_invalid`,
87
- `identity_not_enabled` (granted here, not allowlisted on pple-sso),
88
- `upstream_unavailable`.
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`.
89
127
 
90
128
  Locally, try `DEV_PERSONA=unverified`, `kyc-pending`, or
91
129
  `kyc-verified-nonmember`. Member Personas are verified via membership and the
@@ -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,8 +8,10 @@ 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
  */
@@ -28,6 +30,16 @@ export const KycStatusSchema = z.object({
28
30
  });
29
31
  export type KycStatus = z.infer<typeof KycStatusSchema>;
30
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
+
31
43
  export const ActorSchema = z.object({
32
44
  sub: z.string(),
33
45
  name: z.string().nullable(),
@@ -35,12 +47,23 @@ export const ActorSchema = z.object({
35
47
  mobile: z.string().nullable(),
36
48
  /** Never copied onto `effective`. See KycStatusSchema. */
37
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(),
38
55
  });
39
56
  export type Actor = z.infer<typeof ActorSchema>;
40
57
 
41
58
  export const EffectiveSchema = z.object({
42
59
  /** True ⇒ the values below are borrowed from a supervisor (see Actor). */
43
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(),
44
67
  /** The Role in play now (one of `eligibleRoles`), e.g. "hq", "province". */
45
68
  activeRole: z.string(),
46
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
 
@@ -97,9 +103,62 @@ interface RawUserInfo {
97
103
  name?: string;
98
104
  /** KYC Status of the human who logged in. Absent until PPLE ID sends it. */
99
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;
100
111
  ad?: RawAd;
101
112
  }
102
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
+
103
162
  function stringArray(value: unknown): string[] {
104
163
  return Array.isArray(value)
105
164
  ? value.filter((v): v is string => typeof v === "string")
@@ -137,24 +196,30 @@ function toIdentity(body: RawUserInfo): Identity {
137
196
  const effectivePerson = ad?.user;
138
197
  // When nobody is delegating, `actualPpleUser` mirrors `user`.
139
198
  const actualPerson = ad?.actualPpleUser ?? effectivePerson;
199
+ const isDelegated =
200
+ effectivePerson?.id !== undefined &&
201
+ actualPerson?.id !== undefined &&
202
+ effectivePerson.id !== actualPerson.id;
140
203
  const activeRole = ad?.activeRole ?? effectivePerson?.role ?? "";
141
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;
142
207
 
143
208
  return {
144
209
  actor: {
145
- // Top-level `sub` is the OIDC subject of the human who logged in.
146
- sub: body.sub as string,
210
+ sub: actorSub,
147
211
  name: body.name ?? null,
148
212
  mobile: actualPerson?.mobile ?? null,
149
213
  // The human's own KYC Status. A delegated session still reports the
150
214
  // actor's, never the supervisor's, and effective never carries it.
151
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),
152
219
  },
153
220
  effective: {
154
- isDelegated:
155
- effectivePerson?.id !== undefined &&
156
- actualPerson?.id !== undefined &&
157
- effectivePerson.id !== actualPerson.id,
221
+ isDelegated,
222
+ sub: effectiveSub(actorSub, isDelegated, effectivePerson, ad?.actualPpleUser),
158
223
  activeRole,
159
224
  eligibleRoles:
160
225
  eligibleRoles.length > 0 ? eligibleRoles : activeRole ? [activeRole] : [],
@@ -0,0 +1,140 @@
1
+ // A Consumer's call to a Registry Service (ADR-0024). The Builder names the
2
+ // entry and the path tail. This module adds the services address, the App
3
+ // Key, and — only when the caller passes one — the user token. The Builder
4
+ // never sets those.
5
+ //
6
+ // Under `npm run dev`, while `DEV_PERSONA` is not `"none"`, the answer comes
7
+ // from the workspace overlay (ADR-0026, `dev-call-service.ts`). That branch
8
+ // is compiled out of the production build. `getLegalIdentity` still uses its
9
+ // built-in Persona answer before it calls this. Under vitest the fetch is
10
+ // the one the test injected: this module does not read the overlay, which
11
+ // CI does not have.
12
+
13
+ import { answerOfflineCall } from "./dev-call-service";
14
+
15
+ export const PLATFORM_ERROR_HEADER = "x-pple-error";
16
+
17
+ /**
18
+ * Platform refusals, in the order a caller meets them.
19
+ * The first eight are ADR-0024 (a Service call). The last four are ADR-0030
20
+ * (a Dataset read) and are not part of the Service check order.
21
+ */
22
+ export const PLATFORM_ERROR_CODES = [
23
+ "invalid_app_key",
24
+ "not_listed",
25
+ "not_granted",
26
+ "needs_beta",
27
+ "user_token_invalid",
28
+ "identity_unavailable",
29
+ "provider_unavailable",
30
+ "provider_timeout",
31
+ "method_not_allowed",
32
+ "not_found",
33
+ "invalid_query",
34
+ "level_not_met",
35
+ ] as const;
36
+
37
+ export type PlatformErrorCode = (typeof PLATFORM_ERROR_CODES)[number];
38
+
39
+ const PLATFORM_ERROR_SET: ReadonlySet<string> = new Set(PLATFORM_ERROR_CODES);
40
+
41
+ const USER_TOKEN_HEADER = "x-pple-user-token";
42
+
43
+ export interface CallServiceEnv {
44
+ PPLE_SERVICES_URL?: string;
45
+ PPLE_APP_KEY?: string;
46
+ /** `"none"` uses the real service. Anything else, under `npm run dev`, uses the overlay. */
47
+ DEV_PERSONA?: string;
48
+ /**
49
+ * Offline only. `<code>` for every entry, or `<provider>/<name>=<code>`,
50
+ * comma-separated. A per-entry code wins. Unknown codes are ignored.
51
+ */
52
+ DEV_REGISTRY_ERROR?: string;
53
+ }
54
+
55
+ export interface CallServiceOptions {
56
+ /** End-user PPLE ID token. Sent as `x-pple-user-token` only when set. */
57
+ userToken?: string;
58
+ /**
59
+ * Test seam. Under vitest this is required: `callService` does not fall
60
+ * back to `globalThis.fetch`. Production omits it.
61
+ */
62
+ fetchImpl?: typeof fetch;
63
+ }
64
+
65
+ /**
66
+ * `GET`/`POST`/… `services.<domain>/<provider>/<name>/<path>`.
67
+ *
68
+ * `path` `""` is the entry root. A path without a leading `/` or `?` gains
69
+ * one `/`. The App Key is always `Authorization: Bearer`. A `userToken` is
70
+ * the only way the user token is sent; a token on `init` is dropped.
71
+ *
72
+ * The returned `Response` is the platform's or the Provider's, unchanged,
73
+ * except under `npm run dev` (see above), where it is a mock or a dev refusal.
74
+ * Tell a platform refusal apart from a Provider response with `platformError`.
75
+ * `no_mock` is dev-only and is not a platform code.
76
+ */
77
+ export async function callService(
78
+ env: CallServiceEnv,
79
+ entry: string,
80
+ path: string,
81
+ init: RequestInit = {},
82
+ options: CallServiceOptions = {},
83
+ ): Promise<Response> {
84
+ if (!/^[^/\s]+\/[^/\s]+$/.test(entry)) {
85
+ throw new Error('callService entry must be "<provider>/<name>"');
86
+ }
87
+
88
+ // `import.meta.env.DEV` is false in the production build, so this branch
89
+ // and `dev-call-service.ts` are absent from the deployed Worker. Vitest
90
+ // keeps the injected fetch even when a Persona is set.
91
+ if (import.meta.env.DEV && env.DEV_PERSONA !== "none" && !underVitest()) {
92
+ return answerOfflineCall(env, entry, path, init, options);
93
+ }
94
+
95
+ const fetchImpl = resolveFetch(options.fetchImpl);
96
+ if (!env.PPLE_SERVICES_URL || !env.PPLE_APP_KEY) {
97
+ throw new Error("callService needs PPLE_SERVICES_URL and PPLE_APP_KEY");
98
+ }
99
+
100
+ const headers = new Headers(init.headers);
101
+ headers.set("authorization", `Bearer ${env.PPLE_APP_KEY}`);
102
+ if (options.userToken !== undefined) headers.set(USER_TOKEN_HEADER, options.userToken);
103
+ else headers.delete(USER_TOKEN_HEADER);
104
+
105
+ return fetchImpl(serviceUrl(env.PPLE_SERVICES_URL, entry, path), { ...init, headers });
106
+ }
107
+
108
+ /**
109
+ * The platform refusal code on `response`, read from `x-pple-error`.
110
+ *
111
+ * Null when the header is absent or names a code outside ADR-0024 and
112
+ * ADR-0030. A null from an absent header means the Provider answered.
113
+ * `no_mock` is dev-only and stays null here. Callers that must not treat an
114
+ * unknown code as a Provider response check the header itself.
115
+ */
116
+ export function platformError(response: Response): PlatformErrorCode | null {
117
+ const code = response.headers.get(PLATFORM_ERROR_HEADER);
118
+ if (code === null || !PLATFORM_ERROR_SET.has(code)) return null;
119
+ return code as PlatformErrorCode;
120
+ }
121
+
122
+ function serviceUrl(base: string, entry: string, path: string): string {
123
+ const root = `${base.replace(/\/+$/, "")}/${entry}`;
124
+ if (path === "") return root;
125
+ if (path.startsWith("/") || path.startsWith("?")) return `${root}${path}`;
126
+ return `${root}/${path}`;
127
+ }
128
+
129
+ function resolveFetch(fetchImpl: typeof fetch | undefined): typeof fetch {
130
+ if (fetchImpl) return fetchImpl;
131
+ if (underVitest()) {
132
+ throw new Error("callService under vitest uses the injected fetch");
133
+ }
134
+ return globalThis.fetch.bind(globalThis);
135
+ }
136
+
137
+ function underVitest(): boolean {
138
+ const flag = import.meta.env.VITEST;
139
+ return import.meta.env.MODE === "test" || flag === true || flag === "true";
140
+ }