@pplethai/cli 0.5.1 → 0.7.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/README.md +117 -16
- package/dist/bin.js +32 -48
- package/package.json +2 -4
- package/templates/golden-path/AGENTS.md +1 -0
- package/templates/golden-path/CLAUDE.md +44 -24
- package/templates/golden-path/gitignore +5 -0
- package/templates/golden-path/shared/api.ts +17 -0
- package/templates/golden-path/src/auth.ts +27 -0
- package/templates/golden-path/src/dev-personas.ts +67 -3
- package/templates/golden-path/src/env.ts +6 -0
- package/templates/golden-path/src/legal-identity.ts +161 -0
- package/templates/golden-path/test/legal-identity.test.ts +256 -0
- package/templates/golden-path/wrangler.jsonc +14 -1
- package/skills/pple-platform/SKILL.md +0 -114
- package/skills/pple-platform/evals/evals.json +0 -29
- package/skills/pple-platform/references/deployment.md +0 -129
- package/skills/pple-platform/references/design-system.md +0 -55
- package/skills/pple-platform/references/mini-app.md +0 -111
- package/skills/pple-platform/references/pple-id-auth.md +0 -99
- package/skills/pple-platform/references/project-scaffold.md +0 -75
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pplethai/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -13,13 +13,11 @@
|
|
|
13
13
|
"files": [
|
|
14
14
|
"askpass",
|
|
15
15
|
"dist",
|
|
16
|
-
"skills",
|
|
17
16
|
"templates"
|
|
18
17
|
],
|
|
19
18
|
"scripts": {
|
|
20
|
-
"build": "npm run build:template &&
|
|
19
|
+
"build": "npm run build:template && node scripts/bundle.mjs",
|
|
21
20
|
"build:template": "node scripts/bundle-template.mjs",
|
|
22
|
-
"build:skills": "node scripts/bundle-skills.mjs",
|
|
23
21
|
"typecheck": "tsc --noEmit",
|
|
24
22
|
"test": "vitest run",
|
|
25
23
|
"test:watch": "vitest"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Before you start, read `.pple/rules.md` — the PPLE Platform Rules for this app. They win over anything else in the repo.
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# Working in this repo
|
|
2
2
|
|
|
3
3
|
This is a **PPLE Platform Builder App** — a mobile-first mini-app that runs
|
|
4
|
-
inside PPLE Today.
|
|
5
|
-
|
|
6
|
-
prefer editing the existing demo cards over introducing new patterns.
|
|
4
|
+
inside PPLE Today. Prefer editing the existing demo cards over introducing new
|
|
5
|
+
patterns.
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
The platform-wide rules (Platform Rules) and the `pple-platform` Skill are
|
|
8
|
+
delivered by the `pple` CLI as git-ignored local files, and kept current by it.
|
|
9
|
+
This file is only about *this* app, and where it disagrees with Platform Rules,
|
|
10
|
+
Platform Rules win.
|
|
10
11
|
|
|
11
12
|
## Architecture (read once)
|
|
12
13
|
|
|
@@ -34,16 +35,15 @@ to an `Identity` (`src/auth.ts`, shape in `shared/api.ts`) with two faces:
|
|
|
34
35
|
and display only.
|
|
35
36
|
- **`effective`** — what they may do: `activeRole`, `eligibleRoles`,
|
|
36
37
|
`provinces` (scope), `extraRoles` (capabilities like `platform:admin`).
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
is true.
|
|
38
|
+
Under delegation `effective` holds the *supervisor's* values and
|
|
39
|
+
`effective.isDelegated` is true.
|
|
40
40
|
|
|
41
41
|
Where the token comes from:
|
|
42
42
|
|
|
43
43
|
- Inside PPLE Today, the mini-app SDK (`web/pple.ts`) captures the access token
|
|
44
44
|
from the launch URL; the Worker validates it against PPLE ID.
|
|
45
45
|
- In a plain browser, the same SDK runs the PPLE ID OIDC login (redirect /
|
|
46
|
-
callback)
|
|
46
|
+
callback).
|
|
47
47
|
- Locally (`npm run dev`) there is no login: the Worker signs you in as a mock
|
|
48
48
|
**persona** (`DEV_PERSONA`, default `hq-staff` — see `src/dev-personas.ts`).
|
|
49
49
|
To test a province-scoped feature use `DEV_PERSONA=province-coordinator`;
|
|
@@ -55,21 +55,41 @@ Where the token comes from:
|
|
|
55
55
|
|
|
56
56
|
- **TypeScript is strict.** Run `npm run typecheck` after changes.
|
|
57
57
|
- **Validate input with the Zod schemas** in `shared/api.ts`.
|
|
58
|
-
- **
|
|
59
|
-
with
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
58
|
+
- **Notifications**: the shape that makes the audience required is in
|
|
59
|
+
`shared/api.ts`; read the rules that come with naming recipients before
|
|
60
|
+
changing anything there.
|
|
61
|
+
- **Mobile-first layout** uses the layout classes in `web/app.css`.
|
|
62
|
+
- **Add a DB table** by writing a new migration and running
|
|
63
|
+
`npm run db:migrate:local`.
|
|
64
|
+
|
|
65
|
+
## KYC Status and Legal Identity
|
|
66
|
+
|
|
67
|
+
**KYC Status** is `actor.kyc`. Gate on `actor.kyc.verified`. It is never on
|
|
68
|
+
`effective`. After someone returns from `/kyc`, re-read userinfo — `?kyc=` is
|
|
69
|
+
a UI hint only. `kyc.verified` means PPLE ID knows a real person behind the
|
|
70
|
+
account; it does not prove the account holder is the cardholder, and an app
|
|
71
|
+
that needs that check adds its own.
|
|
72
|
+
|
|
73
|
+
A **Legal Identity** (name and citizen ID) is not in userinfo. Call
|
|
74
|
+
`getLegalIdentity(env, token)` only at the moment it is needed, such as
|
|
75
|
+
checkout, and copy it onto that record, such as the receipt. Not on page
|
|
76
|
+
views, and nowhere else — do not log, cache, or store it aside from that
|
|
77
|
+
record. `idCardNumber` may be `null` for a member without a KYC record; send
|
|
78
|
+
them through `/kyc` (`kycStartUrl`) if the app needs the number.
|
|
79
|
+
|
|
80
|
+
Under delegation the Legal Identity is the actor's. If delegated use should
|
|
81
|
+
be blocked, check `effective.isDelegated`.
|
|
82
|
+
|
|
83
|
+
A **Legal Identity Grant** comes from a Platform Admin, and the app must be
|
|
84
|
+
Beta or Live. Ask a Platform Admin. `getLegalIdentity` reasons:
|
|
85
|
+
`not_configured` (bindings missing), `invalid_app_key`, `not_granted` (no
|
|
86
|
+
grant, or Draft or retired), `not_verified`, `user_token_invalid`,
|
|
87
|
+
`identity_not_enabled` (granted here, not allowlisted on pple-sso),
|
|
88
|
+
`upstream_unavailable`.
|
|
89
|
+
|
|
90
|
+
Locally, try `DEV_PERSONA=unverified`, `kyc-pending`, or
|
|
91
|
+
`kyc-verified-nonmember`. Member Personas are verified via membership and the
|
|
92
|
+
offline helper returns `idCardNumber: null`.
|
|
73
93
|
|
|
74
94
|
## Before you finish
|
|
75
95
|
|
|
@@ -13,11 +13,28 @@ import { z } from "zod";
|
|
|
13
13
|
* never on `actor`. Under Delegation these are the supervisor's values and
|
|
14
14
|
* `isDelegated` is true. See docs/adr/0012 and CONTEXT.md.
|
|
15
15
|
*/
|
|
16
|
+
/**
|
|
17
|
+
* KYC Status (CONTEXT.md). Whether PPLE ID knows a real person behind this
|
|
18
|
+
* account. It carries no name and no citizen ID, and it belongs to the human,
|
|
19
|
+
* so it sits on the actor — a delegate does not inherit a supervisor's status.
|
|
20
|
+
* `verified` does not prove the account holder is the cardholder.
|
|
21
|
+
*/
|
|
22
|
+
export const KycStatusSchema = z.object({
|
|
23
|
+
status: z.enum(["none", "pending_review", "verified", "rejected"]),
|
|
24
|
+
verified: z.boolean(),
|
|
25
|
+
verifiedAt: z.string().nullable(),
|
|
26
|
+
/** `membership` for an active member, `kyc` for a national-ID check, or null. */
|
|
27
|
+
source: z.enum(["membership", "kyc"]).nullable(),
|
|
28
|
+
});
|
|
29
|
+
export type KycStatus = z.infer<typeof KycStatusSchema>;
|
|
30
|
+
|
|
16
31
|
export const ActorSchema = z.object({
|
|
17
32
|
sub: z.string(),
|
|
18
33
|
name: z.string().nullable(),
|
|
19
34
|
/** Thai mobile number, the join key across PPLE systems. May be null. */
|
|
20
35
|
mobile: z.string().nullable(),
|
|
36
|
+
/** Never copied onto `effective`. See KycStatusSchema. */
|
|
37
|
+
kyc: KycStatusSchema,
|
|
21
38
|
});
|
|
22
39
|
export type Actor = z.infer<typeof ActorSchema>;
|
|
23
40
|
|
|
@@ -85,9 +85,18 @@ interface RawAd {
|
|
|
85
85
|
eligibleProvinces?: unknown;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
interface RawKyc {
|
|
89
|
+
status?: string;
|
|
90
|
+
verified?: boolean;
|
|
91
|
+
verifiedAt?: string | null;
|
|
92
|
+
source?: string | null;
|
|
93
|
+
}
|
|
94
|
+
|
|
88
95
|
interface RawUserInfo {
|
|
89
96
|
sub?: string;
|
|
90
97
|
name?: string;
|
|
98
|
+
/** KYC Status of the human who logged in. Absent until PPLE ID sends it. */
|
|
99
|
+
kyc?: RawKyc;
|
|
91
100
|
ad?: RawAd;
|
|
92
101
|
}
|
|
93
102
|
|
|
@@ -108,6 +117,21 @@ function extraRolesOf(person: RawPerson | undefined): string[] {
|
|
|
108
117
|
return roles;
|
|
109
118
|
}
|
|
110
119
|
|
|
120
|
+
const KYC_STATUSES = ["none", "pending_review", "verified", "rejected"] as const;
|
|
121
|
+
const KYC_SOURCES = ["membership", "kyc"] as const;
|
|
122
|
+
|
|
123
|
+
/** A missing or odd `kyc` is "PPLE ID has not verified this person", not an error. */
|
|
124
|
+
function kycOf(raw: RawKyc | undefined): Identity["actor"]["kyc"] {
|
|
125
|
+
const status = KYC_STATUSES.find((s) => s === raw?.status) ?? "none";
|
|
126
|
+
const source = KYC_SOURCES.find((s) => s === raw?.source) ?? null;
|
|
127
|
+
return {
|
|
128
|
+
status,
|
|
129
|
+
verified: raw?.verified === true,
|
|
130
|
+
verifiedAt: typeof raw?.verifiedAt === "string" ? raw.verifiedAt : null,
|
|
131
|
+
source,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
111
135
|
function toIdentity(body: RawUserInfo): Identity {
|
|
112
136
|
const ad = body.ad;
|
|
113
137
|
const effectivePerson = ad?.user;
|
|
@@ -122,6 +146,9 @@ function toIdentity(body: RawUserInfo): Identity {
|
|
|
122
146
|
sub: body.sub as string,
|
|
123
147
|
name: body.name ?? null,
|
|
124
148
|
mobile: actualPerson?.mobile ?? null,
|
|
149
|
+
// The human's own KYC Status. A delegated session still reports the
|
|
150
|
+
// actor's, never the supervisor's, and effective never carries it.
|
|
151
|
+
kyc: kycOf(body.kyc),
|
|
125
152
|
},
|
|
126
153
|
effective: {
|
|
127
154
|
isDelegated:
|
|
@@ -11,14 +11,32 @@
|
|
|
11
11
|
// The rule your app code must follow (docs/adr/0012): gate authorization on
|
|
12
12
|
// `effective`, use `actor` for audit/display only, and honor
|
|
13
13
|
// `effective.isDelegated`.
|
|
14
|
-
import type { Identity } from "../shared/api";
|
|
14
|
+
import type { Identity, KycStatus } from "../shared/api";
|
|
15
15
|
|
|
16
16
|
export const DEFAULT_DEV_PERSONA = "hq-staff";
|
|
17
17
|
|
|
18
|
+
/** An active member: verified through membership, not a national-ID check. */
|
|
19
|
+
function memberKyc(verifiedAt: string): KycStatus {
|
|
20
|
+
return { status: "verified", verified: true, verifiedAt, source: "membership" };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const citizen: Identity["effective"] = {
|
|
24
|
+
isDelegated: false,
|
|
25
|
+
activeRole: "",
|
|
26
|
+
eligibleRoles: [],
|
|
27
|
+
provinces: [],
|
|
28
|
+
extraRoles: [],
|
|
29
|
+
};
|
|
30
|
+
|
|
18
31
|
export const DEV_PERSONAS: Record<string, Identity> = {
|
|
19
32
|
// Party HQ staff: every province in scope, no special capabilities. Default.
|
|
20
33
|
"hq-staff": {
|
|
21
|
-
actor: {
|
|
34
|
+
actor: {
|
|
35
|
+
sub: "dev-hq-staff",
|
|
36
|
+
name: "Local Dev (HQ)",
|
|
37
|
+
mobile: "0800000001",
|
|
38
|
+
kyc: memberKyc("2024-01-01T00:00:00.000Z"),
|
|
39
|
+
},
|
|
22
40
|
effective: {
|
|
23
41
|
isDelegated: false,
|
|
24
42
|
activeRole: "hq",
|
|
@@ -34,6 +52,7 @@ export const DEV_PERSONAS: Record<string, Identity> = {
|
|
|
34
52
|
sub: "dev-province-coordinator",
|
|
35
53
|
name: "Local Dev (จังหวัด)",
|
|
36
54
|
mobile: "0800000002",
|
|
55
|
+
kyc: memberKyc("2024-02-01T00:00:00.000Z"),
|
|
37
56
|
},
|
|
38
57
|
effective: {
|
|
39
58
|
isDelegated: false,
|
|
@@ -50,6 +69,7 @@ export const DEV_PERSONAS: Record<string, Identity> = {
|
|
|
50
69
|
sub: "dev-platform-admin",
|
|
51
70
|
name: "Local Dev (Admin)",
|
|
52
71
|
mobile: "0800000003",
|
|
72
|
+
kyc: memberKyc("2024-03-01T00:00:00.000Z"),
|
|
53
73
|
},
|
|
54
74
|
effective: {
|
|
55
75
|
isDelegated: false,
|
|
@@ -62,8 +82,14 @@ export const DEV_PERSONAS: Record<string, Identity> = {
|
|
|
62
82
|
|
|
63
83
|
// A delegate acting for a supervisor: effective.* are the SUPERVISOR's role
|
|
64
84
|
// and scope, actor.* is the delegate human, and isDelegated is true.
|
|
85
|
+
// KYC Status is the delegate's own, not the province coordinator's.
|
|
65
86
|
delegate: {
|
|
66
|
-
actor: {
|
|
87
|
+
actor: {
|
|
88
|
+
sub: "dev-delegate",
|
|
89
|
+
name: "Local Dev (ผู้รับมอบ)",
|
|
90
|
+
mobile: "0800000004",
|
|
91
|
+
kyc: memberKyc("2024-04-01T00:00:00.000Z"),
|
|
92
|
+
},
|
|
67
93
|
effective: {
|
|
68
94
|
isDelegated: true,
|
|
69
95
|
activeRole: "province",
|
|
@@ -72,6 +98,44 @@ export const DEV_PERSONAS: Record<string, Identity> = {
|
|
|
72
98
|
extraRoles: [],
|
|
73
99
|
},
|
|
74
100
|
},
|
|
101
|
+
|
|
102
|
+
// No KYC Status at all.
|
|
103
|
+
unverified: {
|
|
104
|
+
actor: {
|
|
105
|
+
sub: "dev-unverified",
|
|
106
|
+
name: "Local Dev (ยังไม่ยืนยัน)",
|
|
107
|
+
mobile: "0800000005",
|
|
108
|
+
kyc: { status: "none", verified: false, verifiedAt: null, source: null },
|
|
109
|
+
},
|
|
110
|
+
effective: { ...citizen },
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
// Submitted a national ID and waiting on review.
|
|
114
|
+
"kyc-pending": {
|
|
115
|
+
actor: {
|
|
116
|
+
sub: "dev-kyc-pending",
|
|
117
|
+
name: "Local Dev (รอตรวจ)",
|
|
118
|
+
mobile: "0800000006",
|
|
119
|
+
kyc: { status: "pending_review", verified: false, verifiedAt: null, source: null },
|
|
120
|
+
},
|
|
121
|
+
effective: { ...citizen },
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
// Verified with a national ID, and not a party member.
|
|
125
|
+
"kyc-verified-nonmember": {
|
|
126
|
+
actor: {
|
|
127
|
+
sub: "dev-kyc-verified",
|
|
128
|
+
name: "Local Dev (ยืนยันด้วยบัตร)",
|
|
129
|
+
mobile: "0800000007",
|
|
130
|
+
kyc: {
|
|
131
|
+
status: "verified",
|
|
132
|
+
verified: true,
|
|
133
|
+
verifiedAt: "2024-05-01T00:00:00.000Z",
|
|
134
|
+
source: "kyc",
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
effective: { ...citizen },
|
|
138
|
+
},
|
|
75
139
|
};
|
|
76
140
|
|
|
77
141
|
/** Resolve the configured persona; an unknown name warns and falls back to the
|
|
@@ -14,6 +14,12 @@ export interface Env {
|
|
|
14
14
|
NOTIFY_API_BASE: string;
|
|
15
15
|
NOTIFY_API_KEY?: string;
|
|
16
16
|
|
|
17
|
+
// Platform Services (ADR-0022). The App Key is a secret; the URL is plain.
|
|
18
|
+
// Both are empty until the platform binds them. Legal Identity reads them
|
|
19
|
+
// in src/legal-identity.ts.
|
|
20
|
+
PPLE_SERVICES_URL?: string;
|
|
21
|
+
PPLE_APP_KEY?: string;
|
|
22
|
+
|
|
17
23
|
// Local development only: which mock persona to sign in as (src/dev-personas.ts).
|
|
18
24
|
// Unset means the default persona; "none" turns the bypass off. Never set in
|
|
19
25
|
// production — the dev bypass is compiled out of the deployed Worker anyway.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Legal Identity (CONTEXT.md). A Builder App reads a person's name and
|
|
2
|
+
// citizen ID through Platform Services, only under a Legal Identity Grant,
|
|
3
|
+
// only at the moment it needs them. This module fetches that record. It does
|
|
4
|
+
// not store it.
|
|
5
|
+
//
|
|
6
|
+
// Offline (`npm run dev` with a Persona) the answer is a fixed fake. Member
|
|
7
|
+
// Personas have no citizen ID, matching production. The one ID this module
|
|
8
|
+
// invents fails the Thai checksum, so it can never be mistaken for a real one.
|
|
9
|
+
|
|
10
|
+
import type { KycStatus } from "../shared/api";
|
|
11
|
+
import { resolveDevPersona } from "./dev-personas";
|
|
12
|
+
|
|
13
|
+
export interface LegalIdentity {
|
|
14
|
+
source: "membership" | "kyc";
|
|
15
|
+
name: string;
|
|
16
|
+
/** Null for a member who never did a national-ID check. */
|
|
17
|
+
idCardNumber: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type LegalIdentityReason =
|
|
21
|
+
| "not_configured"
|
|
22
|
+
| "invalid_app_key"
|
|
23
|
+
| "not_granted"
|
|
24
|
+
| "not_verified"
|
|
25
|
+
| "user_token_invalid"
|
|
26
|
+
| "identity_not_enabled"
|
|
27
|
+
| "upstream_unavailable";
|
|
28
|
+
|
|
29
|
+
export type LegalIdentityResult =
|
|
30
|
+
| { ok: true; legalIdentity: LegalIdentity }
|
|
31
|
+
| { ok: false; reason: LegalIdentityReason };
|
|
32
|
+
|
|
33
|
+
interface LegalIdentityEnv {
|
|
34
|
+
PPLE_SERVICES_URL?: string;
|
|
35
|
+
PPLE_APP_KEY?: string;
|
|
36
|
+
/** Which Persona is signed in. `"none"` uses the real Platform Service. */
|
|
37
|
+
DEV_PERSONA?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The only name the offline path returns. */
|
|
41
|
+
export const OFFLINE_LEGAL_NAME = "บุคคลทดสอบ ออฟไลน์";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 13 digits that fail the Thai national-ID checksum (expected check digit 8).
|
|
45
|
+
* The offline path uses this and nothing that would pass.
|
|
46
|
+
*/
|
|
47
|
+
export const OFFLINE_INVALID_ID = "1100000000000";
|
|
48
|
+
|
|
49
|
+
const REASONS: readonly LegalIdentityReason[] = [
|
|
50
|
+
"invalid_app_key",
|
|
51
|
+
"not_granted",
|
|
52
|
+
"not_verified",
|
|
53
|
+
"user_token_invalid",
|
|
54
|
+
"identity_not_enabled",
|
|
55
|
+
"upstream_unavailable",
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
/** True when `id` is 13 digits and its check digit matches. */
|
|
59
|
+
export function thaiIdChecksumValid(id: string): boolean {
|
|
60
|
+
if (!/^\d{13}$/.test(id)) return false;
|
|
61
|
+
let sum = 0;
|
|
62
|
+
for (let i = 0; i < 12; i++) sum += Number(id[i]) * (13 - i);
|
|
63
|
+
return (11 - (sum % 11)) % 10 === Number(id[12]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The caller's Legal Identity, or why it isn't available.
|
|
68
|
+
*
|
|
69
|
+
* `not_configured` means PPLE_SERVICES_URL or PPLE_APP_KEY is missing — the
|
|
70
|
+
* same idea as a notification send before its key is bound. The other reasons
|
|
71
|
+
* are what `pple-services` answers.
|
|
72
|
+
*/
|
|
73
|
+
export async function getLegalIdentity(
|
|
74
|
+
env: LegalIdentityEnv,
|
|
75
|
+
token: string,
|
|
76
|
+
fetchImpl: typeof fetch = globalThis.fetch.bind(globalThis),
|
|
77
|
+
): Promise<LegalIdentityResult> {
|
|
78
|
+
// Same seam as authenticate: a Persona stands in for PPLE ID, and the
|
|
79
|
+
// block is dead-code-eliminated from the production build (ADR-0011).
|
|
80
|
+
if (import.meta.env.DEV && env.DEV_PERSONA !== "none") {
|
|
81
|
+
return offlineLegalIdentity(env.DEV_PERSONA);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!env.PPLE_SERVICES_URL || !env.PPLE_APP_KEY) {
|
|
85
|
+
return { ok: false, reason: "not_configured" };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let res: Response;
|
|
89
|
+
try {
|
|
90
|
+
res = await fetchImpl(`${env.PPLE_SERVICES_URL.replace(/\/$/, "")}/legal-identity`, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: {
|
|
93
|
+
authorization: `Bearer ${env.PPLE_APP_KEY}`,
|
|
94
|
+
"content-type": "application/json",
|
|
95
|
+
},
|
|
96
|
+
body: JSON.stringify({ token }),
|
|
97
|
+
});
|
|
98
|
+
} catch {
|
|
99
|
+
return { ok: false, reason: "upstream_unavailable" };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const body = (await res.json().catch(() => ({}))) as {
|
|
103
|
+
error?: string;
|
|
104
|
+
source?: string;
|
|
105
|
+
name?: string;
|
|
106
|
+
idCardNumber?: string | null;
|
|
107
|
+
};
|
|
108
|
+
// The error string wins over the status: 401 is both an App Key rejection
|
|
109
|
+
// and a user-token rejection, and only the body says which.
|
|
110
|
+
const reason = REASONS.find((r) => r === body.error);
|
|
111
|
+
if (reason) return { ok: false, reason };
|
|
112
|
+
if (!res.ok) return { ok: false, reason: "upstream_unavailable" };
|
|
113
|
+
|
|
114
|
+
if (typeof body.name !== "string" || body.name === "") {
|
|
115
|
+
return { ok: false, reason: "upstream_unavailable" };
|
|
116
|
+
}
|
|
117
|
+
const source = body.source === "membership" || body.source === "kyc" ? body.source : null;
|
|
118
|
+
if (!source) return { ok: false, reason: "upstream_unavailable" };
|
|
119
|
+
const idCardNumber = typeof body.idCardNumber === "string" ? body.idCardNumber : null;
|
|
120
|
+
return { ok: true, legalIdentity: { source, name: body.name, idCardNumber } };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function offlineLegalIdentity(personaName: string | undefined): LegalIdentityResult {
|
|
124
|
+
const persona = resolveDevPersona(personaName);
|
|
125
|
+
const kyc: KycStatus = persona.actor.kyc;
|
|
126
|
+
if (!kyc.verified || (kyc.source !== "membership" && kyc.source !== "kyc")) {
|
|
127
|
+
return { ok: false, reason: "not_verified" };
|
|
128
|
+
}
|
|
129
|
+
// Members have a name and no citizen ID until they do KYC. Anyone else gets
|
|
130
|
+
// the one invalid ID, and a checksum that accidentally passed is dropped.
|
|
131
|
+
let idCardNumber = kyc.source === "membership" ? null : OFFLINE_INVALID_ID;
|
|
132
|
+
if (idCardNumber && thaiIdChecksumValid(idCardNumber)) idCardNumber = null;
|
|
133
|
+
return {
|
|
134
|
+
ok: true,
|
|
135
|
+
legalIdentity: { source: kyc.source, name: OFFLINE_LEGAL_NAME, idCardNumber },
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Where a "start KYC" button sends the person. Locally that is straight back
|
|
141
|
+
* to `returnTo` with `?kyc=cancelled` — there is no PPLE ID to open. In
|
|
142
|
+
* production it is `{ssoBaseUrl}/kyc`, which is the shape pple-today owns
|
|
143
|
+
* (PPLEThai/pple-today#500).
|
|
144
|
+
*/
|
|
145
|
+
export function kycStartUrl(input: {
|
|
146
|
+
ssoBaseUrl: string;
|
|
147
|
+
clientId: string;
|
|
148
|
+
returnTo: string;
|
|
149
|
+
dev?: boolean;
|
|
150
|
+
}): string {
|
|
151
|
+
const dev = input.dev ?? import.meta.env.DEV;
|
|
152
|
+
if (dev) {
|
|
153
|
+
const back = new URL(input.returnTo);
|
|
154
|
+
back.searchParams.set("kyc", "cancelled");
|
|
155
|
+
return back.toString();
|
|
156
|
+
}
|
|
157
|
+
const url = new URL("/kyc", input.ssoBaseUrl);
|
|
158
|
+
url.searchParams.set("client_id", input.clientId);
|
|
159
|
+
url.searchParams.set("return_to", input.returnTo);
|
|
160
|
+
return url.toString();
|
|
161
|
+
}
|