@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.
- package/dist/bin.js +171 -22
- package/package.json +3 -2
- package/templates/golden-path/.dev.vars.example +19 -1
- package/templates/golden-path/CLAUDE.md +51 -13
- package/templates/golden-path/README.md +27 -2
- package/templates/golden-path/gitignore +3 -0
- package/templates/golden-path/overlay-mtime.mjs +30 -0
- package/templates/golden-path/package-lock.json +19 -1
- package/templates/golden-path/package.json +3 -2
- package/templates/golden-path/scripts/assert-dev-compiled-out.mjs +42 -0
- package/templates/golden-path/shared/api.ts +25 -2
- package/templates/golden-path/src/auth.ts +72 -7
- package/templates/golden-path/src/call-service.ts +140 -0
- package/templates/golden-path/src/dataset-query.ts +470 -0
- package/templates/golden-path/src/dataset-schema.ts +398 -0
- package/templates/golden-path/src/dev-call-service.ts +391 -0
- package/templates/golden-path/src/dev-personas.ts +49 -15
- package/templates/golden-path/src/dev-read-dataset.ts +429 -0
- package/templates/golden-path/src/dev-registry.ts +276 -0
- package/templates/golden-path/src/env.ts +13 -2
- package/templates/golden-path/src/identity-header.ts +17 -0
- package/templates/golden-path/src/index.ts +32 -1
- package/templates/golden-path/src/legal-identity.ts +47 -28
- package/templates/golden-path/src/levels.ts +132 -0
- package/templates/golden-path/src/read-dataset.ts +376 -0
- package/templates/golden-path/src/registry.ts +122 -0
- package/templates/golden-path/src/vite-env.d.ts +13 -0
- package/templates/golden-path/test/call-service.test.ts +178 -0
- package/templates/golden-path/test/dev-call-service.test.ts +279 -0
- package/templates/golden-path/test/dev-read-dataset.test.ts +270 -0
- package/templates/golden-path/test/dev-registry.test.ts +375 -0
- package/templates/golden-path/test/effective-sub.test.ts +101 -0
- package/templates/golden-path/test/fixtures/overlay/directory/branches/entry.md +85 -0
- package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/audience.json +3 -0
- package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/named.json +3 -0
- package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/offices.json +6 -0
- package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/roster.json +3 -0
- package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/summary.json +1 -0
- package/templates/golden-path/test/fixtures/overlay/platform/legal-identity/entry.md +8 -0
- package/templates/golden-path/test/fixtures/overlay/platform/legal-identity/mocks/responses.json +13 -0
- package/templates/golden-path/test/fixtures/overlay/shop/catalog/entry.md +8 -0
- package/templates/golden-path/test/fixtures/overlay/shop/catalog/mocks/responses.json +7 -0
- package/templates/golden-path/test/fixtures/overlay/shop/orders/entry.md +12 -0
- package/templates/golden-path/test/fixtures/overlay/shop/orders/mocks/responses.json +36 -0
- package/templates/golden-path/test/harness.ts +101 -16
- package/templates/golden-path/test/legal-identity.test.ts +91 -24
- package/templates/golden-path/test/membership.test.ts +105 -0
- package/templates/golden-path/test/read-dataset.test.ts +214 -0
- package/templates/golden-path/test/service-call.test.ts +166 -0
- package/templates/golden-path/vite.config.ts +2 -1
- 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.
|
|
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`).
|
|
35
|
-
and display only.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
`
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
`
|
|
86
|
-
|
|
87
|
-
`
|
|
88
|
-
`
|
|
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 `
|
|
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
|
|
@@ -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
|
|
12
|
-
*
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|