@pplethai/cli 0.6.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 +74 -7
- 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 +42 -2
- package/templates/golden-path/src/auth.ts +99 -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 +105 -7
- 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 +17 -0
- 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 +180 -0
- 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 +323 -0
- 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,376 @@
|
|
|
1
|
+
// A Consumer's read of one Dataset Object Type (ADR-0030). The Builder names
|
|
2
|
+
// the entry and the Object Type. This module adds the services address, the
|
|
3
|
+
// App Key, and — only when the caller passes one — the user token. It builds
|
|
4
|
+
// the filter, sort and limit query, looks an object up by id, and follows
|
|
5
|
+
// `next` when asked.
|
|
6
|
+
//
|
|
7
|
+
// Under `npm run dev`, while `DEV_PERSONA` is not `"none"`, the answer comes
|
|
8
|
+
// from the workspace overlay (ADR-0026, `dev-read-dataset.ts`). That branch
|
|
9
|
+
// is compiled out of the production build. Under vitest the fetch is the one
|
|
10
|
+
// the test injected: this module does not read the overlay, which CI does
|
|
11
|
+
// not have.
|
|
12
|
+
|
|
13
|
+
import { callService, PLATFORM_ERROR_HEADER, type CallServiceEnv } from "./call-service";
|
|
14
|
+
import { answerOfflineRead } from "./dev-read-dataset";
|
|
15
|
+
|
|
16
|
+
/** One page of a collection. `next` is null on the last page. */
|
|
17
|
+
export interface DatasetPage<T = Record<string, unknown>> {
|
|
18
|
+
objects: T[];
|
|
19
|
+
next: string | null;
|
|
20
|
+
sweptAt: string | null;
|
|
21
|
+
snapshot: number | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A singleton, or one object looked up by id. `object` is null when the sweep held none. */
|
|
25
|
+
export interface DatasetObject<T = Record<string, unknown>> {
|
|
26
|
+
object: T | null;
|
|
27
|
+
sweptAt: string | null;
|
|
28
|
+
snapshot: number | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DatasetRange {
|
|
32
|
+
gt?: string | number | boolean;
|
|
33
|
+
gte?: string | number | boolean;
|
|
34
|
+
lt?: string | number | boolean;
|
|
35
|
+
lte?: string | number | boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Equality, a list (`in`), or a range on one indexed field. */
|
|
39
|
+
export type DatasetFilterValue =
|
|
40
|
+
| string
|
|
41
|
+
| number
|
|
42
|
+
| boolean
|
|
43
|
+
| readonly (string | number | boolean)[]
|
|
44
|
+
| DatasetRange;
|
|
45
|
+
|
|
46
|
+
interface ReadDatasetCommon {
|
|
47
|
+
/** End-user PPLE ID token. Sent as `x-pple-user-token` only when set. */
|
|
48
|
+
userToken?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Test seam. Under vitest this is required: `readDataset` does not fall
|
|
51
|
+
* back to `globalThis.fetch`. Production omits it.
|
|
52
|
+
*/
|
|
53
|
+
fetchImpl?: typeof fetch;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ReadDatasetPageOptions extends ReadDatasetCommon {
|
|
57
|
+
shape?: "collection";
|
|
58
|
+
id?: undefined;
|
|
59
|
+
/** Equality, a repeated value (`in`), or `{ gt, gte, lt, lte }`. */
|
|
60
|
+
filter?: Record<string, DatasetFilterValue>;
|
|
61
|
+
/** `<index>` or `-<index>`. Omitted sorts by id ascending. */
|
|
62
|
+
sort?: string;
|
|
63
|
+
limit?: number;
|
|
64
|
+
/** Opaque cursor from a previous page. Bound to the filters and the sort. */
|
|
65
|
+
next?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Walk `next` until the last page and return every object. `data.next` is
|
|
68
|
+
* null. If a later page's `snapshot` differs, the walk starts over so one
|
|
69
|
+
* result does not mix two sweeps. After three such starts the result is
|
|
70
|
+
* `{ ok: false, error: null }`.
|
|
71
|
+
*/
|
|
72
|
+
follow?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ReadDatasetSingletonOptions extends ReadDatasetCommon {
|
|
76
|
+
shape: "singleton";
|
|
77
|
+
id?: undefined;
|
|
78
|
+
/** Sent as-is. The platform answers `invalid_query` for any query on a singleton. */
|
|
79
|
+
filter?: Record<string, DatasetFilterValue>;
|
|
80
|
+
sort?: string;
|
|
81
|
+
limit?: number;
|
|
82
|
+
next?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ReadDatasetLookupOptions extends ReadDatasetCommon {
|
|
86
|
+
/** Look up this id. A query string as well is `invalid_query`. */
|
|
87
|
+
id: string;
|
|
88
|
+
shape?: "collection" | "singleton";
|
|
89
|
+
filter?: Record<string, DatasetFilterValue>;
|
|
90
|
+
sort?: string;
|
|
91
|
+
limit?: number;
|
|
92
|
+
next?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type DatasetSuccess<T> = {
|
|
96
|
+
ok: true;
|
|
97
|
+
status: number;
|
|
98
|
+
etag: string | null;
|
|
99
|
+
data: T;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A platform or dev refusal. `error` is the `x-pple-error` header, including
|
|
104
|
+
* the dev-only `no_mock`. `platformError` reads the same header and returns
|
|
105
|
+
* null for `no_mock` and for any code it does not know. Null `error` means
|
|
106
|
+
* the header was absent.
|
|
107
|
+
*/
|
|
108
|
+
export type DatasetFailure = {
|
|
109
|
+
ok: false;
|
|
110
|
+
status: number;
|
|
111
|
+
error: string | null;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export type DatasetResult<T> = DatasetSuccess<T> | DatasetFailure;
|
|
115
|
+
|
|
116
|
+
const RANGE_OPS = ["gt", "gte", "lt", "lte"] as const;
|
|
117
|
+
const FOLLOW_ATTEMPTS = 3;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* `GET services.<domain>/<provider>/<name>/<objectType>[/<id>]`.
|
|
121
|
+
*
|
|
122
|
+
* A collection (the default) is a page. `shape: "singleton"` or `id` is one
|
|
123
|
+
* object. `follow: true` walks a collection to the end. The App Key is always
|
|
124
|
+
* `Authorization: Bearer`.
|
|
125
|
+
*/
|
|
126
|
+
export async function readDataset<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
127
|
+
env: CallServiceEnv,
|
|
128
|
+
entry: string,
|
|
129
|
+
objectType: string,
|
|
130
|
+
options: ReadDatasetLookupOptions,
|
|
131
|
+
): Promise<DatasetResult<DatasetObject<T>>>;
|
|
132
|
+
export async function readDataset<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
133
|
+
env: CallServiceEnv,
|
|
134
|
+
entry: string,
|
|
135
|
+
objectType: string,
|
|
136
|
+
options: ReadDatasetSingletonOptions,
|
|
137
|
+
): Promise<DatasetResult<DatasetObject<T>>>;
|
|
138
|
+
export async function readDataset<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
139
|
+
env: CallServiceEnv,
|
|
140
|
+
entry: string,
|
|
141
|
+
objectType: string,
|
|
142
|
+
options?: ReadDatasetPageOptions,
|
|
143
|
+
): Promise<DatasetResult<DatasetPage<T>>>;
|
|
144
|
+
export async function readDataset<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
145
|
+
env: CallServiceEnv,
|
|
146
|
+
entry: string,
|
|
147
|
+
objectType: string,
|
|
148
|
+
options: ReadDatasetPageOptions | ReadDatasetSingletonOptions | ReadDatasetLookupOptions = {},
|
|
149
|
+
): Promise<DatasetResult<DatasetPage<T> | DatasetObject<T>>> {
|
|
150
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(entry)) {
|
|
151
|
+
throw new Error('readDataset entry must be "<provider>/<name>"');
|
|
152
|
+
}
|
|
153
|
+
if (objectType === "" || /[/\s?#]/.test(objectType)) {
|
|
154
|
+
throw new Error("readDataset objectType must be one path segment");
|
|
155
|
+
}
|
|
156
|
+
if ("id" in options && options.id === "") {
|
|
157
|
+
throw new Error("readDataset id must not be empty");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (isPageRequest(options) && options.follow === true) {
|
|
161
|
+
return followPages(env, entry, objectType, options);
|
|
162
|
+
}
|
|
163
|
+
return readOnce(env, entry, objectType, options);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function followPages<T extends Record<string, unknown>>(
|
|
167
|
+
env: CallServiceEnv,
|
|
168
|
+
entry: string,
|
|
169
|
+
objectType: string,
|
|
170
|
+
options: ReadDatasetPageOptions,
|
|
171
|
+
): Promise<DatasetResult<DatasetPage<T>>> {
|
|
172
|
+
let cursor = options.next;
|
|
173
|
+
for (let attempt = 0; attempt < FOLLOW_ATTEMPTS; attempt++) {
|
|
174
|
+
const walked = await walkPages<T>(env, entry, objectType, options, cursor);
|
|
175
|
+
if (walked.outcome !== "moved") return walked.result;
|
|
176
|
+
cursor = undefined;
|
|
177
|
+
}
|
|
178
|
+
// The snapshot kept moving. There is no platform code for that.
|
|
179
|
+
return { ok: false, status: 200, error: null };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function walkPages<T extends Record<string, unknown>>(
|
|
183
|
+
env: CallServiceEnv,
|
|
184
|
+
entry: string,
|
|
185
|
+
objectType: string,
|
|
186
|
+
options: ReadDatasetPageOptions,
|
|
187
|
+
cursor: string | undefined,
|
|
188
|
+
): Promise<{ outcome: "done"; result: DatasetResult<DatasetPage<T>> } | { outcome: "moved" }> {
|
|
189
|
+
const first = await readOnce<T>(env, entry, objectType, pageOptions(options, cursor));
|
|
190
|
+
if (!first.ok) return { outcome: "done", result: first };
|
|
191
|
+
if (!isPage<T>(first.data)) return { outcome: "done", result: { ok: false, status: first.status, error: null } };
|
|
192
|
+
|
|
193
|
+
const objects = [...first.data.objects];
|
|
194
|
+
let next = first.data.next;
|
|
195
|
+
const snapshot = first.data.snapshot;
|
|
196
|
+
let sweptAt = first.data.sweptAt;
|
|
197
|
+
let etag = first.etag;
|
|
198
|
+
let status = first.status;
|
|
199
|
+
const seen = new Set<string>();
|
|
200
|
+
while (next !== null) {
|
|
201
|
+
if (seen.has(next)) return { outcome: "done", result: { ok: false, status: 400, error: null } };
|
|
202
|
+
seen.add(next);
|
|
203
|
+
const page = await readOnce<T>(env, entry, objectType, pageOptions(options, next));
|
|
204
|
+
if (!page.ok) return { outcome: "done", result: page };
|
|
205
|
+
if (!isPage<T>(page.data)) return { outcome: "done", result: { ok: false, status: page.status, error: null } };
|
|
206
|
+
if (page.data.snapshot !== snapshot) return { outcome: "moved" };
|
|
207
|
+
objects.push(...page.data.objects);
|
|
208
|
+
next = page.data.next;
|
|
209
|
+
sweptAt = page.data.sweptAt;
|
|
210
|
+
etag = page.etag;
|
|
211
|
+
status = page.status;
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
outcome: "done",
|
|
215
|
+
result: { ok: true, status, etag, data: { objects, next: null, sweptAt, snapshot } },
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function pageOptions(options: ReadDatasetPageOptions, next: string | undefined): ReadDatasetPageOptions {
|
|
220
|
+
const rest: ReadDatasetPageOptions = { ...options, follow: false };
|
|
221
|
+
if (next === undefined) {
|
|
222
|
+
delete rest.next;
|
|
223
|
+
return rest;
|
|
224
|
+
}
|
|
225
|
+
return { ...rest, next };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function readOnce<T extends Record<string, unknown>>(
|
|
229
|
+
env: CallServiceEnv,
|
|
230
|
+
entry: string,
|
|
231
|
+
objectType: string,
|
|
232
|
+
options: ReadDatasetPageOptions | ReadDatasetSingletonOptions | ReadDatasetLookupOptions,
|
|
233
|
+
): Promise<DatasetResult<DatasetPage<T> | DatasetObject<T>>> {
|
|
234
|
+
const response = await load(env, entry, objectType, options);
|
|
235
|
+
const error = response.headers.get(PLATFORM_ERROR_HEADER);
|
|
236
|
+
if (error !== null) return { ok: false, status: response.status, error };
|
|
237
|
+
if (!response.ok) return { ok: false, status: response.status, error: null };
|
|
238
|
+
|
|
239
|
+
const body = (await response.json().catch(() => null)) as unknown;
|
|
240
|
+
const etag = response.headers.get("etag");
|
|
241
|
+
if (isObjectRequest(options)) {
|
|
242
|
+
const data = asObject<T>(body);
|
|
243
|
+
if (!data) return { ok: false, status: response.status, error: null };
|
|
244
|
+
return { ok: true, status: response.status, etag, data };
|
|
245
|
+
}
|
|
246
|
+
const data = asPage<T>(body);
|
|
247
|
+
if (!data) return { ok: false, status: response.status, error: null };
|
|
248
|
+
return { ok: true, status: response.status, etag, data };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function load(
|
|
252
|
+
env: CallServiceEnv,
|
|
253
|
+
entry: string,
|
|
254
|
+
objectType: string,
|
|
255
|
+
options: ReadDatasetPageOptions | ReadDatasetSingletonOptions | ReadDatasetLookupOptions,
|
|
256
|
+
): Promise<Response> {
|
|
257
|
+
const search = datasetSearch(options);
|
|
258
|
+
// `import.meta.env.DEV` is false in the production build, so this branch
|
|
259
|
+
// and `dev-read-dataset.ts` are absent from the deployed Worker. Vitest
|
|
260
|
+
// keeps the injected fetch even when a Persona is set.
|
|
261
|
+
if (import.meta.env.DEV && env.DEV_PERSONA !== "none" && !underVitest()) {
|
|
262
|
+
return Promise.resolve(answerOfflineRead(env, entry, objectType, {
|
|
263
|
+
id: "id" in options ? options.id : undefined,
|
|
264
|
+
search,
|
|
265
|
+
userToken: options.userToken,
|
|
266
|
+
}));
|
|
267
|
+
}
|
|
268
|
+
const id = "id" in options ? options.id : undefined;
|
|
269
|
+
return callService(env, entry, datasetPath(objectType, id, search), { method: "GET" }, {
|
|
270
|
+
userToken: options.userToken,
|
|
271
|
+
fetchImpl: options.fetchImpl,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function datasetPath(
|
|
276
|
+
objectType: string,
|
|
277
|
+
id: string | undefined,
|
|
278
|
+
search: readonly (readonly [string, string])[],
|
|
279
|
+
): string {
|
|
280
|
+
const head = id !== undefined
|
|
281
|
+
? `${encodeURIComponent(objectType)}/${encodeURIComponent(id)}`
|
|
282
|
+
: encodeURIComponent(objectType);
|
|
283
|
+
if (search.length === 0) return head;
|
|
284
|
+
const params = new URLSearchParams();
|
|
285
|
+
for (const [key, value] of search) params.append(key, value);
|
|
286
|
+
return `${head}?${params.toString()}`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function datasetSearch(
|
|
290
|
+
options: ReadDatasetPageOptions | ReadDatasetSingletonOptions | ReadDatasetLookupOptions,
|
|
291
|
+
): [string, string][] {
|
|
292
|
+
const entries: [string, string][] = [];
|
|
293
|
+
const filter = "filter" in options ? options.filter : undefined;
|
|
294
|
+
if (filter) {
|
|
295
|
+
for (const [field, value] of Object.entries(filter)) {
|
|
296
|
+
if (Array.isArray(value)) {
|
|
297
|
+
for (const item of value) entries.push([field, String(item)]);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (isRange(value)) {
|
|
301
|
+
for (const op of RANGE_OPS) {
|
|
302
|
+
const bound = value[op];
|
|
303
|
+
if (bound !== undefined) entries.push([`${field}[${op}]`, String(bound)]);
|
|
304
|
+
}
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
entries.push([field, String(value)]);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (options.sort !== undefined) entries.push(["sort", options.sort]);
|
|
311
|
+
if (options.limit !== undefined) entries.push(["limit", String(options.limit)]);
|
|
312
|
+
if (options.next !== undefined) entries.push(["next", options.next]);
|
|
313
|
+
return entries;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function isRange(value: DatasetFilterValue): value is DatasetRange {
|
|
317
|
+
if (typeof value !== "object") return false;
|
|
318
|
+
const keys = Object.keys(value);
|
|
319
|
+
return keys.length > 0 && keys.every((key) => key === "gt" || key === "gte" || key === "lt" || key === "lte");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function isPageRequest(
|
|
323
|
+
options: ReadDatasetPageOptions | ReadDatasetSingletonOptions | ReadDatasetLookupOptions,
|
|
324
|
+
): options is ReadDatasetPageOptions {
|
|
325
|
+
return !("id" in options && options.id !== undefined) && options.shape !== "singleton";
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function isObjectRequest(
|
|
329
|
+
options: ReadDatasetPageOptions | ReadDatasetSingletonOptions | ReadDatasetLookupOptions,
|
|
330
|
+
): boolean {
|
|
331
|
+
return ("id" in options && options.id !== undefined) || options.shape === "singleton";
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function isPage<T>(data: DatasetPage<T> | DatasetObject<T>): data is DatasetPage<T> {
|
|
335
|
+
return "objects" in data;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function asPage<T extends Record<string, unknown>>(body: unknown): DatasetPage<T> | null {
|
|
339
|
+
if (!isRecord(body) || !Array.isArray(body.objects)) return null;
|
|
340
|
+
if (!body.objects.every((item) => isRecord(item))) return null;
|
|
341
|
+
if (!(body.next === null || typeof body.next === "string")) return null;
|
|
342
|
+
const sweptAt = asSweptAt(body.sweptAt);
|
|
343
|
+
const snapshot = asSnapshot(body.snapshot);
|
|
344
|
+
if (sweptAt === undefined || snapshot === undefined) return null;
|
|
345
|
+
return { objects: body.objects as T[], next: body.next, sweptAt, snapshot };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function asObject<T extends Record<string, unknown>>(body: unknown): DatasetObject<T> | null {
|
|
349
|
+
if (!isRecord(body)) return null;
|
|
350
|
+
if (!(body.object === null || isRecord(body.object))) return null;
|
|
351
|
+
const sweptAt = asSweptAt(body.sweptAt);
|
|
352
|
+
const snapshot = asSnapshot(body.snapshot);
|
|
353
|
+
if (sweptAt === undefined || snapshot === undefined) return null;
|
|
354
|
+
return { object: body.object as T | null, sweptAt, snapshot };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function asSweptAt(value: unknown): string | null | undefined {
|
|
358
|
+
if (value === null) return null;
|
|
359
|
+
if (typeof value === "string") return value;
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function asSnapshot(value: unknown): number | null | undefined {
|
|
364
|
+
if (value === null) return null;
|
|
365
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
370
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function underVitest(): boolean {
|
|
374
|
+
const flag = import.meta.env.VITEST;
|
|
375
|
+
return import.meta.env.MODE === "test" || flag === true || flag === "true";
|
|
376
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// A Builder Service or Dataset (ADR-0024, ADR-0027). The platform calls
|
|
2
|
+
// `registry[name]` on the Worker default export at `/.pple/registry/<name>/…`.
|
|
3
|
+
// `serviceCall` reads who is calling from the headers the platform set. It
|
|
4
|
+
// returns null for any other request, including public traffic: the wrapper
|
|
5
|
+
// strips `x-pple-*` before app code sees it. Under `npm run dev` the template
|
|
6
|
+
// emulates that path and those headers; that emulation is compiled out of
|
|
7
|
+
// production.
|
|
8
|
+
//
|
|
9
|
+
// The Identity is one header, `x-pple-identity`: base64url (RFC 4648 §5, no
|
|
10
|
+
// padding) of the UTF-8 JSON `{ actor, effective }`. A Thai name is not a
|
|
11
|
+
// reliable raw header value. The header is absent when the entry declares
|
|
12
|
+
// `userToken: none`, and then `identity` is null. The user's token is never
|
|
13
|
+
// one of these headers.
|
|
14
|
+
|
|
15
|
+
import { IdentitySchema, type Identity } from "../shared/api";
|
|
16
|
+
import type { Env } from "./env";
|
|
17
|
+
|
|
18
|
+
export const CONSUMER_HEADER = "x-pple-consumer";
|
|
19
|
+
export const ENTRY_HEADER = "x-pple-entry";
|
|
20
|
+
export const IDENTITY_HEADER = "x-pple-identity";
|
|
21
|
+
|
|
22
|
+
export interface ServiceCall {
|
|
23
|
+
/** The Consumer, named from its App Key. */
|
|
24
|
+
consumer: string;
|
|
25
|
+
/** `<provider>/<name>`. */
|
|
26
|
+
entry: string;
|
|
27
|
+
/**
|
|
28
|
+
* Effective (including `sub`), Actor, and `effective.isDelegated`. Null
|
|
29
|
+
* when the entry declares `userToken: none`: nothing about the user was
|
|
30
|
+
* passed.
|
|
31
|
+
*/
|
|
32
|
+
identity: Identity | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Fetch-shaped, so a Hono app (which has `.fetch`) plugs straight in. */
|
|
36
|
+
export type RegistryFetch = (
|
|
37
|
+
request: Request,
|
|
38
|
+
env: Env,
|
|
39
|
+
ctx: ExecutionContext,
|
|
40
|
+
) => Response | Promise<Response>;
|
|
41
|
+
|
|
42
|
+
export type RegistryHandler = RegistryFetch | { fetch: RegistryFetch };
|
|
43
|
+
|
|
44
|
+
/** What a Dataset page function is called with. `cursor` is null on the first page. */
|
|
45
|
+
export interface PageInput {
|
|
46
|
+
cursor: string | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* One page of a collection. `next` is the Provider's own cursor, or null on
|
|
51
|
+
* the last page. `subs` is the swept audience, and only on the last page.
|
|
52
|
+
*/
|
|
53
|
+
export interface CollectionPage<T = unknown> {
|
|
54
|
+
objects: T[];
|
|
55
|
+
next: string | null;
|
|
56
|
+
subs?: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A singleton. `object` is null when the Dataset currently holds none. `subs` sits beside it. */
|
|
60
|
+
export interface SingletonPage<T = unknown> {
|
|
61
|
+
object: T | null;
|
|
62
|
+
subs?: string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type CollectionPageFunction<T = unknown> = (
|
|
66
|
+
input: PageInput,
|
|
67
|
+
env: Env,
|
|
68
|
+
ctx: ExecutionContext,
|
|
69
|
+
) => CollectionPage<T> | Promise<CollectionPage<T>>;
|
|
70
|
+
|
|
71
|
+
export type SingletonPageFunction<T = unknown> = (
|
|
72
|
+
input: PageInput,
|
|
73
|
+
env: Env,
|
|
74
|
+
ctx: ExecutionContext,
|
|
75
|
+
) => SingletonPage<T> | Promise<SingletonPage<T>>;
|
|
76
|
+
|
|
77
|
+
/** One Object Type's page function. The declared shape says which return to write. */
|
|
78
|
+
export type ObjectTypePageFunction<T = unknown> = CollectionPageFunction<T> | SingletonPageFunction<T>;
|
|
79
|
+
|
|
80
|
+
/** Page functions for one Dataset, keyed by Object Type name. */
|
|
81
|
+
export type DatasetPages = Record<string, ObjectTypePageFunction>;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Service handlers and Dataset page functions, keyed by the entry name.
|
|
85
|
+
* A Service value is fetch-shaped. A Dataset value is {@link DatasetPages}.
|
|
86
|
+
*/
|
|
87
|
+
export type RegistryMap = Record<string, RegistryHandler | DatasetPages>;
|
|
88
|
+
|
|
89
|
+
export interface PpleWorker {
|
|
90
|
+
fetch(request: Request, env: Env, ctx: ExecutionContext): Response | Promise<Response>;
|
|
91
|
+
registry?: RegistryMap;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function decodeIdentity(value: string): Identity | null {
|
|
95
|
+
try {
|
|
96
|
+
const pad = value.length % 4 === 0 ? "" : "=".repeat(4 - (value.length % 4));
|
|
97
|
+
const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/") + pad);
|
|
98
|
+
const bytes = new Uint8Array(binary.length);
|
|
99
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
100
|
+
const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
|
|
101
|
+
const identity = IdentitySchema.safeParse(parsed);
|
|
102
|
+
return identity.success ? identity.data : null;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Who is calling this Service, or null when the request is not a platform
|
|
110
|
+
* registry call. Never includes the user's token.
|
|
111
|
+
*/
|
|
112
|
+
export function serviceCall(request: Request): ServiceCall | null {
|
|
113
|
+
const consumer = (request.headers.get(CONSUMER_HEADER) ?? "").trim();
|
|
114
|
+
const entry = (request.headers.get(ENTRY_HEADER) ?? "").trim();
|
|
115
|
+
if (!consumer || !entry) return null;
|
|
116
|
+
const encoded = request.headers.get(IDENTITY_HEADER);
|
|
117
|
+
return {
|
|
118
|
+
consumer,
|
|
119
|
+
entry,
|
|
120
|
+
identity: encoded ? decodeIdentity(encoded) : null,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -7,8 +7,21 @@ interface ImportMetaEnv {
|
|
|
7
7
|
readonly DEV: boolean;
|
|
8
8
|
readonly PROD: boolean;
|
|
9
9
|
readonly MODE: string;
|
|
10
|
+
/** Set by vitest. Absent from `vite dev` and the production build. */
|
|
11
|
+
readonly VITEST?: boolean | "true";
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
interface ImportMeta {
|
|
13
15
|
readonly env: ImportMetaEnv;
|
|
16
|
+
/**
|
|
17
|
+
* Vite loads the matched files at build time. The offline overlay glob
|
|
18
|
+
* keeps `.pple/registry` in the fixed base and sits in an
|
|
19
|
+
* `import.meta.env.DEV` branch, so the production Worker does not contain it.
|
|
20
|
+
* `?mtime` is the overlay file's write time (`overlay-mtime.mjs`), used as
|
|
21
|
+
* `sweptAt` by offline `readDataset`.
|
|
22
|
+
*/
|
|
23
|
+
glob(
|
|
24
|
+
pattern: string,
|
|
25
|
+
options: { eager: true; query: string; import: string },
|
|
26
|
+
): Record<string, unknown>;
|
|
14
27
|
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// callService (issue #253). The harness is the platform: tests see the URL,
|
|
2
|
+
// the method, and the headers the Builder would observe, and the record
|
|
3
|
+
// keeps no App Key and no user token.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { callService, platformError } from "../src/call-service";
|
|
7
|
+
import { makeHarness, serviceRefusal } from "./harness";
|
|
8
|
+
|
|
9
|
+
const env = {
|
|
10
|
+
DEV_PERSONA: "none",
|
|
11
|
+
PPLE_SERVICES_URL: "https://services.test",
|
|
12
|
+
PPLE_APP_KEY: "app-key",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
describe("callService", () => {
|
|
16
|
+
it("sends the App Key and the user token to the entry URL", async () => {
|
|
17
|
+
let seen: { authorization: string | null; userToken: string | null; method: string } | undefined;
|
|
18
|
+
const h = makeHarness(
|
|
19
|
+
{
|
|
20
|
+
services: (req) => {
|
|
21
|
+
seen = {
|
|
22
|
+
authorization: req.headers.get("authorization"),
|
|
23
|
+
userToken: req.headers.get("x-pple-user-token"),
|
|
24
|
+
method: req.method,
|
|
25
|
+
};
|
|
26
|
+
return Response.json({ ok: true });
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
env,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const res = await callService(
|
|
33
|
+
h.testEnv,
|
|
34
|
+
"platform/legal-identity",
|
|
35
|
+
"",
|
|
36
|
+
{
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: {
|
|
39
|
+
accept: "application/json",
|
|
40
|
+
authorization: "Bearer caller",
|
|
41
|
+
"x-pple-user-token": "smuggled",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{ userToken: "user-token", fetchImpl: h.fetchImpl },
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
expect(res.status).toBe(200);
|
|
48
|
+
expect(seen).toEqual({
|
|
49
|
+
authorization: "Bearer app-key",
|
|
50
|
+
userToken: "user-token",
|
|
51
|
+
method: "POST",
|
|
52
|
+
});
|
|
53
|
+
expect(h.calls.services).toEqual([
|
|
54
|
+
{
|
|
55
|
+
path: "/platform/legal-identity",
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { accept: "application/json" },
|
|
58
|
+
},
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("omits the user token unless one was given, and joins the path onto the entry", async () => {
|
|
63
|
+
let userToken: string | null = "unset";
|
|
64
|
+
const h = makeHarness(
|
|
65
|
+
{
|
|
66
|
+
services: (req) => {
|
|
67
|
+
userToken = req.headers.get("x-pple-user-token");
|
|
68
|
+
return new Response(null, { status: 204 });
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{ ...env, PPLE_SERVICES_URL: "https://services.test/" },
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
await callService(
|
|
75
|
+
h.testEnv,
|
|
76
|
+
"shop/orders",
|
|
77
|
+
"items/1?limit=2",
|
|
78
|
+
{ method: "GET", headers: { "x-pple-user-token": "smuggled" } },
|
|
79
|
+
{ fetchImpl: h.fetchImpl },
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(userToken).toBeNull();
|
|
83
|
+
expect(h.calls.services).toEqual([
|
|
84
|
+
{ path: "/shop/orders/items/1?limit=2", method: "GET", headers: {} },
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("uses the injected fetch under vitest", async () => {
|
|
89
|
+
const fetchImpl = vi.fn<typeof fetch>(async () => new Response("from-injected"));
|
|
90
|
+
const globalFetch = vi.spyOn(globalThis, "fetch");
|
|
91
|
+
try {
|
|
92
|
+
const res = await callService(env, "platform/legal-identity", "?dry=1", { method: "POST" }, {
|
|
93
|
+
userToken: "t",
|
|
94
|
+
fetchImpl,
|
|
95
|
+
});
|
|
96
|
+
expect(await res.text()).toBe("from-injected");
|
|
97
|
+
expect(fetchImpl).toHaveBeenCalledOnce();
|
|
98
|
+
expect(String(fetchImpl.mock.calls[0]?.[0])).toBe("https://services.test/platform/legal-identity?dry=1");
|
|
99
|
+
expect(globalFetch).not.toHaveBeenCalled();
|
|
100
|
+
} finally {
|
|
101
|
+
globalFetch.mockRestore();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
await expect(callService(env, "platform/legal-identity", "")).rejects.toThrow(/injected fetch/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("keeps the injected fetch when a persona and DEV_REGISTRY_ERROR are set", async () => {
|
|
108
|
+
const fetchImpl = vi.fn<typeof fetch>(async () => new Response("from-injected"));
|
|
109
|
+
const res = await callService(
|
|
110
|
+
{ ...env, DEV_PERSONA: "hq-staff", DEV_REGISTRY_ERROR: "not_granted" },
|
|
111
|
+
"shop/orders",
|
|
112
|
+
"/items/1",
|
|
113
|
+
{ method: "GET" },
|
|
114
|
+
{ userToken: "t", fetchImpl },
|
|
115
|
+
);
|
|
116
|
+
expect(await res.text()).toBe("from-injected");
|
|
117
|
+
expect(fetchImpl).toHaveBeenCalledOnce();
|
|
118
|
+
expect(String(fetchImpl.mock.calls[0]?.[0])).toBe("https://services.test/shop/orders/items/1");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("does not call out without the services URL or the App Key", async () => {
|
|
122
|
+
const fetchImpl = vi.fn<typeof fetch>();
|
|
123
|
+
await expect(
|
|
124
|
+
callService({ PPLE_SERVICES_URL: "https://services.test" }, "platform/legal-identity", "", {}, { fetchImpl }),
|
|
125
|
+
).rejects.toThrow(/PPLE_APP_KEY/);
|
|
126
|
+
await expect(
|
|
127
|
+
callService({ PPLE_APP_KEY: "app-key" }, "platform/legal-identity", "", {}, { fetchImpl }),
|
|
128
|
+
).rejects.toThrow(/PPLE_SERVICES_URL/);
|
|
129
|
+
expect(fetchImpl).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("services fake", () => {
|
|
134
|
+
it("answers as any Provider and refuses with any x-pple-error code", async () => {
|
|
135
|
+
const codes = [
|
|
136
|
+
"invalid_app_key",
|
|
137
|
+
"not_listed",
|
|
138
|
+
"not_granted",
|
|
139
|
+
"needs_beta",
|
|
140
|
+
"user_token_invalid",
|
|
141
|
+
"identity_unavailable",
|
|
142
|
+
"provider_unavailable",
|
|
143
|
+
"provider_timeout",
|
|
144
|
+
"no_mock",
|
|
145
|
+
"made_up",
|
|
146
|
+
];
|
|
147
|
+
let next = Response.json({ items: ["a"] });
|
|
148
|
+
const h = makeHarness({ services: () => next }, env);
|
|
149
|
+
|
|
150
|
+
const answered = await callService(h.testEnv, "shop/catalog", "/search", { method: "GET" }, {
|
|
151
|
+
fetchImpl: h.fetchImpl,
|
|
152
|
+
});
|
|
153
|
+
expect(answered.status).toBe(200);
|
|
154
|
+
expect(await answered.json()).toEqual({ items: ["a"] });
|
|
155
|
+
expect(platformError(answered)).toBeNull();
|
|
156
|
+
|
|
157
|
+
for (const code of codes) {
|
|
158
|
+
next = serviceRefusal(code, 418);
|
|
159
|
+
const res = await callService(h.testEnv, "shop/catalog", "", { method: "POST" }, {
|
|
160
|
+
userToken: "t",
|
|
161
|
+
fetchImpl: h.fetchImpl,
|
|
162
|
+
});
|
|
163
|
+
expect(res.status).toBe(418);
|
|
164
|
+
expect(res.headers.get("x-pple-error")).toBe(code);
|
|
165
|
+
expect(await res.json()).toEqual({ error: code });
|
|
166
|
+
const known = platformError(res);
|
|
167
|
+
if (code === "no_mock" || code === "made_up") expect(known).toBeNull();
|
|
168
|
+
else expect(known).toBe(code);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
expect(h.calls.services[0]).toEqual({ path: "/shop/catalog/search", method: "GET", headers: {} });
|
|
172
|
+
expect(h.calls.services).toHaveLength(1 + codes.length);
|
|
173
|
+
for (const call of h.calls.services) {
|
|
174
|
+
expect(call.headers).not.toHaveProperty("authorization");
|
|
175
|
+
expect(call.headers).not.toHaveProperty("x-pple-user-token");
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|