@pplethai/cli 0.6.0 → 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/package.json +1 -1
- package/templates/golden-path/CLAUDE.md +29 -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/package.json
CHANGED
|
@@ -62,6 +62,35 @@ Where the token comes from:
|
|
|
62
62
|
- **Add a DB table** by writing a new migration and running
|
|
63
63
|
`npm run db:migrate:local`.
|
|
64
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`.
|
|
93
|
+
|
|
65
94
|
## Before you finish
|
|
66
95
|
|
|
67
96
|
Run these and make sure they pass:
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// KYC Status on the actor, and getLegalIdentity (issue #216). Every refusal
|
|
2
|
+
// the helper can return is named here. The offline path never yields a
|
|
3
|
+
// citizen ID that would pass the Thai checksum.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { DEV_PERSONAS } from "../src/dev-personas";
|
|
7
|
+
import {
|
|
8
|
+
getLegalIdentity,
|
|
9
|
+
kycStartUrl,
|
|
10
|
+
OFFLINE_INVALID_ID,
|
|
11
|
+
OFFLINE_LEGAL_NAME,
|
|
12
|
+
thaiIdChecksumValid,
|
|
13
|
+
type LegalIdentityReason,
|
|
14
|
+
} from "../src/legal-identity";
|
|
15
|
+
import { makeHarness } from "./harness";
|
|
16
|
+
|
|
17
|
+
describe("actor.kyc", () => {
|
|
18
|
+
it("gives every Persona a KYC Status, and members are verified via membership", () => {
|
|
19
|
+
for (const persona of Object.values(DEV_PERSONAS)) {
|
|
20
|
+
expect(persona.actor.kyc).toEqual(
|
|
21
|
+
expect.objectContaining({
|
|
22
|
+
status: expect.any(String),
|
|
23
|
+
verified: expect.any(Boolean),
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
expect("kyc" in persona.effective).toBe(false);
|
|
27
|
+
}
|
|
28
|
+
expect(DEV_PERSONAS["hq-staff"]!.actor.kyc).toMatchObject({
|
|
29
|
+
status: "verified",
|
|
30
|
+
verified: true,
|
|
31
|
+
source: "membership",
|
|
32
|
+
});
|
|
33
|
+
expect(DEV_PERSONAS.unverified!.actor.kyc.status).toBe("none");
|
|
34
|
+
expect(DEV_PERSONAS["kyc-pending"]!.actor.kyc.status).toBe("pending_review");
|
|
35
|
+
expect(DEV_PERSONAS["kyc-verified-nonmember"]!.actor.kyc).toMatchObject({
|
|
36
|
+
status: "verified",
|
|
37
|
+
source: "kyc",
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("keeps the delegate's KYC Status, not the supervisor's", async () => {
|
|
42
|
+
const delegate = makeHarness({}, { DEV_PERSONA: "delegate" });
|
|
43
|
+
const supervisor = makeHarness({}, { DEV_PERSONA: "province-coordinator" });
|
|
44
|
+
const delegateMe = await (await delegate.request("/api/me")).json<{
|
|
45
|
+
actor: { kyc: { verifiedAt: string; source: string } };
|
|
46
|
+
effective: Record<string, unknown>;
|
|
47
|
+
}>();
|
|
48
|
+
const supervisorMe = await (await supervisor.request("/api/me")).json<{
|
|
49
|
+
actor: { kyc: { verifiedAt: string } };
|
|
50
|
+
}>();
|
|
51
|
+
expect(delegateMe.actor.kyc.verifiedAt).toBe("2024-04-01T00:00:00.000Z");
|
|
52
|
+
expect(delegateMe.actor.kyc.verifiedAt).not.toBe(supervisorMe.actor.kyc.verifiedAt);
|
|
53
|
+
expect(delegateMe.actor.kyc.source).toBe("membership");
|
|
54
|
+
expect(delegateMe.effective.kyc).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("parses userinfo kyc onto the actor only, including a delegated session", async () => {
|
|
58
|
+
const h = makeHarness(
|
|
59
|
+
{
|
|
60
|
+
sso: () =>
|
|
61
|
+
Response.json({
|
|
62
|
+
sub: "u-delegate",
|
|
63
|
+
name: "Nok",
|
|
64
|
+
kyc: {
|
|
65
|
+
status: "verified",
|
|
66
|
+
verified: true,
|
|
67
|
+
verifiedAt: "2024-06-01T00:00:00.000Z",
|
|
68
|
+
source: "kyc",
|
|
69
|
+
},
|
|
70
|
+
ad: {
|
|
71
|
+
user: { id: 9, role: "province", mobile: "0810000009" },
|
|
72
|
+
actualPpleUser: { id: 14, role: "delegate", mobile: "0810000014" },
|
|
73
|
+
activeRole: "province",
|
|
74
|
+
eligibleRoles: ["province"],
|
|
75
|
+
eligibleProvinces: ["เชียงใหม่"],
|
|
76
|
+
},
|
|
77
|
+
}),
|
|
78
|
+
},
|
|
79
|
+
{ DEV_PERSONA: "none" },
|
|
80
|
+
);
|
|
81
|
+
const me = await (await h.request("/api/me", {}, "valid-token")).json<{
|
|
82
|
+
actor: { kyc: { source: string; verifiedAt: string } };
|
|
83
|
+
effective: Record<string, unknown>;
|
|
84
|
+
}>();
|
|
85
|
+
expect(me.actor.kyc).toEqual({
|
|
86
|
+
status: "verified",
|
|
87
|
+
verified: true,
|
|
88
|
+
verifiedAt: "2024-06-01T00:00:00.000Z",
|
|
89
|
+
source: "kyc",
|
|
90
|
+
});
|
|
91
|
+
expect(me.effective.kyc).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("defaults a userinfo payload with no kyc to none", async () => {
|
|
95
|
+
const h = makeHarness(
|
|
96
|
+
{
|
|
97
|
+
sso: () =>
|
|
98
|
+
Response.json({
|
|
99
|
+
sub: "u-42",
|
|
100
|
+
name: "Somchai",
|
|
101
|
+
ad: {
|
|
102
|
+
user: { id: 7, role: "hq", mobile: "0810000007" },
|
|
103
|
+
actualPpleUser: { id: 7, role: "hq", mobile: "0810000007" },
|
|
104
|
+
activeRole: "hq",
|
|
105
|
+
eligibleRoles: ["hq"],
|
|
106
|
+
eligibleProvinces: ["all"],
|
|
107
|
+
},
|
|
108
|
+
}),
|
|
109
|
+
},
|
|
110
|
+
{ DEV_PERSONA: "none" },
|
|
111
|
+
);
|
|
112
|
+
const me = await (await h.request("/api/me", {}, "valid-token")).json<{
|
|
113
|
+
actor: { kyc: { status: string; verified: boolean; source: null } };
|
|
114
|
+
}>();
|
|
115
|
+
expect(me.actor.kyc).toEqual({
|
|
116
|
+
status: "none",
|
|
117
|
+
verified: false,
|
|
118
|
+
verifiedAt: null,
|
|
119
|
+
source: null,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("getLegalIdentity offline", () => {
|
|
125
|
+
it("never returns a checksum-valid citizen ID", async () => {
|
|
126
|
+
expect(thaiIdChecksumValid(OFFLINE_INVALID_ID)).toBe(false);
|
|
127
|
+
// A known-valid check digit for the same first 12 digits is 8.
|
|
128
|
+
expect(thaiIdChecksumValid("1100000000008")).toBe(true);
|
|
129
|
+
|
|
130
|
+
for (const name of Object.keys(DEV_PERSONAS)) {
|
|
131
|
+
const result = await getLegalIdentity({ DEV_PERSONA: name }, "ignored");
|
|
132
|
+
if (!result.ok) {
|
|
133
|
+
expect(result.reason).toBe("not_verified");
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
expect(result.legalIdentity.name).toBe(OFFLINE_LEGAL_NAME);
|
|
137
|
+
if (result.legalIdentity.idCardNumber !== null) {
|
|
138
|
+
expect(thaiIdChecksumValid(result.legalIdentity.idCardNumber)).toBe(false);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const member = await getLegalIdentity({ DEV_PERSONA: "hq-staff" }, "t");
|
|
143
|
+
expect(member).toEqual({
|
|
144
|
+
ok: true,
|
|
145
|
+
legalIdentity: { source: "membership", name: OFFLINE_LEGAL_NAME, idCardNumber: null },
|
|
146
|
+
});
|
|
147
|
+
const card = await getLegalIdentity({ DEV_PERSONA: "kyc-verified-nonmember" }, "t");
|
|
148
|
+
expect(card).toEqual({
|
|
149
|
+
ok: true,
|
|
150
|
+
legalIdentity: { source: "kyc", name: OFFLINE_LEGAL_NAME, idCardNumber: OFFLINE_INVALID_ID },
|
|
151
|
+
});
|
|
152
|
+
expect(await getLegalIdentity({ DEV_PERSONA: "unverified" }, "t")).toEqual({
|
|
153
|
+
ok: false,
|
|
154
|
+
reason: "not_verified",
|
|
155
|
+
});
|
|
156
|
+
expect(await getLegalIdentity({ DEV_PERSONA: "kyc-pending" }, "t")).toEqual({
|
|
157
|
+
ok: false,
|
|
158
|
+
reason: "not_verified",
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("getLegalIdentity against Platform Services", () => {
|
|
164
|
+
const env = {
|
|
165
|
+
DEV_PERSONA: "none",
|
|
166
|
+
PPLE_SERVICES_URL: "https://services.test",
|
|
167
|
+
PPLE_APP_KEY: "app-key",
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
it("is not_configured until both bindings exist, and does not call out", async () => {
|
|
171
|
+
const fetchImpl = vi.fn<typeof fetch>();
|
|
172
|
+
expect(await getLegalIdentity({ DEV_PERSONA: "none" }, "tok", fetchImpl)).toEqual({
|
|
173
|
+
ok: false,
|
|
174
|
+
reason: "not_configured",
|
|
175
|
+
});
|
|
176
|
+
expect(
|
|
177
|
+
await getLegalIdentity({ DEV_PERSONA: "none", PPLE_SERVICES_URL: "https://services.test" }, "tok", fetchImpl),
|
|
178
|
+
).toEqual({ ok: false, reason: "not_configured" });
|
|
179
|
+
expect(fetchImpl).not.toHaveBeenCalled();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("names every error the service can return", async () => {
|
|
183
|
+
const reasons: LegalIdentityReason[] = [
|
|
184
|
+
"invalid_app_key",
|
|
185
|
+
"not_granted",
|
|
186
|
+
"not_verified",
|
|
187
|
+
"user_token_invalid",
|
|
188
|
+
"identity_not_enabled",
|
|
189
|
+
"upstream_unavailable",
|
|
190
|
+
];
|
|
191
|
+
for (const reason of reasons) {
|
|
192
|
+
const status = reason === "invalid_app_key" || reason === "user_token_invalid" ? 401 : 403;
|
|
193
|
+
const fetchImpl = vi.fn<typeof fetch>(async () => Response.json({ error: reason }, { status }));
|
|
194
|
+
expect(await getLegalIdentity(env, "user-token", fetchImpl)).toEqual({ ok: false, reason });
|
|
195
|
+
const [url, init] = fetchImpl.mock.calls[0]!;
|
|
196
|
+
expect(url).toBe("https://services.test/legal-identity");
|
|
197
|
+
expect(new Headers(init?.headers).get("authorization")).toBe("Bearer app-key");
|
|
198
|
+
expect(JSON.parse(String(init?.body))).toEqual({ token: "user-token" });
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("returns the Legal Identity the service answered with", async () => {
|
|
203
|
+
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
|
204
|
+
Response.json({ source: "kyc", name: "นาย ก", idCardNumber: "1100000000008" }),
|
|
205
|
+
);
|
|
206
|
+
expect(await getLegalIdentity(env, "user-token", fetchImpl)).toEqual({
|
|
207
|
+
ok: true,
|
|
208
|
+
legalIdentity: { source: "kyc", name: "นาย ก", idCardNumber: "1100000000008" },
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("treats a transport failure and a body-less error as upstream_unavailable", async () => {
|
|
213
|
+
const down = vi.fn<typeof fetch>(async () => {
|
|
214
|
+
throw new Error("network");
|
|
215
|
+
});
|
|
216
|
+
expect(await getLegalIdentity(env, "user-token", down)).toEqual({
|
|
217
|
+
ok: false,
|
|
218
|
+
reason: "upstream_unavailable",
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const odd = vi.fn<typeof fetch>(async () => new Response("nope", { status: 502 }));
|
|
222
|
+
expect(await getLegalIdentity(env, "user-token", odd)).toEqual({
|
|
223
|
+
ok: false,
|
|
224
|
+
reason: "upstream_unavailable",
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("local KYC redirect", () => {
|
|
230
|
+
it("sends a local click straight back with ?kyc=cancelled", () => {
|
|
231
|
+
const back = kycStartUrl({
|
|
232
|
+
ssoBaseUrl: "https://id.example",
|
|
233
|
+
clientId: "zitadel-app",
|
|
234
|
+
returnTo: "https://app.example/checkout?step=1#pay",
|
|
235
|
+
dev: true,
|
|
236
|
+
});
|
|
237
|
+
const url = new URL(back);
|
|
238
|
+
expect(url.origin + url.pathname).toBe("https://app.example/checkout");
|
|
239
|
+
expect(url.searchParams.get("step")).toBe("1");
|
|
240
|
+
expect(url.searchParams.get("kyc")).toBe("cancelled");
|
|
241
|
+
expect(url.hash).toBe("#pay");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("points production at PPLE ID /kyc", () => {
|
|
245
|
+
expect(
|
|
246
|
+
kycStartUrl({
|
|
247
|
+
ssoBaseUrl: "https://id.example",
|
|
248
|
+
clientId: "zitadel-app",
|
|
249
|
+
returnTo: "https://app.example/checkout",
|
|
250
|
+
dev: false,
|
|
251
|
+
}),
|
|
252
|
+
).toBe(
|
|
253
|
+
"https://id.example/kyc?client_id=zitadel-app&return_to=https%3A%2F%2Fapp.example%2Fcheckout",
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
});
|