@pplethai/cli 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +171 -22
- package/package.json +3 -2
- package/templates/golden-path/.dev.vars.example +19 -1
- package/templates/golden-path/CLAUDE.md +51 -13
- package/templates/golden-path/README.md +27 -2
- package/templates/golden-path/gitignore +3 -0
- package/templates/golden-path/overlay-mtime.mjs +30 -0
- package/templates/golden-path/package-lock.json +19 -1
- package/templates/golden-path/package.json +3 -2
- package/templates/golden-path/scripts/assert-dev-compiled-out.mjs +42 -0
- package/templates/golden-path/shared/api.ts +25 -2
- package/templates/golden-path/src/auth.ts +72 -7
- package/templates/golden-path/src/call-service.ts +140 -0
- package/templates/golden-path/src/dataset-query.ts +470 -0
- package/templates/golden-path/src/dataset-schema.ts +398 -0
- package/templates/golden-path/src/dev-call-service.ts +391 -0
- package/templates/golden-path/src/dev-personas.ts +49 -15
- package/templates/golden-path/src/dev-read-dataset.ts +429 -0
- package/templates/golden-path/src/dev-registry.ts +276 -0
- package/templates/golden-path/src/env.ts +13 -2
- package/templates/golden-path/src/identity-header.ts +17 -0
- package/templates/golden-path/src/index.ts +32 -1
- package/templates/golden-path/src/legal-identity.ts +47 -28
- package/templates/golden-path/src/levels.ts +132 -0
- package/templates/golden-path/src/read-dataset.ts +376 -0
- package/templates/golden-path/src/registry.ts +122 -0
- package/templates/golden-path/src/vite-env.d.ts +13 -0
- package/templates/golden-path/test/call-service.test.ts +178 -0
- package/templates/golden-path/test/dev-call-service.test.ts +279 -0
- package/templates/golden-path/test/dev-read-dataset.test.ts +270 -0
- package/templates/golden-path/test/dev-registry.test.ts +375 -0
- package/templates/golden-path/test/effective-sub.test.ts +101 -0
- package/templates/golden-path/test/fixtures/overlay/directory/branches/entry.md +85 -0
- package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/audience.json +3 -0
- package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/named.json +3 -0
- package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/offices.json +6 -0
- package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/roster.json +3 -0
- package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/summary.json +1 -0
- package/templates/golden-path/test/fixtures/overlay/platform/legal-identity/entry.md +8 -0
- package/templates/golden-path/test/fixtures/overlay/platform/legal-identity/mocks/responses.json +13 -0
- package/templates/golden-path/test/fixtures/overlay/shop/catalog/entry.md +8 -0
- package/templates/golden-path/test/fixtures/overlay/shop/catalog/mocks/responses.json +7 -0
- package/templates/golden-path/test/fixtures/overlay/shop/orders/entry.md +12 -0
- package/templates/golden-path/test/fixtures/overlay/shop/orders/mocks/responses.json +36 -0
- package/templates/golden-path/test/harness.ts +101 -16
- package/templates/golden-path/test/legal-identity.test.ts +91 -24
- package/templates/golden-path/test/membership.test.ts +105 -0
- package/templates/golden-path/test/read-dataset.test.ts +214 -0
- package/templates/golden-path/test/service-call.test.ts +166 -0
- package/templates/golden-path/vite.config.ts +2 -1
- package/templates/golden-path/vitest.config.ts +2 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// Offline Provider development (ADR-0026). Active only where
|
|
2
|
+
// `import.meta.env.DEV` is true — `vite dev` and the template tests. The
|
|
3
|
+
// production build drops this module the same way it drops the Persona
|
|
4
|
+
// bypass (ADR-0011).
|
|
5
|
+
//
|
|
6
|
+
// `npm run dev` has no platform wrapper and no platform-call secret, so a
|
|
7
|
+
// Builder can curl `/.pple/registry/<name>/…` directly. This delivers that
|
|
8
|
+
// request to `registry[name]` the way the platform wrapper does: the tail
|
|
9
|
+
// path, the method and body, and the `x-pple-*` facts. Incoming `x-pple-*`
|
|
10
|
+
// headers are never trusted. Public traffic has them stripped before app
|
|
11
|
+
// code, so dev and production agree that a Builder cannot invent platform
|
|
12
|
+
// facts.
|
|
13
|
+
//
|
|
14
|
+
// Identity choice. An unset `DEV_PERSONA` uses the default Persona, as the
|
|
15
|
+
// rest of local dev does. `DEV_PERSONA=none` sends no `x-pple-identity`,
|
|
16
|
+
// which is what the platform does for an entry that declares
|
|
17
|
+
// `userToken: none`. A name that is not a Persona does the same, and does
|
|
18
|
+
// not fall back to the default: a registry call must not invent a caller.
|
|
19
|
+
// `/api` still falls back, so a typo does not lock the Builder out of the
|
|
20
|
+
// app. The Consumer is `DEV_CONSUMER`, or `dev-consumer`. The entry id is
|
|
21
|
+
// `dev-provider/<name>` — a local app has no App Slug until the platform
|
|
22
|
+
// creates it.
|
|
23
|
+
//
|
|
24
|
+
// A Dataset value is an object of page functions. `/.pple/registry/<name>/<objectType>`
|
|
25
|
+
// calls that function with `{ cursor }` and answers with its page. A value with
|
|
26
|
+
// no fetch-shaped handler, and a page map with no function for the segment,
|
|
27
|
+
// throws `registry handler missing`, the error the platform wrapper throws.
|
|
28
|
+
// `pple-services` turns that throw
|
|
29
|
+
// into `provider_unavailable` (502). Nothing here invents that response:
|
|
30
|
+
// locally there is no gateway in front of the handler, and the 502 is a
|
|
31
|
+
// Consumer-facing answer the Provider's code never sees. Other `/.pple/`
|
|
32
|
+
// paths (cron, the deploy probe) are not emulated; they reach app code with
|
|
33
|
+
// platform headers stripped.
|
|
34
|
+
|
|
35
|
+
import type { Identity } from "../shared/api";
|
|
36
|
+
import { DEV_PERSONAS, DEFAULT_DEV_PERSONA } from "./dev-personas";
|
|
37
|
+
import type { Env } from "./env";
|
|
38
|
+
import { encodeIdentityHeader } from "./identity-header";
|
|
39
|
+
import {
|
|
40
|
+
CONSUMER_HEADER,
|
|
41
|
+
ENTRY_HEADER,
|
|
42
|
+
IDENTITY_HEADER,
|
|
43
|
+
type DatasetPages,
|
|
44
|
+
type RegistryFetch,
|
|
45
|
+
type RegistryMap,
|
|
46
|
+
} from "./registry";
|
|
47
|
+
|
|
48
|
+
export const DEFAULT_DEV_CONSUMER = "dev-consumer";
|
|
49
|
+
|
|
50
|
+
/** Local stand-in for the App Slug. The entry id is `${DEV_PROVIDER_SLUG}/<name>`. */
|
|
51
|
+
export const DEV_PROVIDER_SLUG = "dev-provider";
|
|
52
|
+
|
|
53
|
+
const REGISTRY_PATH_PREFIX = "/.pple/registry/";
|
|
54
|
+
|
|
55
|
+
const HOP_BY_HOP = new Set([
|
|
56
|
+
"host",
|
|
57
|
+
"connection",
|
|
58
|
+
"keep-alive",
|
|
59
|
+
"transfer-encoding",
|
|
60
|
+
"te",
|
|
61
|
+
"trailer",
|
|
62
|
+
"upgrade",
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
export interface DevWorker {
|
|
66
|
+
fetch: RegistryFetch;
|
|
67
|
+
registry?: RegistryMap;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface CallRegistryOptions {
|
|
71
|
+
/**
|
|
72
|
+
* Persona for `x-pple-identity`. `"none"` or an unknown name sends none.
|
|
73
|
+
* Omit to use the env's `DEV_PERSONA`.
|
|
74
|
+
*/
|
|
75
|
+
persona?: string;
|
|
76
|
+
/** Consumer slug. Omit to use `DEV_CONSUMER`, then `dev-consumer`. */
|
|
77
|
+
consumer?: string;
|
|
78
|
+
/**
|
|
79
|
+
* Method and body, which the platform preserves. `x-pple-*`, Authorization
|
|
80
|
+
* and Cookie here are dropped: the call sets the platform facts itself.
|
|
81
|
+
*/
|
|
82
|
+
init?: RequestInit;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The Identity a dev registry call carries, or null for `userToken: none`. */
|
|
86
|
+
export function devRegistryIdentity(name: string | undefined): Identity | null {
|
|
87
|
+
const key = name?.trim() || DEFAULT_DEV_PERSONA;
|
|
88
|
+
if (key === "none") return null;
|
|
89
|
+
const persona = DEV_PERSONAS[key];
|
|
90
|
+
if (persona) return persona;
|
|
91
|
+
console.warn(
|
|
92
|
+
`Unknown DEV_PERSONA "${key}" on the emulated registry path; sending no Identity ` +
|
|
93
|
+
`(userToken: none). Valid personas: ${Object.keys(DEV_PERSONAS).join(", ")}.`,
|
|
94
|
+
);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function devConsumerSlug(value: string | undefined): string {
|
|
99
|
+
const slug = value?.trim();
|
|
100
|
+
return slug ? slug : DEFAULT_DEV_CONSUMER;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Dev entry. Registry calls go to `user.registry`. Everything else is public
|
|
105
|
+
* traffic: `x-pple-*` is removed, then `user.fetch` runs.
|
|
106
|
+
*/
|
|
107
|
+
export async function devWorkerFetch(
|
|
108
|
+
user: DevWorker,
|
|
109
|
+
request: Request,
|
|
110
|
+
env: Env,
|
|
111
|
+
ctx: ExecutionContext,
|
|
112
|
+
): Promise<Response> {
|
|
113
|
+
const url = new URL(request.url);
|
|
114
|
+
const parsed = parseRegistryPath(url.pathname);
|
|
115
|
+
if (!parsed) return user.fetch(stripPlatformHeaders(request), env, ctx);
|
|
116
|
+
if (!parsed.name) return emptyRegistryResponse();
|
|
117
|
+
|
|
118
|
+
const pages = datasetPages(user.registry, parsed.name);
|
|
119
|
+
if (pages) return runDatasetPage(pages, parsed.tail, request, env, ctx);
|
|
120
|
+
|
|
121
|
+
const headers = handlerHeaders(
|
|
122
|
+
request.headers,
|
|
123
|
+
registryFacts(parsed.name, devConsumerSlug(env.DEV_CONSUMER), devRegistryIdentity(env.DEV_PERSONA)),
|
|
124
|
+
);
|
|
125
|
+
const target = new URL(request.url);
|
|
126
|
+
target.pathname = parsed.tail;
|
|
127
|
+
return handlerFor(user.registry, parsed.name)(
|
|
128
|
+
forwardedRequest(target, request.method, headers, request.body),
|
|
129
|
+
env,
|
|
130
|
+
ctx,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Call `registry[name]` the way the platform wrapper does. See the file
|
|
136
|
+
* comment for a missing handler: this throws `registry handler missing`.
|
|
137
|
+
*/
|
|
138
|
+
export async function callRegistry(
|
|
139
|
+
user: DevWorker,
|
|
140
|
+
env: Env,
|
|
141
|
+
ctx: ExecutionContext,
|
|
142
|
+
name: string,
|
|
143
|
+
path: string,
|
|
144
|
+
options: CallRegistryOptions = {},
|
|
145
|
+
): Promise<Response> {
|
|
146
|
+
if (!name) return emptyRegistryResponse();
|
|
147
|
+
const personaName = options.persona !== undefined ? options.persona : env.DEV_PERSONA;
|
|
148
|
+
const consumerName = options.consumer !== undefined ? options.consumer : env.DEV_CONSUMER;
|
|
149
|
+
const headers = handlerHeaders(
|
|
150
|
+
new Headers(options.init?.headers),
|
|
151
|
+
registryFacts(name, devConsumerSlug(consumerName), devRegistryIdentity(personaName)),
|
|
152
|
+
);
|
|
153
|
+
const method = options.init?.method ?? "GET";
|
|
154
|
+
const body = options.init?.body ?? null;
|
|
155
|
+
// No incoming Host: the pathname and query are the tail the platform sets.
|
|
156
|
+
const request = forwardedRequest(tailUrl(path), method, headers, body);
|
|
157
|
+
const pages = datasetPages(user.registry, name);
|
|
158
|
+
if (pages) return runDatasetPage(pages, new URL(request.url).pathname, request, env, ctx);
|
|
159
|
+
return handlerFor(user.registry, name)(request, env, ctx);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function registryFacts(
|
|
163
|
+
name: string,
|
|
164
|
+
consumer: string,
|
|
165
|
+
identity: Identity | null,
|
|
166
|
+
): { consumer: string; entry: string; identity: Identity | null } {
|
|
167
|
+
return { consumer, entry: `${DEV_PROVIDER_SLUG}/${name}`, identity };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** What the handler sees. Matches pple-services' header filter, minus the secret. */
|
|
171
|
+
function handlerHeaders(
|
|
172
|
+
incoming: Headers,
|
|
173
|
+
facts: { consumer: string; entry: string; identity: Identity | null },
|
|
174
|
+
): Headers {
|
|
175
|
+
const headers = new Headers();
|
|
176
|
+
for (const [key, value] of incoming) {
|
|
177
|
+
const lower = key.toLowerCase();
|
|
178
|
+
if (
|
|
179
|
+
lower === "authorization" ||
|
|
180
|
+
lower === "cookie" ||
|
|
181
|
+
lower.startsWith("x-pple-") ||
|
|
182
|
+
HOP_BY_HOP.has(lower)
|
|
183
|
+
) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
headers.set(key, value);
|
|
187
|
+
}
|
|
188
|
+
headers.set(CONSUMER_HEADER, facts.consumer);
|
|
189
|
+
headers.set(ENTRY_HEADER, facts.entry);
|
|
190
|
+
if (facts.identity) headers.set(IDENTITY_HEADER, encodeIdentityHeader(facts.identity));
|
|
191
|
+
return headers;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function datasetPages(registry: RegistryMap | undefined, name: string): DatasetPages | null {
|
|
195
|
+
const value = registry?.[name];
|
|
196
|
+
if (typeof value !== "object" || value === null || typeof value.fetch === "function") return null;
|
|
197
|
+
return value as DatasetPages;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* A Dataset page function is `{ cursor }` in, a page out. The path's single
|
|
202
|
+
* segment is the Object Type. No segment, or no function by that name, is
|
|
203
|
+
* the same missing-handler failure as a Service with nothing fetch-shaped.
|
|
204
|
+
*/
|
|
205
|
+
async function runDatasetPage(
|
|
206
|
+
pages: DatasetPages,
|
|
207
|
+
tail: string,
|
|
208
|
+
request: Request,
|
|
209
|
+
env: Env,
|
|
210
|
+
ctx: ExecutionContext,
|
|
211
|
+
): Promise<Response> {
|
|
212
|
+
const objectType = tail.startsWith("/") ? tail.slice(1) : tail;
|
|
213
|
+
const fn = objectType.includes("/") || objectType === "" ? undefined : pages[objectType];
|
|
214
|
+
if (typeof fn !== "function") throw new Error("registry handler missing");
|
|
215
|
+
let cursor: string | null = null;
|
|
216
|
+
if (request.method !== "GET" && request.method !== "HEAD" && request.body) {
|
|
217
|
+
try {
|
|
218
|
+
const body = (await request.json()) as { cursor?: unknown };
|
|
219
|
+
if (body && typeof body.cursor === "string") cursor = body.cursor;
|
|
220
|
+
} catch {
|
|
221
|
+
cursor = null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return Response.json(await fn({ cursor }, env, ctx));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function handlerFor(registry: RegistryMap | undefined, name: string): RegistryFetch {
|
|
228
|
+
const value = registry?.[name];
|
|
229
|
+
if (typeof value === "function") return value;
|
|
230
|
+
if (value && typeof value === "object" && typeof value.fetch === "function") {
|
|
231
|
+
const app = value as { fetch: RegistryFetch };
|
|
232
|
+
return (request, env, ctx) => app.fetch(request, env, ctx);
|
|
233
|
+
}
|
|
234
|
+
throw new Error("registry handler missing");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function parseRegistryPath(pathname: string): { name: string; tail: string } | null {
|
|
238
|
+
if (pathname !== "/.pple/registry" && !pathname.startsWith(REGISTRY_PATH_PREFIX)) return null;
|
|
239
|
+
const restPath = pathname.startsWith(REGISTRY_PATH_PREFIX)
|
|
240
|
+
? pathname.slice(REGISTRY_PATH_PREFIX.length)
|
|
241
|
+
: "";
|
|
242
|
+
const slash = restPath.indexOf("/");
|
|
243
|
+
const name = slash === -1 ? restPath : restPath.slice(0, slash);
|
|
244
|
+
const tail = slash === -1 ? "/" : restPath.slice(slash) || "/";
|
|
245
|
+
return { name, tail };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function tailUrl(path: string): URL {
|
|
249
|
+
const raw = path.startsWith("/") ? path : `/${path}`;
|
|
250
|
+
return new URL(raw, "https://provider.invalid");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function forwardedRequest(url: URL, method: string, headers: Headers, body: BodyInit | null): Request {
|
|
254
|
+
const init: RequestInit & { duplex?: "half" } = { method, headers };
|
|
255
|
+
if (method !== "GET" && method !== "HEAD" && body != null) {
|
|
256
|
+
init.body = body;
|
|
257
|
+
init.duplex = "half";
|
|
258
|
+
}
|
|
259
|
+
return new Request(url, init);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function stripPlatformHeaders(request: Request): Request {
|
|
263
|
+
const headers = new Headers(request.headers);
|
|
264
|
+
let changed = false;
|
|
265
|
+
for (const key of [...headers.keys()]) {
|
|
266
|
+
if (key.toLowerCase().startsWith("x-pple-")) {
|
|
267
|
+
headers.delete(key);
|
|
268
|
+
changed = true;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return changed ? new Request(request, { headers }) : request;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function emptyRegistryResponse(): Response {
|
|
275
|
+
return new Response(null, { status: 404, headers: { "cache-control": "no-store" } });
|
|
276
|
+
}
|
|
@@ -15,8 +15,8 @@ export interface Env {
|
|
|
15
15
|
NOTIFY_API_KEY?: string;
|
|
16
16
|
|
|
17
17
|
// Platform Services (ADR-0022). The App Key is a secret; the URL is plain.
|
|
18
|
-
// Both are empty until the platform binds them.
|
|
19
|
-
//
|
|
18
|
+
// Both are empty until the platform binds them. `callService` reads them
|
|
19
|
+
// (src/call-service.ts); the Builder does not put the App Key on a request.
|
|
20
20
|
PPLE_SERVICES_URL?: string;
|
|
21
21
|
PPLE_APP_KEY?: string;
|
|
22
22
|
|
|
@@ -24,4 +24,15 @@ export interface Env {
|
|
|
24
24
|
// Unset means the default persona; "none" turns the bypass off. Never set in
|
|
25
25
|
// production — the dev bypass is compiled out of the deployed Worker anyway.
|
|
26
26
|
DEV_PERSONA?: string;
|
|
27
|
+
|
|
28
|
+
// Local development only: make offline `callService` and `readDataset`
|
|
29
|
+
// return a platform refusal instead of a mock (`not_granted`,
|
|
30
|
+
// `level_not_met`, …). A bare code applies to every entry;
|
|
31
|
+
// `<provider>/<name>=<code>` wins for that entry. Comma-separated.
|
|
32
|
+
// Compiled out with the offline path.
|
|
33
|
+
DEV_REGISTRY_ERROR?: string;
|
|
34
|
+
|
|
35
|
+
// Local development only: the Consumer slug on the emulated registry path.
|
|
36
|
+
// Unset means `dev-consumer`. Compiled out with the dev wrapper.
|
|
37
|
+
DEV_CONSUMER?: string;
|
|
27
38
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// How a Provider test and the dev server build `x-pple-identity` (ADR-0024).
|
|
2
|
+
//
|
|
3
|
+
// Base64url (RFC 4648 §5, no padding) of the UTF-8 JSON `{ actor, effective }`.
|
|
4
|
+
// A Thai name is not a reliable raw header value. `serviceCall` decodes this
|
|
5
|
+
// exact encoding. The platform's copy is
|
|
6
|
+
// `workers/src/services/identity-header.ts`; a test keeps the two strings
|
|
7
|
+
// equal. One function, so the dev wrapper and `callRegistry` cannot drift.
|
|
8
|
+
|
|
9
|
+
import type { Identity } from "../shared/api";
|
|
10
|
+
|
|
11
|
+
/** Base64url of the Identity JSON. ASCII, so it survives an HTTP header. */
|
|
12
|
+
export function encodeIdentityHeader(identity: Identity): string {
|
|
13
|
+
const bytes = new TextEncoder().encode(JSON.stringify(identity));
|
|
14
|
+
let binary = "";
|
|
15
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
16
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
17
|
+
}
|
|
@@ -9,8 +9,10 @@ import { CreateNoteSchema, NotificationSchema } from "../shared/api";
|
|
|
9
9
|
import { authenticate } from "./auth";
|
|
10
10
|
import { addNote, listNotes } from "./db";
|
|
11
11
|
import type { Env } from "./env";
|
|
12
|
+
import { devWorkerFetch } from "./dev-registry";
|
|
12
13
|
import { getFile, listFiles, putFile } from "./files";
|
|
13
14
|
import { sendNotification } from "./notify";
|
|
15
|
+
import type { PpleWorker, RegistryFetch, RegistryMap } from "./registry";
|
|
14
16
|
|
|
15
17
|
/** Test seam: real fetch by default, an injected fake in tests. */
|
|
16
18
|
export interface AppOverrides {
|
|
@@ -113,4 +115,33 @@ export function createApp(overrides: AppOverrides = {}) {
|
|
|
113
115
|
return app;
|
|
114
116
|
}
|
|
115
117
|
|
|
116
|
-
|
|
118
|
+
const api = createApp();
|
|
119
|
+
|
|
120
|
+
const registry: RegistryMap = {};
|
|
121
|
+
|
|
122
|
+
const appFetch: RegistryFetch = (request, env, ctx) => api.fetch(request, env, ctx);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The Worker entry. `registry` is where a Service handler or a Dataset's page
|
|
126
|
+
* functions live, keyed by the entry name. A Hono app plugs straight in
|
|
127
|
+
* (`registry: { "<name>": app }`). A Dataset is an object of page functions
|
|
128
|
+
* (`registry: { "<name>": { "<objectType>": ({ cursor }, env, ctx) => page } }`).
|
|
129
|
+
* The platform calls a Service at `/.pple/registry/<name>/…` and a Dataset at
|
|
130
|
+
* `/.pple/registry/<name>/<objectType>`. Read a Service caller with
|
|
131
|
+
* `serviceCall(request)` from `./registry`.
|
|
132
|
+
*
|
|
133
|
+
* Under `vite dev` the same path is emulated locally. `import.meta.env.DEV`
|
|
134
|
+
* is false in `vite build`, so that branch and `dev-registry.ts` are absent
|
|
135
|
+
* from the deployed Worker, the same way the Persona bypass is (ADR-0011).
|
|
136
|
+
*/
|
|
137
|
+
const worker: PpleWorker = {
|
|
138
|
+
fetch(request, env, ctx) {
|
|
139
|
+
if (import.meta.env.DEV) {
|
|
140
|
+
return devWorkerFetch({ fetch: appFetch, registry }, request, env, ctx);
|
|
141
|
+
}
|
|
142
|
+
return appFetch(request, env, ctx);
|
|
143
|
+
},
|
|
144
|
+
registry,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export default worker;
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// Legal Identity (CONTEXT.md). A Builder App reads a person's name and
|
|
2
|
-
// citizen ID
|
|
3
|
-
// only at the moment it needs them.
|
|
4
|
-
// not store it.
|
|
2
|
+
// citizen ID from the Private Platform Service `platform/legal-identity`,
|
|
3
|
+
// only under a Use Grant of that entry, only at the moment it needs them.
|
|
4
|
+
// This module fetches that record. It does not store it.
|
|
5
5
|
//
|
|
6
6
|
// Offline (`npm run dev` with a Persona) the answer is a fixed fake. Member
|
|
7
7
|
// Personas have no citizen ID, matching production. The one ID this module
|
|
8
8
|
// invents fails the Thai checksum, so it can never be mistaken for a real one.
|
|
9
9
|
|
|
10
10
|
import type { KycStatus } from "../shared/api";
|
|
11
|
+
import { callService, platformError, type PlatformErrorCode } from "./call-service";
|
|
11
12
|
import { resolveDevPersona } from "./dev-personas";
|
|
12
13
|
|
|
13
14
|
export interface LegalIdentity {
|
|
@@ -21,9 +22,9 @@ export type LegalIdentityReason =
|
|
|
21
22
|
| "not_configured"
|
|
22
23
|
| "invalid_app_key"
|
|
23
24
|
| "not_granted"
|
|
25
|
+
| "needs_beta"
|
|
24
26
|
| "not_verified"
|
|
25
27
|
| "user_token_invalid"
|
|
26
|
-
| "identity_not_enabled"
|
|
27
28
|
| "upstream_unavailable";
|
|
28
29
|
|
|
29
30
|
export type LegalIdentityResult =
|
|
@@ -46,14 +47,10 @@ export const OFFLINE_LEGAL_NAME = "บุคคลทดสอบ ออฟไ
|
|
|
46
47
|
*/
|
|
47
48
|
export const OFFLINE_INVALID_ID = "1100000000000";
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
"user_token_invalid",
|
|
54
|
-
"identity_not_enabled",
|
|
55
|
-
"upstream_unavailable",
|
|
56
|
-
];
|
|
50
|
+
/** What this Service itself answers, with no `x-pple-error` header. */
|
|
51
|
+
const PROVIDER_REASONS = ["not_verified", "user_token_invalid", "upstream_unavailable"] as const;
|
|
52
|
+
|
|
53
|
+
type ProviderReason = (typeof PROVIDER_REASONS)[number];
|
|
57
54
|
|
|
58
55
|
/** True when `id` is 13 digits and its check digit matches. */
|
|
59
56
|
export function thaiIdChecksumValid(id: string): boolean {
|
|
@@ -66,14 +63,17 @@ export function thaiIdChecksumValid(id: string): boolean {
|
|
|
66
63
|
/**
|
|
67
64
|
* The caller's Legal Identity, or why it isn't available.
|
|
68
65
|
*
|
|
69
|
-
* `
|
|
70
|
-
*
|
|
71
|
-
*
|
|
66
|
+
* Offline (`npm run dev` with a Persona) this never calls out. Otherwise it
|
|
67
|
+
* wraps `callService(env, "platform/legal-identity", "", { method: "POST" },
|
|
68
|
+
* { userToken })`. `not_configured` means PPLE_SERVICES_URL or PPLE_APP_KEY
|
|
69
|
+
* is missing. `needs_beta` means the Use Grant is held and the app is still
|
|
70
|
+
* Draft. `not_listed`, `identity_unavailable`, `provider_unavailable` and
|
|
71
|
+
* `provider_timeout` are `upstream_unavailable`.
|
|
72
72
|
*/
|
|
73
73
|
export async function getLegalIdentity(
|
|
74
74
|
env: LegalIdentityEnv,
|
|
75
75
|
token: string,
|
|
76
|
-
fetchImpl
|
|
76
|
+
fetchImpl?: typeof fetch,
|
|
77
77
|
): Promise<LegalIdentityResult> {
|
|
78
78
|
// Same seam as authenticate: a Persona stands in for PPLE ID, and the
|
|
79
79
|
// block is dead-code-eliminated from the production build (ADR-0011).
|
|
@@ -87,28 +87,31 @@ export async function getLegalIdentity(
|
|
|
87
87
|
|
|
88
88
|
let res: Response;
|
|
89
89
|
try {
|
|
90
|
-
res = await
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
},
|
|
96
|
-
|
|
97
|
-
});
|
|
90
|
+
res = await callService(
|
|
91
|
+
env,
|
|
92
|
+
"platform/legal-identity",
|
|
93
|
+
"",
|
|
94
|
+
{ method: "POST" },
|
|
95
|
+
{ userToken: token, ...(fetchImpl ? { fetchImpl } : {}) },
|
|
96
|
+
);
|
|
98
97
|
} catch {
|
|
99
98
|
return { ok: false, reason: "upstream_unavailable" };
|
|
100
99
|
}
|
|
101
100
|
|
|
101
|
+
// A platform refusal carries `x-pple-error`. This Service's own answers
|
|
102
|
+
// do not, so a body `error` is trusted only when the header is absent.
|
|
103
|
+
if (res.headers.get("x-pple-error")) {
|
|
104
|
+
return { ok: false, reason: platformReason(platformError(res)) };
|
|
105
|
+
}
|
|
106
|
+
|
|
102
107
|
const body = (await res.json().catch(() => ({}))) as {
|
|
103
108
|
error?: string;
|
|
104
109
|
source?: string;
|
|
105
110
|
name?: string;
|
|
106
111
|
idCardNumber?: string | null;
|
|
107
112
|
};
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const reason = REASONS.find((r) => r === body.error);
|
|
111
|
-
if (reason) return { ok: false, reason };
|
|
113
|
+
const provider = providerReason(body.error);
|
|
114
|
+
if (provider) return { ok: false, reason: provider };
|
|
112
115
|
if (!res.ok) return { ok: false, reason: "upstream_unavailable" };
|
|
113
116
|
|
|
114
117
|
if (typeof body.name !== "string" || body.name === "") {
|
|
@@ -120,6 +123,22 @@ export async function getLegalIdentity(
|
|
|
120
123
|
return { ok: true, legalIdentity: { source, name: body.name, idCardNumber } };
|
|
121
124
|
}
|
|
122
125
|
|
|
126
|
+
function platformReason(code: PlatformErrorCode | null): LegalIdentityReason {
|
|
127
|
+
switch (code) {
|
|
128
|
+
case "invalid_app_key":
|
|
129
|
+
case "not_granted":
|
|
130
|
+
case "needs_beta":
|
|
131
|
+
case "user_token_invalid":
|
|
132
|
+
return code;
|
|
133
|
+
default:
|
|
134
|
+
return "upstream_unavailable";
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function providerReason(error: string | undefined): ProviderReason | null {
|
|
139
|
+
return PROVIDER_REASONS.find((reason) => reason === error) ?? null;
|
|
140
|
+
}
|
|
141
|
+
|
|
123
142
|
function offlineLegalIdentity(personaName: string | undefined): LegalIdentityResult {
|
|
124
143
|
const persona = resolveDevPersona(personaName);
|
|
125
144
|
const kyc: KycStatus = persona.actor.kyc;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Whether an Identity passes a Dataset Access Level (ADR-0028).
|
|
2
|
+
//
|
|
3
|
+
// A copy of workers/src/shared/registry/levels.ts. This file is copied into
|
|
4
|
+
// a Builder App, which cannot import the platform package. Offline reads
|
|
5
|
+
// (#270) use this copy so a Persona sees the same refusals as production.
|
|
6
|
+
// cli/test/registry-levels.test.ts keeps the two in step.
|
|
7
|
+
|
|
8
|
+
export type AccessLevel =
|
|
9
|
+
| "public"
|
|
10
|
+
| "member"
|
|
11
|
+
| "internal"
|
|
12
|
+
| {
|
|
13
|
+
roles?: readonly string[];
|
|
14
|
+
subs?: readonly string[] | "swept";
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** The Identity facts a level reads. Other Identity fields are ignored. */
|
|
18
|
+
export interface LevelIdentity {
|
|
19
|
+
actor: {
|
|
20
|
+
sub: string;
|
|
21
|
+
/** Non-null only while PPLE ID reports an active party membership. */
|
|
22
|
+
membership: { memberSince: string } | null;
|
|
23
|
+
};
|
|
24
|
+
effective: {
|
|
25
|
+
isDelegated: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* PPLE ID sub of the person whose authority is in effect.
|
|
28
|
+
* The Actor's own sub when nobody is delegating. Under Delegation, the
|
|
29
|
+
* supervisor's Zitadel sub, or null when that id is missing. Null matches
|
|
30
|
+
* no custom `subs`.
|
|
31
|
+
*/
|
|
32
|
+
sub: string | null;
|
|
33
|
+
activeRole: string;
|
|
34
|
+
extraRoles: readonly string[];
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PassesLevelOptions {
|
|
39
|
+
/**
|
|
40
|
+
* The snapshot's subs when the level says `subs: "swept"`.
|
|
41
|
+
* The caller passes the list (#267, #269). This function never loads it.
|
|
42
|
+
* Absent means empty, so swept subs admit nobody until a sweep has landed.
|
|
43
|
+
*/
|
|
44
|
+
sweptSubs?: readonly string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Live visibility stores `pple-ad:` plus the bare Active Role or Extra Role,
|
|
49
|
+
* the vocabulary `GET /role-options` returns. A custom level uses that
|
|
50
|
+
* same vocabulary, matched by exact string against that prefixed union.
|
|
51
|
+
* Eligible Roles are not part of the union.
|
|
52
|
+
*/
|
|
53
|
+
const ROLE_PREFIX = "pple-ad:";
|
|
54
|
+
|
|
55
|
+
function bareRole(role: string): string {
|
|
56
|
+
return role.startsWith(ROLE_PREFIX) ? role.slice(ROLE_PREFIX.length) : role;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function viewerRoles(identity: LevelIdentity): Set<string> {
|
|
60
|
+
const roles = new Set<string>();
|
|
61
|
+
const add = (role: unknown) => {
|
|
62
|
+
if (typeof role !== "string" || role === "") return;
|
|
63
|
+
roles.add(`${ROLE_PREFIX}${bareRole(role)}`);
|
|
64
|
+
};
|
|
65
|
+
add(identity.effective.activeRole);
|
|
66
|
+
const extras = identity.effective.extraRoles;
|
|
67
|
+
if (Array.isArray(extras)) {
|
|
68
|
+
for (const extra of extras) add(extra);
|
|
69
|
+
}
|
|
70
|
+
return roles;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function passesInternal(identity: LevelIdentity): boolean {
|
|
74
|
+
return typeof identity.effective.activeRole === "string" && identity.effective.activeRole !== "";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function passesMember(identity: LevelIdentity): boolean {
|
|
78
|
+
// A delegate is read with the supervisor's authority at every level.
|
|
79
|
+
// A null membership (the lookup failed, or there is none) does not pass on its own.
|
|
80
|
+
if (identity.effective.isDelegated === true) return true;
|
|
81
|
+
if (identity.actor.membership !== null && identity.actor.membership !== undefined) return true;
|
|
82
|
+
return passesInternal(identity);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Custom `subs` match the Actor's `sub`. Under Delegation they also match
|
|
87
|
+
* `effective.sub`, the supervisor's PPLE ID sub.
|
|
88
|
+
*
|
|
89
|
+
* A null or empty Effective sub matches nothing: a supervisor without a
|
|
90
|
+
* Zitadel id does not pass a level that names them. A session that is not
|
|
91
|
+
* delegated matches the Actor only, even if `effective.sub` says someone else.
|
|
92
|
+
*/
|
|
93
|
+
function subsAdmit(identity: LevelIdentity, subs: readonly string[]): boolean {
|
|
94
|
+
if (typeof identity.actor.sub === "string" && identity.actor.sub !== "" && subs.includes(identity.actor.sub)) {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
if (identity.effective.isDelegated !== true) return false;
|
|
98
|
+
const supervisor = identity.effective.sub;
|
|
99
|
+
if (typeof supervisor !== "string" || supervisor === "") return false;
|
|
100
|
+
return subs.includes(supervisor);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function subsMatch(
|
|
104
|
+
identity: LevelIdentity,
|
|
105
|
+
subs: unknown,
|
|
106
|
+
sweptSubs: readonly string[] | undefined,
|
|
107
|
+
): boolean {
|
|
108
|
+
const list = subs === "swept" ? sweptSubs : subs;
|
|
109
|
+
if (!Array.isArray(list)) return false;
|
|
110
|
+
const names = list.filter((item): item is string => typeof item === "string");
|
|
111
|
+
return subsAdmit(identity, names);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function rolesMatch(identity: LevelIdentity, roles: unknown): boolean {
|
|
115
|
+
if (!Array.isArray(roles)) return false;
|
|
116
|
+
const viewer = viewerRoles(identity);
|
|
117
|
+
return roles.some((role) => typeof role === "string" && viewer.has(role));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function passesLevel(
|
|
121
|
+
identity: LevelIdentity | null,
|
|
122
|
+
level: AccessLevel,
|
|
123
|
+
options?: PassesLevelOptions,
|
|
124
|
+
): boolean {
|
|
125
|
+
if (level === "public") return true;
|
|
126
|
+
if (identity === null || identity === undefined) return false;
|
|
127
|
+
if (level === "member") return passesMember(identity);
|
|
128
|
+
if (level === "internal") return passesInternal(identity);
|
|
129
|
+
if (typeof level !== "object") return false;
|
|
130
|
+
if (rolesMatch(identity, level.roles)) return true;
|
|
131
|
+
return subsMatch(identity, level.subs, options?.sweptSubs);
|
|
132
|
+
}
|