@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.
- 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 +74 -7
- 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 +42 -2
- package/templates/golden-path/src/auth.ts +99 -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 +105 -7
- 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 +17 -0
- 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 +180 -0
- 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 +323 -0
- 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
|
|
|
@@ -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 `
|
|
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,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
|
|
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
|
*/
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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] : [],
|