@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pplethai/cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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: { sub: "dev-hq-staff", name: "Local Dev (HQ)", mobile: "0800000001" },
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: { sub: "dev-delegate", name: "Local Dev (ผู้รับมอบ)", mobile: "0800000004" },
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
+ });