@pplethai/cli 0.3.0 → 0.4.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/commands/init.js +32 -3
- package/package.json +1 -1
- package/skills/pple-platform/references/pple-id-auth.md +14 -6
- package/templates/golden-path/.dev.vars.example +10 -13
- package/templates/golden-path/CLAUDE.md +22 -7
- package/templates/golden-path/README.md +13 -8
- package/templates/golden-path/package.json +1 -0
- package/templates/golden-path/shared/api.ts +36 -3
- package/templates/golden-path/src/auth.ts +101 -16
- package/templates/golden-path/src/dev-personas.ts +88 -0
- package/templates/golden-path/src/env.ts +4 -2
- package/templates/golden-path/src/index.ts +8 -7
- package/templates/golden-path/src/vite-env.d.ts +14 -0
- package/templates/golden-path/test/harness.ts +6 -2
- package/templates/golden-path/test/smoke.test.ts +98 -15
- package/templates/golden-path/web/App.tsx +1 -1
- package/templates/golden-path/web/main.tsx +2 -2
- package/templates/golden-path/web/pple.ts +24 -7
package/dist/commands/init.js
CHANGED
|
@@ -49,7 +49,32 @@ export async function init(deps, options) {
|
|
|
49
49
|
if (await hasLocalChanges(deps, targetDir)) {
|
|
50
50
|
await recordChanges(deps, targetDir, "เริ่มจากเทมเพลตมาตรฐานของ PPLE", access);
|
|
51
51
|
}
|
|
52
|
-
|
|
52
|
+
// Install dependencies and bring the local database up before handing over,
|
|
53
|
+
// so the Builder (or their agent) can `npm run dev` straight away. Done after
|
|
54
|
+
// the scaffold commit so node_modules/.wrangler stay out of it.
|
|
55
|
+
const setupOk = await setupWorkspace(deps, targetDir);
|
|
56
|
+
report(deps, app, targetDir, setupOk);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Install packages and apply local migrations. Non-fatal by design: the app
|
|
60
|
+
* already exists and is committed, so a flaky network or wrong Node version
|
|
61
|
+
* must not strand the Builder — we warn, and `report` prints the two commands
|
|
62
|
+
* to run by hand.
|
|
63
|
+
*/
|
|
64
|
+
async function setupWorkspace(deps, targetDir) {
|
|
65
|
+
deps.out.say("กำลังติดตั้งแพ็กเกจและเตรียมฐานข้อมูลบนเครื่อง…");
|
|
66
|
+
const steps = [
|
|
67
|
+
["npm", ["install"]],
|
|
68
|
+
["npm", ["run", "db:migrate:local"]],
|
|
69
|
+
];
|
|
70
|
+
for (const [command, args] of steps) {
|
|
71
|
+
const result = await deps
|
|
72
|
+
.run(command, args, { cwd: targetDir })
|
|
73
|
+
.catch(() => null);
|
|
74
|
+
if (!result || result.code !== 0)
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
53
78
|
}
|
|
54
79
|
async function resolveName(deps, name) {
|
|
55
80
|
if (await peekLink(deps)) {
|
|
@@ -116,7 +141,7 @@ async function isAlreadyBuilt(targetDir) {
|
|
|
116
141
|
.then(() => true)
|
|
117
142
|
.catch(() => false);
|
|
118
143
|
}
|
|
119
|
-
function report(deps, app, targetDir) {
|
|
144
|
+
function report(deps, app, targetDir, setupOk) {
|
|
120
145
|
const folder = path.basename(targetDir);
|
|
121
146
|
deps.out.say();
|
|
122
147
|
deps.out.say(`"${app.name}" พร้อมให้ทำงานแล้วใน ${folder}/`);
|
|
@@ -124,7 +149,11 @@ function report(deps, app, targetDir) {
|
|
|
124
149
|
deps.out.say();
|
|
125
150
|
deps.out.say("ขั้นต่อไป:");
|
|
126
151
|
deps.out.say(` cd ${folder}`);
|
|
127
|
-
|
|
152
|
+
if (!setupOk) {
|
|
153
|
+
// Auto-setup didn't finish — the Builder runs these once by hand.
|
|
154
|
+
deps.out.say(" npm install");
|
|
155
|
+
deps.out.say(" npm run db:migrate:local");
|
|
156
|
+
}
|
|
128
157
|
deps.out.say(" npm run dev # ดูผลบนเครื่องนี้");
|
|
129
158
|
deps.out.say(" pple deploy # นำขึ้นใช้งานจริง");
|
|
130
159
|
}
|
package/package.json
CHANGED
|
@@ -40,12 +40,20 @@ import { PPLEMiniApp } from "@pplethai/pple-today-miniapp-sdk";
|
|
|
40
40
|
// Production (Today or browser): sdk.init() → getAccessToken() → { kind: "signed-in" }
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
OAuth client values
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
(
|
|
43
|
+
OAuth client values are provisioned per app — Builders do not register
|
|
44
|
+
clients by hand. The SPA reads them at **build time** via Vite
|
|
45
|
+
(`VITE_OAUTH_URL`, `VITE_OAUTH_CLIENT_ID`), so the platform bakes them into
|
|
46
|
+
the `env:` block of the deploy workflow it owns in each app repo
|
|
47
|
+
(`.github/workflows/deploy.yml`), where `npm run build` can see them. Because
|
|
48
|
+
that workflow is reconciled on every deploy (ADR-0004), an app self-heals
|
|
49
|
+
these values on its next deploy. `VITE_OAUTH_REDIRECT_URI` is not baked in:
|
|
50
|
+
it defaults to the app's own origin (`window.location.origin`), which matches
|
|
51
|
+
the mini-app URL the platform registers as an allowed redirect on that OAuth
|
|
52
|
+
client (`docs/integration/today-sso-changes.md` §5).
|
|
53
|
+
|
|
54
|
+
A build that runs without `VITE_OAUTH_URL` set produces a bundle with an
|
|
55
|
+
empty `oauthUrl`; the template's `bootstrap()` catches that and shows an
|
|
56
|
+
"app is misconfigured" error instead of the raw `oidc-client-ts` failure.
|
|
49
57
|
|
|
50
58
|
## Worker: validating the token
|
|
51
59
|
|
|
@@ -1,17 +1,14 @@
|
|
|
1
|
-
#
|
|
2
|
-
#
|
|
3
|
-
# automatically.
|
|
1
|
+
# Optional local-development overrides. `npm run dev` works with NO `.dev.vars`
|
|
2
|
+
# at all — copy this to `.dev.vars` (gitignored) only if you want one of the
|
|
3
|
+
# settings below. `vite dev` and `wrangler dev` load `.dev.vars` automatically.
|
|
4
4
|
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# NEVER set DEV_AUTH in production — the platform never provisions it, and the
|
|
11
|
-
# Worker treats its absence as "real auth only".
|
|
12
|
-
DEV_AUTH="dev-token"
|
|
5
|
+
# Which mock identity to sign in as locally. Defaults to "hq-staff".
|
|
6
|
+
# Built-in personas: hq-staff, province-coordinator, platform-admin, delegate
|
|
7
|
+
# (add your own in src/dev-personas.ts). Set to "none" to turn the bypass off
|
|
8
|
+
# and exercise the real PPLE ID sign-in path locally.
|
|
9
|
+
# DEV_PERSONA="province-coordinator"
|
|
13
10
|
|
|
14
|
-
#
|
|
15
|
-
#
|
|
11
|
+
# Exercise the notification helper locally. These are normally injected by the
|
|
12
|
+
# platform at deploy time.
|
|
16
13
|
# NOTIFY_API_BASE="https://today.peoplesparty.or.th"
|
|
17
14
|
# NOTIFY_API_KEY="your-app-notification-key"
|
|
@@ -27,14 +27,29 @@ which attach the PPLE ID bearer token. Same origin, so paths are relative
|
|
|
27
27
|
|
|
28
28
|
## How auth works
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
Every `/api/*` request carries a PPLE ID bearer token; the Worker resolves it
|
|
31
|
+
to an `Identity` (`src/auth.ts`, shape in `shared/api.ts`) with two faces:
|
|
32
|
+
|
|
33
|
+
- **`actor`** — the real human clicking (`sub`, `name`, `mobile`). For audit
|
|
34
|
+
and display only.
|
|
35
|
+
- **`effective`** — what they may do: `activeRole`, `eligibleRoles`,
|
|
36
|
+
`provinces` (scope), `extraRoles` (capabilities like `platform:admin`).
|
|
37
|
+
**Gate every authorization decision on `effective`, never on `actor`.** Under
|
|
38
|
+
delegation these are the *supervisor's* values and `effective.isDelegated`
|
|
39
|
+
is true.
|
|
40
|
+
|
|
41
|
+
Where the token comes from:
|
|
42
|
+
|
|
43
|
+
- Inside PPLE Today, the mini-app SDK (`web/pple.ts`) captures the access token
|
|
44
|
+
from the launch URL; the Worker validates it against PPLE ID.
|
|
33
45
|
- In a plain browser, the same SDK runs the PPLE ID OIDC login (redirect /
|
|
34
|
-
callback)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
46
|
+
callback) — do not add a custom sign-in page or resurrect a NotInApp guard.
|
|
47
|
+
- Locally (`npm run dev`) there is no login: the Worker signs you in as a mock
|
|
48
|
+
**persona** (`DEV_PERSONA`, default `hq-staff` — see `src/dev-personas.ts`).
|
|
49
|
+
To test a province-scoped feature use `DEV_PERSONA=province-coordinator`;
|
|
50
|
+
for delegation use `delegate`; for admin-only paths use `platform-admin`.
|
|
51
|
+
This bypass is gated on `import.meta.env.DEV`, so it is compiled out of the
|
|
52
|
+
deployed Worker — there is nothing to set or unset for production.
|
|
38
53
|
|
|
39
54
|
## Conventions
|
|
40
55
|
|
|
@@ -14,7 +14,7 @@ what you want to your AI coding agent and edit the demo cards. See
|
|
|
14
14
|
| Capability | Where | Demo |
|
|
15
15
|
| ---------- | ----- | ---- |
|
|
16
16
|
| **API** | Hono Worker, `src/index.ts` | routes under `/api/*` |
|
|
17
|
-
| **Identity** | `src/auth.ts` + `web/pple.ts` | validates PPLE ID tokens; browser PPLE ID login; dev
|
|
17
|
+
| **Identity** | `src/auth.ts` + `web/pple.ts` | validates PPLE ID tokens; browser PPLE ID login; local mock personas (`src/dev-personas.ts`) |
|
|
18
18
|
| **Database** | D1, `src/db.ts` + `migrations/` | the Notes card |
|
|
19
19
|
| **Files** | R2, `src/files.ts` | the Files card |
|
|
20
20
|
| **Notifications** | `src/notify.ts` | the Notify card (audience-bound; optional `linkPath`) |
|
|
@@ -27,15 +27,20 @@ everything else — same origin, no CORS.
|
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
29
|
npm install
|
|
30
|
-
|
|
31
|
-
npm run db:migrate:local # set up the local database
|
|
32
|
-
npm run dev # http://localhost:5173
|
|
30
|
+
npm run dev # http://localhost:5173
|
|
33
31
|
```
|
|
34
32
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
33
|
+
`npm run dev` applies your local database migrations, then starts the
|
|
34
|
+
full-stack dev server. No config files, no PPLE Today, no OAuth: you're signed
|
|
35
|
+
in automatically as a mock **persona** (`hq-staff` by default). To develop as
|
|
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`.
|
|
39
|
+
|
|
40
|
+
Opened inside PPLE Today, the mini-app SDK signs the real user in from the
|
|
41
|
+
launch URL; opened in a plain browser after deploy, the same SDK runs the
|
|
42
|
+
PPLE ID login. (`pple init` runs `npm install` and the first migration for
|
|
43
|
+
you, so a freshly created app is ready to `npm run dev` straight away.)
|
|
39
44
|
|
|
40
45
|
## Everyday commands
|
|
41
46
|
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
"type": "module",
|
|
5
5
|
"//": "Golden-path template for a PPLE Platform Builder App. Rename this to your app slug. Everything runs on Cloudflare: a Hono Worker (API), D1 (database), R2 (files); the UI is a React SPA built with the People's Party design system.",
|
|
6
6
|
"scripts": {
|
|
7
|
+
"predev": "wrangler d1 migrations apply DB --local",
|
|
7
8
|
"dev": "vite dev",
|
|
8
9
|
"build": "vite build",
|
|
9
10
|
"preview": "vite preview",
|
|
@@ -4,12 +4,45 @@
|
|
|
4
4
|
// the inferred TypeScript types.
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
|
|
7
|
+
/**
|
|
8
|
+
* The signed-in caller, as the Worker resolves it from the PPLE ID token
|
|
9
|
+
* (src/auth.ts). Two faces:
|
|
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,
|
|
13
|
+
* never on `actor`. Under Delegation these are the supervisor's values and
|
|
14
|
+
* `isDelegated` is true. See docs/adr/0012 and CONTEXT.md.
|
|
15
|
+
*/
|
|
16
|
+
export const ActorSchema = z.object({
|
|
9
17
|
sub: z.string(),
|
|
10
18
|
name: z.string().nullable(),
|
|
19
|
+
/** Thai mobile number, the join key across PPLE systems. May be null. */
|
|
20
|
+
mobile: z.string().nullable(),
|
|
21
|
+
});
|
|
22
|
+
export type Actor = z.infer<typeof ActorSchema>;
|
|
23
|
+
|
|
24
|
+
export const EffectiveSchema = z.object({
|
|
25
|
+
/** True ⇒ the values below are borrowed from a supervisor (see Actor). */
|
|
26
|
+
isDelegated: z.boolean(),
|
|
27
|
+
/** The Role in play now (one of `eligibleRoles`), e.g. "hq", "province". */
|
|
28
|
+
activeRole: z.string(),
|
|
29
|
+
eligibleRoles: z.array(z.string()),
|
|
30
|
+
/** Geographic scope, or ["all"]. */
|
|
31
|
+
provinces: z.array(z.string()),
|
|
32
|
+
/** Capabilities, e.g. "platform:admin", "exco", "province_co". */
|
|
33
|
+
extraRoles: z.array(z.string()),
|
|
34
|
+
});
|
|
35
|
+
export type Effective = z.infer<typeof EffectiveSchema>;
|
|
36
|
+
|
|
37
|
+
export const IdentitySchema = z.object({
|
|
38
|
+
actor: ActorSchema,
|
|
39
|
+
effective: EffectiveSchema,
|
|
11
40
|
});
|
|
12
|
-
export type
|
|
41
|
+
export type Identity = z.infer<typeof IdentitySchema>;
|
|
42
|
+
|
|
43
|
+
/** What `GET /api/me` returns: the caller's Identity. */
|
|
44
|
+
export const MeSchema = IdentitySchema;
|
|
45
|
+
export type Me = Identity;
|
|
13
46
|
|
|
14
47
|
/** A row in the example `notes` table (the D1 read/write demo). */
|
|
15
48
|
export const NoteSchema = z.object({
|
|
@@ -5,11 +5,16 @@
|
|
|
5
5
|
// The Worker validates that token server-side against the PPLE ID userinfo
|
|
6
6
|
// endpoint — the same seam the platform's own services use. There is no local
|
|
7
7
|
// session or cookie: each request carries the token.
|
|
8
|
+
//
|
|
9
|
+
// The token resolves to an Identity with two faces (see shared/api.ts):
|
|
10
|
+
// • actor — the real human clicking (audit/display only)
|
|
11
|
+
// • effective — what they may do (gate authorization on this)
|
|
12
|
+
// Under Delegation, effective.* are the supervisor's and effective.isDelegated
|
|
13
|
+
// is true, while actor stays the delegate. See docs/adr/0012.
|
|
14
|
+
import type { Identity } from "../shared/api";
|
|
15
|
+
import { resolveDevPersona } from "./dev-personas";
|
|
8
16
|
|
|
9
|
-
export
|
|
10
|
-
sub: string;
|
|
11
|
-
name: string | null;
|
|
12
|
-
}
|
|
17
|
+
export type { Identity };
|
|
13
18
|
|
|
14
19
|
export type AuthResult =
|
|
15
20
|
| { ok: true; identity: Identity }
|
|
@@ -17,8 +22,12 @@ export type AuthResult =
|
|
|
17
22
|
|
|
18
23
|
interface AuthEnv {
|
|
19
24
|
SSO_BASE_URL: string;
|
|
20
|
-
/**
|
|
21
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Local development only: which mock persona to sign in as (see
|
|
27
|
+
* src/dev-personas.ts). Unset means the default persona; "none" turns the
|
|
28
|
+
* bypass off so you can exercise the real PPLE ID path locally.
|
|
29
|
+
*/
|
|
30
|
+
DEV_PERSONA?: string;
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
/**
|
|
@@ -31,23 +40,99 @@ export async function authenticate(
|
|
|
31
40
|
token: string,
|
|
32
41
|
fetchImpl: typeof fetch = globalThis.fetch.bind(globalThis),
|
|
33
42
|
): Promise<AuthResult> {
|
|
34
|
-
// Local development bypass
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
// Local development bypass. `import.meta.env.DEV` is true under `vite dev`
|
|
44
|
+
// (and tests) and statically `false` in `vite build`, so this whole block is
|
|
45
|
+
// dead-code-eliminated from the deployed Worker — the bypass cannot exist in
|
|
46
|
+
// production (docs/adr/0011). No token, no `.dev.vars`, no PPLE Today needed;
|
|
47
|
+
// set DEV_PERSONA="none" to fall through to the real path instead.
|
|
48
|
+
if (import.meta.env.DEV && env.DEV_PERSONA !== "none") {
|
|
49
|
+
return { ok: true, identity: resolveDevPersona(env.DEV_PERSONA) };
|
|
39
50
|
}
|
|
40
51
|
|
|
52
|
+
if (!token) return { ok: false, status: 401 };
|
|
53
|
+
|
|
41
54
|
const res = await fetchImpl(`${env.SSO_BASE_URL}/oidc/v1/userinfo`, {
|
|
42
55
|
headers: { authorization: `Bearer ${token}` },
|
|
43
56
|
});
|
|
44
57
|
if (res.status === 401 || res.status === 403) return { ok: false, status: 401 };
|
|
45
58
|
if (!res.ok) return { ok: false, status: 502 };
|
|
46
59
|
|
|
47
|
-
const body = (await res.json().catch(() => ({}))) as
|
|
48
|
-
sub?: string;
|
|
49
|
-
name?: string;
|
|
50
|
-
};
|
|
60
|
+
const body = (await res.json().catch(() => ({}))) as RawUserInfo;
|
|
51
61
|
if (!body.sub) return { ok: false, status: 502 };
|
|
52
|
-
return { ok: true, identity:
|
|
62
|
+
return { ok: true, identity: toIdentity(body) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --- Parsing the PPLE ID userinfo response -------------------------------
|
|
66
|
+
//
|
|
67
|
+
// Organization members carry an `ad` block. Its `user` is the person whose
|
|
68
|
+
// authority is in effect (a delegate is already resolved to their supervisor
|
|
69
|
+
// here); `actualPpleUser` is the real human who logged in. Every level is
|
|
70
|
+
// optional, so a missing or oddly-shaped payload yields empty values rather
|
|
71
|
+
// than throwing. See pple-sso's lib/ad/legacy-context.ts for the full shape.
|
|
72
|
+
|
|
73
|
+
interface RawPerson {
|
|
74
|
+
id?: number;
|
|
75
|
+
role?: string;
|
|
76
|
+
mobile?: string;
|
|
77
|
+
metadata?: { ad?: { extra_roles?: unknown; is_province_coordinator?: unknown } };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface RawAd {
|
|
81
|
+
user?: RawPerson;
|
|
82
|
+
actualPpleUser?: RawPerson;
|
|
83
|
+
activeRole?: string;
|
|
84
|
+
eligibleRoles?: unknown;
|
|
85
|
+
eligibleProvinces?: unknown;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface RawUserInfo {
|
|
89
|
+
sub?: string;
|
|
90
|
+
name?: string;
|
|
91
|
+
ad?: RawAd;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function stringArray(value: unknown): string[] {
|
|
95
|
+
return Array.isArray(value)
|
|
96
|
+
? value.filter((v): v is string => typeof v === "string")
|
|
97
|
+
: [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Extra-roles nested under a person's AD metadata, plus the synthetic
|
|
101
|
+
* `province_co` a province coordinator carries. */
|
|
102
|
+
function extraRolesOf(person: RawPerson | undefined): string[] {
|
|
103
|
+
const ad = person?.metadata?.ad;
|
|
104
|
+
const roles = stringArray(ad?.extra_roles);
|
|
105
|
+
if (person?.role === "province" && ad?.is_province_coordinator === true) {
|
|
106
|
+
roles.push("province_co");
|
|
107
|
+
}
|
|
108
|
+
return roles;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function toIdentity(body: RawUserInfo): Identity {
|
|
112
|
+
const ad = body.ad;
|
|
113
|
+
const effectivePerson = ad?.user;
|
|
114
|
+
// When nobody is delegating, `actualPpleUser` mirrors `user`.
|
|
115
|
+
const actualPerson = ad?.actualPpleUser ?? effectivePerson;
|
|
116
|
+
const activeRole = ad?.activeRole ?? effectivePerson?.role ?? "";
|
|
117
|
+
const eligibleRoles = stringArray(ad?.eligibleRoles);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
actor: {
|
|
121
|
+
// Top-level `sub` is the OIDC subject of the human who logged in.
|
|
122
|
+
sub: body.sub as string,
|
|
123
|
+
name: body.name ?? null,
|
|
124
|
+
mobile: actualPerson?.mobile ?? null,
|
|
125
|
+
},
|
|
126
|
+
effective: {
|
|
127
|
+
isDelegated:
|
|
128
|
+
effectivePerson?.id !== undefined &&
|
|
129
|
+
actualPerson?.id !== undefined &&
|
|
130
|
+
effectivePerson.id !== actualPerson.id,
|
|
131
|
+
activeRole,
|
|
132
|
+
eligibleRoles:
|
|
133
|
+
eligibleRoles.length > 0 ? eligibleRoles : activeRole ? [activeRole] : [],
|
|
134
|
+
provinces: stringArray(ad?.eligibleProvinces),
|
|
135
|
+
extraRoles: extraRolesOf(effectivePerson),
|
|
136
|
+
},
|
|
137
|
+
};
|
|
53
138
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Mock identities for local development (`npm run dev`).
|
|
2
|
+
//
|
|
3
|
+
// Pick one with DEV_PERSONA in .dev.vars (default "hq-staff"); add your own by
|
|
4
|
+
// appending to DEV_PERSONAS. Each persona is a real Identity shape, so what
|
|
5
|
+
// works against a persona works against a real PPLE ID user in production.
|
|
6
|
+
//
|
|
7
|
+
// This whole module is compiled out of the deployed Worker: it is only reached
|
|
8
|
+
// from the `import.meta.env.DEV` branch in src/auth.ts, which `vite build`
|
|
9
|
+
// eliminates (docs/adr/0011). Nothing here can run in production.
|
|
10
|
+
//
|
|
11
|
+
// The rule your app code must follow (docs/adr/0012): gate authorization on
|
|
12
|
+
// `effective`, use `actor` for audit/display only, and honor
|
|
13
|
+
// `effective.isDelegated`.
|
|
14
|
+
import type { Identity } from "../shared/api";
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_DEV_PERSONA = "hq-staff";
|
|
17
|
+
|
|
18
|
+
export const DEV_PERSONAS: Record<string, Identity> = {
|
|
19
|
+
// Party HQ staff: every province in scope, no special capabilities. Default.
|
|
20
|
+
"hq-staff": {
|
|
21
|
+
actor: { sub: "dev-hq-staff", name: "Local Dev (HQ)", mobile: "0800000001" },
|
|
22
|
+
effective: {
|
|
23
|
+
isDelegated: false,
|
|
24
|
+
activeRole: "hq",
|
|
25
|
+
eligibleRoles: ["hq"],
|
|
26
|
+
provinces: ["all"],
|
|
27
|
+
extraRoles: [],
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
// A province coordinator: scoped to a single province.
|
|
32
|
+
"province-coordinator": {
|
|
33
|
+
actor: {
|
|
34
|
+
sub: "dev-province-coordinator",
|
|
35
|
+
name: "Local Dev (จังหวัด)",
|
|
36
|
+
mobile: "0800000002",
|
|
37
|
+
},
|
|
38
|
+
effective: {
|
|
39
|
+
isDelegated: false,
|
|
40
|
+
activeRole: "province",
|
|
41
|
+
eligibleRoles: ["province"],
|
|
42
|
+
provinces: ["เชียงใหม่"],
|
|
43
|
+
extraRoles: ["province_co"],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
// Carries the platform:admin extra-role (may work the platform admin queue).
|
|
48
|
+
"platform-admin": {
|
|
49
|
+
actor: {
|
|
50
|
+
sub: "dev-platform-admin",
|
|
51
|
+
name: "Local Dev (Admin)",
|
|
52
|
+
mobile: "0800000003",
|
|
53
|
+
},
|
|
54
|
+
effective: {
|
|
55
|
+
isDelegated: false,
|
|
56
|
+
activeRole: "hq",
|
|
57
|
+
eligibleRoles: ["hq"],
|
|
58
|
+
provinces: ["all"],
|
|
59
|
+
extraRoles: ["platform:admin"],
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
// A delegate acting for a supervisor: effective.* are the SUPERVISOR's role
|
|
64
|
+
// and scope, actor.* is the delegate human, and isDelegated is true.
|
|
65
|
+
delegate: {
|
|
66
|
+
actor: { sub: "dev-delegate", name: "Local Dev (ผู้รับมอบ)", mobile: "0800000004" },
|
|
67
|
+
effective: {
|
|
68
|
+
isDelegated: true,
|
|
69
|
+
activeRole: "province",
|
|
70
|
+
eligibleRoles: ["province"],
|
|
71
|
+
provinces: ["เชียงใหม่"],
|
|
72
|
+
extraRoles: [],
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Resolve the configured persona; an unknown name warns and falls back to the
|
|
78
|
+
* default so a typo never breaks local dev. */
|
|
79
|
+
export function resolveDevPersona(name: string | undefined): Identity {
|
|
80
|
+
const key = name?.trim() || DEFAULT_DEV_PERSONA;
|
|
81
|
+
const persona = DEV_PERSONAS[key];
|
|
82
|
+
if (persona) return persona;
|
|
83
|
+
console.warn(
|
|
84
|
+
`Unknown DEV_PERSONA "${key}"; using "${DEFAULT_DEV_PERSONA}". ` +
|
|
85
|
+
`Valid personas: ${Object.keys(DEV_PERSONAS).join(", ")}.`,
|
|
86
|
+
);
|
|
87
|
+
return DEV_PERSONAS[DEFAULT_DEV_PERSONA] as Identity;
|
|
88
|
+
}
|
|
@@ -14,6 +14,8 @@ export interface Env {
|
|
|
14
14
|
NOTIFY_API_BASE: string;
|
|
15
15
|
NOTIFY_API_KEY?: string;
|
|
16
16
|
|
|
17
|
-
// Local development
|
|
18
|
-
|
|
17
|
+
// Local development only: which mock persona to sign in as (src/dev-personas.ts).
|
|
18
|
+
// Unset means the default persona; "none" turns the bypass off. Never set in
|
|
19
|
+
// production — the dev bypass is compiled out of the deployed Worker anyway.
|
|
20
|
+
DEV_PERSONA?: string;
|
|
19
21
|
}
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
// so each concern is easy to find and test.
|
|
5
5
|
import { Hono } from "hono";
|
|
6
6
|
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
7
|
+
import type { Identity } from "../shared/api";
|
|
7
8
|
import { CreateNoteSchema, NotificationSchema } from "../shared/api";
|
|
8
|
-
import { authenticate
|
|
9
|
+
import { authenticate } from "./auth";
|
|
9
10
|
import { addNote, listNotes } from "./db";
|
|
10
11
|
import type { Env } from "./env";
|
|
11
12
|
import { getFile, listFiles, putFile } from "./files";
|
|
@@ -29,12 +30,13 @@ export function createApp(overrides: AppOverrides = {}) {
|
|
|
29
30
|
// Liveness check — no auth, so uptime monitors and CI smoke tests can hit it.
|
|
30
31
|
app.get("/health", (c) => c.json({ ok: true }));
|
|
31
32
|
|
|
32
|
-
// Everything below requires a valid PPLE ID token.
|
|
33
|
+
// Everything below requires a valid PPLE ID token. authenticate() owns the
|
|
34
|
+
// whole decision — including the local dev-persona bypass — so a missing
|
|
35
|
+
// token is just one of the ways it can say 401.
|
|
33
36
|
app.use("*", async (c, next) => {
|
|
34
37
|
const token = (c.req.header("authorization") ?? "")
|
|
35
38
|
.replace(/^Bearer\s+/i, "")
|
|
36
39
|
.trim();
|
|
37
|
-
if (!token) return c.json({ error: "not_authenticated" }, 401);
|
|
38
40
|
const result = await authenticate(c.env, token, fetchImpl);
|
|
39
41
|
if (!result.ok) {
|
|
40
42
|
return c.json(
|
|
@@ -46,10 +48,9 @@ export function createApp(overrides: AppOverrides = {}) {
|
|
|
46
48
|
await next();
|
|
47
49
|
});
|
|
48
50
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
});
|
|
51
|
+
// The signed-in caller. `actor` is who is clicking; `effective` is what they
|
|
52
|
+
// may do — gate authorization on `effective`, never on `actor`.
|
|
53
|
+
app.get("/me", (c) => c.json(c.get("identity")));
|
|
53
54
|
|
|
54
55
|
// --- D1 demo: a shared notes list ---
|
|
55
56
|
app.get("/notes", async (c) => c.json({ notes: await listNotes(c.env.DB) }));
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The Worker is built by Vite (via @cloudflare/vite-plugin), so `import.meta.env`
|
|
2
|
+
// is available here just as it is in the browser bundle. Vite replaces
|
|
3
|
+
// `import.meta.env.DEV` with a literal: `true` under `vite dev` and `vitest`,
|
|
4
|
+
// `false` in `vite build` — which is what lets the local auth bypass in
|
|
5
|
+
// src/auth.ts be dead-code-eliminated from the deployed Worker.
|
|
6
|
+
interface ImportMetaEnv {
|
|
7
|
+
readonly DEV: boolean;
|
|
8
|
+
readonly PROD: boolean;
|
|
9
|
+
readonly MODE: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ImportMeta {
|
|
13
|
+
readonly env: ImportMetaEnv;
|
|
14
|
+
}
|
|
@@ -2,7 +2,10 @@ import { env } from "cloudflare:test";
|
|
|
2
2
|
import type { Env } from "../src/env";
|
|
3
3
|
import { createApp } from "../src/index";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// A placeholder bearer. In tests `import.meta.env.DEV` is true, so the Worker
|
|
6
|
+
// signs in as the default persona and ignores the token — the value only
|
|
7
|
+
// matters on the real PPLE ID path, where tests pass an explicit one.
|
|
8
|
+
export const DEV_TOKEN = "dev";
|
|
6
9
|
|
|
7
10
|
export interface Fakes {
|
|
8
11
|
/** Handles GET {SSO_BASE_URL}/oidc/v1/userinfo */
|
|
@@ -39,7 +42,8 @@ export function makeHarness(fakes: Fakes = {}, envOverrides: Partial<Env> = {})
|
|
|
39
42
|
...env,
|
|
40
43
|
SSO_BASE_URL: "https://sso.test",
|
|
41
44
|
NOTIFY_API_BASE: "",
|
|
42
|
-
|
|
45
|
+
// No DEV_PERSONA ⇒ the default persona. Pass { DEV_PERSONA: "none" } to
|
|
46
|
+
// exercise the real PPLE ID path instead.
|
|
43
47
|
...envOverrides,
|
|
44
48
|
};
|
|
45
49
|
|
|
@@ -11,38 +11,121 @@ describe("health", () => {
|
|
|
11
11
|
});
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
-
describe("
|
|
15
|
-
it("
|
|
14
|
+
describe("local dev personas", () => {
|
|
15
|
+
it("signs in as the default persona (hq-staff) with no config", async () => {
|
|
16
16
|
const h = makeHarness();
|
|
17
|
-
const res = await h.request("/api/me"
|
|
18
|
-
expect(res.status).toBe(
|
|
19
|
-
expect(await res.json()).toMatchObject({
|
|
17
|
+
const res = await h.request("/api/me");
|
|
18
|
+
expect(res.status).toBe(200);
|
|
19
|
+
expect(await res.json()).toMatchObject({
|
|
20
|
+
actor: { sub: "dev-hq-staff" },
|
|
21
|
+
effective: { activeRole: "hq", provinces: ["all"], isDelegated: false },
|
|
22
|
+
});
|
|
20
23
|
});
|
|
21
24
|
|
|
22
|
-
it("
|
|
23
|
-
const h = makeHarness();
|
|
24
|
-
const res = await h.request("/api/me");
|
|
25
|
+
it("switches persona via DEV_PERSONA, even without a token", async () => {
|
|
26
|
+
const h = makeHarness({}, { DEV_PERSONA: "province-coordinator" });
|
|
27
|
+
const res = await h.request("/api/me", {}, null);
|
|
25
28
|
expect(res.status).toBe(200);
|
|
26
|
-
expect(await res.json()).toMatchObject({
|
|
29
|
+
expect(await res.json()).toMatchObject({
|
|
30
|
+
effective: { activeRole: "province", provinces: ["เชียงใหม่"], extraRoles: ["province_co"] },
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("exposes a delegate persona as effective≠actor", async () => {
|
|
35
|
+
const h = makeHarness({}, { DEV_PERSONA: "delegate" });
|
|
36
|
+
const { actor, effective } = await (await h.request("/api/me")).json<{
|
|
37
|
+
actor: { sub: string };
|
|
38
|
+
effective: { isDelegated: boolean };
|
|
39
|
+
}>();
|
|
40
|
+
expect(actor.sub).toBe("dev-delegate");
|
|
41
|
+
expect(effective.isDelegated).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("falls back to the default persona on an unknown DEV_PERSONA", async () => {
|
|
45
|
+
const h = makeHarness({}, { DEV_PERSONA: "does-not-exist" });
|
|
46
|
+
expect(await (await h.request("/api/me")).json()).toMatchObject({
|
|
47
|
+
actor: { sub: "dev-hq-staff" },
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("real PPLE ID authentication (DEV_PERSONA=none)", () => {
|
|
53
|
+
it("rejects requests without a token", async () => {
|
|
54
|
+
const h = makeHarness({}, { DEV_PERSONA: "none" });
|
|
55
|
+
const res = await h.request("/api/me", {}, null);
|
|
56
|
+
expect(res.status).toBe(401);
|
|
57
|
+
expect(await res.json()).toMatchObject({ error: "not_authenticated" });
|
|
27
58
|
});
|
|
28
59
|
|
|
29
60
|
it("validates real tokens against PPLE ID userinfo", async () => {
|
|
30
|
-
// No dev bypass here; an unknown token must be checked with PPLE ID.
|
|
31
61
|
const h = makeHarness(
|
|
32
62
|
{ sso: () => Response.json({ error: "invalid_token" }, { status: 401 }) },
|
|
33
|
-
{
|
|
63
|
+
{ DEV_PERSONA: "none" },
|
|
34
64
|
);
|
|
35
65
|
const res = await h.request("/api/me", {}, "some-real-looking-token");
|
|
36
66
|
expect(res.status).toBe(401);
|
|
37
67
|
});
|
|
38
68
|
|
|
39
|
-
it("resolves
|
|
69
|
+
it("resolves an effective/actor Identity from a valid token", async () => {
|
|
40
70
|
const h = makeHarness(
|
|
41
|
-
{
|
|
42
|
-
|
|
71
|
+
{
|
|
72
|
+
sso: () =>
|
|
73
|
+
Response.json({
|
|
74
|
+
sub: "u-42",
|
|
75
|
+
name: "Somchai",
|
|
76
|
+
ad: {
|
|
77
|
+
user: {
|
|
78
|
+
id: 7,
|
|
79
|
+
role: "hq",
|
|
80
|
+
mobile: "0810000007",
|
|
81
|
+
metadata: { ad: { extra_roles: ["exco"] } },
|
|
82
|
+
},
|
|
83
|
+
actualPpleUser: { id: 7, role: "hq", mobile: "0810000007" },
|
|
84
|
+
activeRole: "hq",
|
|
85
|
+
eligibleRoles: ["hq"],
|
|
86
|
+
eligibleProvinces: ["all"],
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
},
|
|
90
|
+
{ DEV_PERSONA: "none" },
|
|
43
91
|
);
|
|
44
92
|
const res = await h.request("/api/me", {}, "valid-token");
|
|
45
|
-
expect(await res.json()).toMatchObject({
|
|
93
|
+
expect(await res.json()).toMatchObject({
|
|
94
|
+
actor: { sub: "u-42", name: "Somchai", mobile: "0810000007" },
|
|
95
|
+
effective: {
|
|
96
|
+
isDelegated: false,
|
|
97
|
+
activeRole: "hq",
|
|
98
|
+
provinces: ["all"],
|
|
99
|
+
extraRoles: ["exco"],
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("keeps actor (the delegate) distinct from effective (the supervisor)", async () => {
|
|
105
|
+
const h = makeHarness(
|
|
106
|
+
{
|
|
107
|
+
sso: () =>
|
|
108
|
+
Response.json({
|
|
109
|
+
sub: "u-delegate",
|
|
110
|
+
name: "Nok",
|
|
111
|
+
ad: {
|
|
112
|
+
// The supervisor whose authority is in effect…
|
|
113
|
+
user: { id: 9, role: "province", mobile: "0810000009" },
|
|
114
|
+
// …while the real human acting is the delegate.
|
|
115
|
+
actualPpleUser: { id: 14, role: "delegate", mobile: "0810000014" },
|
|
116
|
+
activeRole: "province",
|
|
117
|
+
eligibleRoles: ["province"],
|
|
118
|
+
eligibleProvinces: ["เชียงใหม่"],
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
},
|
|
122
|
+
{ DEV_PERSONA: "none" },
|
|
123
|
+
);
|
|
124
|
+
const res = await h.request("/api/me", {}, "valid-token");
|
|
125
|
+
expect(await res.json()).toMatchObject({
|
|
126
|
+
actor: { sub: "u-delegate", mobile: "0810000014" },
|
|
127
|
+
effective: { isDelegated: true, activeRole: "province", provinces: ["เชียงใหม่"] },
|
|
128
|
+
});
|
|
46
129
|
});
|
|
47
130
|
});
|
|
48
131
|
|
|
@@ -42,7 +42,7 @@ export function App() {
|
|
|
42
42
|
<header className="appbar">
|
|
43
43
|
<Logo className="appbar__logo" title="People's Party" />
|
|
44
44
|
<span className="appbar__title">PPLE App</span>
|
|
45
|
-
{me && <Badge>{me.name ?? me.sub}</Badge>}
|
|
45
|
+
{me && <Badge>{me.actor.name ?? me.actor.sub}</Badge>}
|
|
46
46
|
</header>
|
|
47
47
|
|
|
48
48
|
<div className="stack">
|
|
@@ -35,8 +35,8 @@ function ErrorScreen({ message }: { message: string }) {
|
|
|
35
35
|
|
|
36
36
|
const root = createRoot(document.getElementById("root")!);
|
|
37
37
|
|
|
38
|
-
// Establish the session (PPLE Today token, browser PPLE ID login, or local
|
|
39
|
-
//
|
|
38
|
+
// Establish the session (PPLE Today token, browser PPLE ID login, or a local
|
|
39
|
+
// dev persona) before rendering, so the first paint is the right screen.
|
|
40
40
|
bootstrap().then((result) => {
|
|
41
41
|
root.render(
|
|
42
42
|
<StrictMode>
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
// The single place the app talks to PPLE Today and to its own API.
|
|
2
2
|
//
|
|
3
3
|
// Three ways the app can start:
|
|
4
|
-
// 1. Local dev (`vite dev`) —
|
|
5
|
-
//
|
|
4
|
+
// 1. Local dev (`vite dev`) — no login. The Worker signs you in as a mock
|
|
5
|
+
// persona (DEV_PERSONA); we just attach a placeholder bearer it ignores.
|
|
6
6
|
// 2. Inside PPLE Today — the SDK captures the access token from the launch
|
|
7
7
|
// URL; we attach it to every API call.
|
|
8
8
|
// 3. A plain browser — the same SDK runs the PPLE ID OIDC sign-in (redirect
|
|
9
9
|
// / callback) and hands back an access token.
|
|
10
10
|
import { PPLEMiniApp } from "@pplethai/pple-today-miniapp-sdk";
|
|
11
11
|
|
|
12
|
-
// Provisioned per app
|
|
13
|
-
//
|
|
12
|
+
// Provisioned per app and baked into this bundle at build time by the
|
|
13
|
+
// platform deploy workflow's env (VITE_OAUTH_URL / VITE_OAUTH_CLIENT_ID).
|
|
14
|
+
// Needed for the SDK in both PPLE Today and the browser (browser login uses
|
|
15
|
+
// the OIDC redirect flow). Empty here means the build var was missing —
|
|
16
|
+
// bootstrap() below turns that into a legible error.
|
|
14
17
|
const OAUTH_URL = import.meta.env.VITE_OAUTH_URL ?? "";
|
|
15
18
|
const OAUTH_CLIENT_ID = import.meta.env.VITE_OAUTH_CLIENT_ID ?? "";
|
|
16
19
|
// Must match a redirect URI on the app's PPLE ID OAuth client. Provisioning
|
|
@@ -18,8 +21,9 @@ const OAUTH_CLIENT_ID = import.meta.env.VITE_OAUTH_CLIENT_ID ?? "";
|
|
|
18
21
|
const OAUTH_REDIRECT_URI =
|
|
19
22
|
import.meta.env.VITE_OAUTH_REDIRECT_URI ?? window.location.origin;
|
|
20
23
|
|
|
21
|
-
//
|
|
22
|
-
|
|
24
|
+
// In local dev the Worker ignores the token entirely (it signs you in as a
|
|
25
|
+
// mock persona), but api() still needs a non-null bearer to attach.
|
|
26
|
+
const DEV_PLACEHOLDER_TOKEN = "dev";
|
|
23
27
|
|
|
24
28
|
export type Bootstrap =
|
|
25
29
|
| { kind: "signed-in" }
|
|
@@ -31,10 +35,23 @@ let token: string | null = null;
|
|
|
31
35
|
export async function bootstrap(): Promise<Bootstrap> {
|
|
32
36
|
try {
|
|
33
37
|
if (import.meta.env.DEV) {
|
|
34
|
-
token =
|
|
38
|
+
token = DEV_PLACEHOLDER_TOKEN;
|
|
35
39
|
return { kind: "signed-in" };
|
|
36
40
|
}
|
|
37
41
|
|
|
42
|
+
// The OAuth config is baked in at build time by the platform deploy
|
|
43
|
+
// workflow (VITE_OAUTH_URL / VITE_OAUTH_CLIENT_ID). If it's missing the
|
|
44
|
+
// SDK throws a bare "No authority or metadataUrl configured on settings"
|
|
45
|
+
// from oidc-client-ts — turn that into something a Builder can act on
|
|
46
|
+
// instead of a stack trace they can't read (issue #115).
|
|
47
|
+
if (!OAUTH_URL || !OAUTH_CLIENT_ID) {
|
|
48
|
+
return {
|
|
49
|
+
kind: "error",
|
|
50
|
+
message:
|
|
51
|
+
"This app is misconfigured: its PPLE ID sign-in settings were not set when it was built. Redeploy the app from the platform to fix it.",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
38
55
|
const sdk = new PPLEMiniApp({
|
|
39
56
|
oauthUrl: OAUTH_URL,
|
|
40
57
|
oauthClientId: OAUTH_CLIENT_ID,
|