@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.
@@ -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
- report(deps, app, targetDir);
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
- deps.out.say(" npm install");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pplethai/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 (`VITE_OAUTH_URL`, `VITE_OAUTH_CLIENT_ID`,
44
- `VITE_OAUTH_REDIRECT_URI`) are provisioned per app and injected at build
45
- time — Builders do not register clients by hand. The redirect URI defaults
46
- to the app's own origin, which matches the mini-app URL the platform
47
- registers as an allowed redirect on that OAuth client
48
- (`docs/integration/today-sso-changes.md` §5).
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
- # Local development configuration. Copy this file to `.dev.vars` (which is
2
- # gitignored) and adjust. `wrangler dev` and `vite dev` load `.dev.vars`
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
- # Turns on the local auth bypass. When set, any request whose bearer token
6
- # equals this value is accepted as a fake signed-in user, so you can develop
7
- # in a plain browser without PPLE Today. The React app uses this token
8
- # automatically during `vite dev`.
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
- # Optional: exercise the notification helper locally. These are normally
15
- # injected by the platform at deploy time.
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
- - Inside PPLE Today, the mini-app SDK (`web/pple.ts`) captures the access
31
- token from the launch URL. The Worker validates it against PPLE ID
32
- (`src/auth.ts`).
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) and returns an access token — do not add a custom sign-in page
35
- or resurrect a NotInApp guard.
36
- - Locally, `DEV_AUTH` in `.dev.vars` bypasses PPLE Today. **Never** set
37
- `DEV_AUTH` in production; the platform never provisions it.
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 bypass |
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
- cp .dev.vars.example .dev.vars # local auth bypass
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
- In local dev you're signed in automatically as a fake user (the `DEV_AUTH`
36
- bypass), so you can build without PPLE Today. Opened inside PPLE Today, the
37
- mini-app SDK signs the real user in from the launch URL; opened in a plain
38
- browser after deploy, the same SDK runs the PPLE ID login.
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
- /** The signed-in user, as the Worker resolves it from the PPLE ID token. */
8
- export const MeSchema = z.object({
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 Me = z.infer<typeof MeSchema>;
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 interface Identity {
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
- /** Local-only bypass token; unset in production. See .dev.vars.example. */
21
- DEV_AUTH?: string;
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: accept a fake user when the request carries the
35
- // configured DEV_AUTH token. Never active in production, because the
36
- // platform never provisions DEV_AUTH.
37
- if (env.DEV_AUTH && token === env.DEV_AUTH) {
38
- return { ok: true, identity: { sub: "dev-user", name: "Local Dev" } };
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: { sub: body.sub, name: body.name ?? null } };
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 auth bypass. Never set in production.
18
- DEV_AUTH?: string;
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, type Identity } from "./auth";
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
- app.get("/me", (c) => {
50
- const { sub, name } = c.get("identity");
51
- return c.json({ sub, name });
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
- export const DEV_TOKEN = "dev-token";
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
- DEV_AUTH: DEV_TOKEN,
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("authentication", () => {
15
- it("rejects requests without a token", async () => {
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", {}, null);
18
- expect(res.status).toBe(401);
19
- expect(await res.json()).toMatchObject({ error: "not_authenticated" });
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("accepts the dev-bypass token locally", async () => {
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({ sub: "dev-user" });
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
- { DEV_AUTH: undefined },
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 identity from a valid PPLE ID token", async () => {
69
+ it("resolves an effective/actor Identity from a valid token", async () => {
40
70
  const h = makeHarness(
41
- { sso: () => Response.json({ sub: "u-42", name: "Somchai" }) },
42
- { DEV_AUTH: undefined },
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({ sub: "u-42", name: "Somchai" });
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
- // DEV_AUTH) before rendering, so the first paint is the right screen.
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`) — bypass PPLE Today, use the DEV_AUTH token so
5
- // the Worker accepts you as a fake user.
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, injected at build time. Needed for the SDK in both
13
- // PPLE Today and the browser (browser login uses the OIDC redirect flow).
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
- // Must match DEV_AUTH in .dev.vars so the Worker's dev bypass accepts it.
22
- const DEV_TOKEN = "dev-token";
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 = DEV_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,