@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.
Files changed (51) hide show
  1. package/dist/bin.js +171 -22
  2. package/package.json +3 -2
  3. package/templates/golden-path/.dev.vars.example +19 -1
  4. package/templates/golden-path/CLAUDE.md +74 -7
  5. package/templates/golden-path/README.md +27 -2
  6. package/templates/golden-path/gitignore +3 -0
  7. package/templates/golden-path/overlay-mtime.mjs +30 -0
  8. package/templates/golden-path/package-lock.json +19 -1
  9. package/templates/golden-path/package.json +3 -2
  10. package/templates/golden-path/scripts/assert-dev-compiled-out.mjs +42 -0
  11. package/templates/golden-path/shared/api.ts +42 -2
  12. package/templates/golden-path/src/auth.ts +99 -7
  13. package/templates/golden-path/src/call-service.ts +140 -0
  14. package/templates/golden-path/src/dataset-query.ts +470 -0
  15. package/templates/golden-path/src/dataset-schema.ts +398 -0
  16. package/templates/golden-path/src/dev-call-service.ts +391 -0
  17. package/templates/golden-path/src/dev-personas.ts +105 -7
  18. package/templates/golden-path/src/dev-read-dataset.ts +429 -0
  19. package/templates/golden-path/src/dev-registry.ts +276 -0
  20. package/templates/golden-path/src/env.ts +17 -0
  21. package/templates/golden-path/src/identity-header.ts +17 -0
  22. package/templates/golden-path/src/index.ts +32 -1
  23. package/templates/golden-path/src/legal-identity.ts +180 -0
  24. package/templates/golden-path/src/levels.ts +132 -0
  25. package/templates/golden-path/src/read-dataset.ts +376 -0
  26. package/templates/golden-path/src/registry.ts +122 -0
  27. package/templates/golden-path/src/vite-env.d.ts +13 -0
  28. package/templates/golden-path/test/call-service.test.ts +178 -0
  29. package/templates/golden-path/test/dev-call-service.test.ts +279 -0
  30. package/templates/golden-path/test/dev-read-dataset.test.ts +270 -0
  31. package/templates/golden-path/test/dev-registry.test.ts +375 -0
  32. package/templates/golden-path/test/effective-sub.test.ts +101 -0
  33. package/templates/golden-path/test/fixtures/overlay/directory/branches/entry.md +85 -0
  34. package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/audience.json +3 -0
  35. package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/named.json +3 -0
  36. package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/offices.json +6 -0
  37. package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/roster.json +3 -0
  38. package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/summary.json +1 -0
  39. package/templates/golden-path/test/fixtures/overlay/platform/legal-identity/entry.md +8 -0
  40. package/templates/golden-path/test/fixtures/overlay/platform/legal-identity/mocks/responses.json +13 -0
  41. package/templates/golden-path/test/fixtures/overlay/shop/catalog/entry.md +8 -0
  42. package/templates/golden-path/test/fixtures/overlay/shop/catalog/mocks/responses.json +7 -0
  43. package/templates/golden-path/test/fixtures/overlay/shop/orders/entry.md +12 -0
  44. package/templates/golden-path/test/fixtures/overlay/shop/orders/mocks/responses.json +36 -0
  45. package/templates/golden-path/test/harness.ts +101 -16
  46. package/templates/golden-path/test/legal-identity.test.ts +323 -0
  47. package/templates/golden-path/test/membership.test.ts +105 -0
  48. package/templates/golden-path/test/read-dataset.test.ts +214 -0
  49. package/templates/golden-path/test/service-call.test.ts +166 -0
  50. package/templates/golden-path/vite.config.ts +2 -1
  51. package/templates/golden-path/vitest.config.ts +2 -0
@@ -0,0 +1,214 @@
1
+ // readDataset (issue #270). The harness is the platform: tests see the URL,
2
+ // the headers, the page, and the refusal a Builder would observe.
3
+
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { platformError } from "../src/call-service";
6
+ import { readDataset } from "../src/read-dataset";
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
+ const SWEPT = "2026-11-01T00:00:00.000Z";
16
+
17
+ describe("readDataset", () => {
18
+ it("sends the App Key, the user token and the query, and returns one page", async () => {
19
+ let seen: { authorization: string | null; userToken: string | null } | undefined;
20
+ const h = makeHarness(
21
+ {
22
+ services: (req) => {
23
+ seen = {
24
+ authorization: req.headers.get("authorization"),
25
+ userToken: req.headers.get("x-pple-user-token"),
26
+ };
27
+ return Response.json(
28
+ {
29
+ objects: [{ id: "branch-a", province: "เชียงใหม่" }],
30
+ next: "cursor-1",
31
+ sweptAt: SWEPT,
32
+ snapshot: 3,
33
+ },
34
+ { headers: { etag: '"3-abc"' } },
35
+ );
36
+ },
37
+ },
38
+ env,
39
+ );
40
+
41
+ const result = await readDataset(h.testEnv, "directory/branches", "offices", {
42
+ userToken: "user-token",
43
+ filter: { province: "เชียงใหม่", seats: { gte: 4, lt: 9 }, status: ["open", "closed"] },
44
+ sort: "-seats",
45
+ limit: 2,
46
+ fetchImpl: h.fetchImpl,
47
+ });
48
+
49
+ expect(result).toEqual({
50
+ ok: true,
51
+ status: 200,
52
+ etag: '"3-abc"',
53
+ data: {
54
+ objects: [{ id: "branch-a", province: "เชียงใหม่" }],
55
+ next: "cursor-1",
56
+ sweptAt: SWEPT,
57
+ snapshot: 3,
58
+ },
59
+ });
60
+ expect(seen).toEqual({ authorization: "Bearer app-key", userToken: "user-token" });
61
+ const url = new URL(`https://services.test${h.calls.services[0]?.path ?? ""}`);
62
+ expect(url.pathname).toBe("/directory/branches/offices");
63
+ expect(url.searchParams.get("province")).toBe("เชียงใหม่");
64
+ expect(url.searchParams.get("seats[gte]")).toBe("4");
65
+ expect(url.searchParams.get("seats[lt]")).toBe("9");
66
+ expect(url.searchParams.getAll("status")).toEqual(["open", "closed"]);
67
+ expect(url.searchParams.get("sort")).toBe("-seats");
68
+ expect(url.searchParams.get("limit")).toBe("2");
69
+ expect(h.calls.services[0]?.method).toBe("GET");
70
+ expect(h.calls.services[0]?.headers).not.toHaveProperty("authorization");
71
+ expect(h.calls.services[0]?.headers).not.toHaveProperty("x-pple-user-token");
72
+ });
73
+
74
+ it("omits the user token unless one was given, and looks an object up by id", async () => {
75
+ let userToken: string | null = "unset";
76
+ const h = makeHarness(
77
+ {
78
+ services: (req) => {
79
+ userToken = req.headers.get("x-pple-user-token");
80
+ return Response.json({
81
+ object: { id: "branch-b", province: "กรุงเทพ" },
82
+ sweptAt: SWEPT,
83
+ snapshot: 3,
84
+ });
85
+ },
86
+ },
87
+ env,
88
+ );
89
+
90
+ const result = await readDataset(h.testEnv, "directory/branches", "offices", {
91
+ id: "branch-b",
92
+ fetchImpl: h.fetchImpl,
93
+ });
94
+
95
+ expect(userToken).toBeNull();
96
+ expect(result.ok).toBe(true);
97
+ if (!result.ok) return;
98
+ expect(result.data).toEqual({
99
+ object: { id: "branch-b", province: "กรุงเทพ" },
100
+ sweptAt: SWEPT,
101
+ snapshot: 3,
102
+ });
103
+ expect(h.calls.services[0]?.path).toBe("/directory/branches/offices/branch-b");
104
+ });
105
+
106
+ it("reads a singleton", async () => {
107
+ const h = makeHarness(
108
+ {
109
+ services: () => Response.json({ object: { title: "สรุปสาขา" }, sweptAt: SWEPT, snapshot: 1 }),
110
+ },
111
+ env,
112
+ );
113
+ const result = await readDataset(h.testEnv, "directory/branches", "summary", {
114
+ shape: "singleton",
115
+ fetchImpl: h.fetchImpl,
116
+ });
117
+ expect(result).toMatchObject({
118
+ ok: true,
119
+ data: { object: { title: "สรุปสาขา" }, sweptAt: SWEPT, snapshot: 1 },
120
+ });
121
+ expect(h.calls.services[0]?.path).toBe("/directory/branches/summary");
122
+ });
123
+
124
+ it("reads platform refusals from x-pple-error", async () => {
125
+ const codes = [
126
+ ["not_found", 404],
127
+ ["invalid_query", 400],
128
+ ["level_not_met", 403],
129
+ ["not_granted", 403],
130
+ ["user_token_invalid", 401],
131
+ ["no_mock", 404],
132
+ ] as const;
133
+ let next = serviceRefusal("not_found", 404);
134
+ const h = makeHarness({ services: () => next }, env);
135
+
136
+ for (const [code, status] of codes) {
137
+ next = serviceRefusal(code, status);
138
+ const result = await readDataset(h.testEnv, "directory/branches", "offices", {
139
+ userToken: "t",
140
+ fetchImpl: h.fetchImpl,
141
+ });
142
+ expect(result).toEqual({ ok: false, status, error: code });
143
+ const header = serviceRefusal(code, status);
144
+ if (code === "no_mock") expect(platformError(header)).toBeNull();
145
+ else expect(platformError(header)).toBe(code);
146
+ }
147
+ });
148
+
149
+ it("follows next and restarts when the snapshot changes", async () => {
150
+ const pages: { path: string }[] = [];
151
+ const h = makeHarness(
152
+ {
153
+ services: (req) => {
154
+ const url = new URL(req.url);
155
+ pages.push({ path: url.pathname + url.search });
156
+ const cursor = url.searchParams.get("next");
157
+ if (cursor === null && pages.length === 1) {
158
+ return Response.json({ objects: [{ id: "a" }], next: "old", sweptAt: SWEPT, snapshot: 1 });
159
+ }
160
+ if (cursor === "old") {
161
+ return Response.json({ objects: [{ id: "b" }], next: null, sweptAt: SWEPT, snapshot: 2 });
162
+ }
163
+ if (cursor === null) {
164
+ return Response.json({ objects: [{ id: "a2" }], next: "new", sweptAt: SWEPT, snapshot: 2 });
165
+ }
166
+ return Response.json({ objects: [{ id: "b2" }], next: null, sweptAt: SWEPT, snapshot: 2 });
167
+ },
168
+ },
169
+ env,
170
+ );
171
+
172
+ const result = await readDataset(h.testEnv, "directory/branches", "offices", {
173
+ limit: 1,
174
+ follow: true,
175
+ fetchImpl: h.fetchImpl,
176
+ });
177
+
178
+ expect(result).toMatchObject({
179
+ ok: true,
180
+ data: {
181
+ objects: [{ id: "a2" }, { id: "b2" }],
182
+ next: null,
183
+ snapshot: 2,
184
+ sweptAt: SWEPT,
185
+ },
186
+ });
187
+ expect(pages.map((page) => new URL(`https://services.test${page.path}`).searchParams.get("next"))).toEqual([
188
+ null,
189
+ "old",
190
+ null,
191
+ "new",
192
+ ]);
193
+ });
194
+
195
+ it("uses the injected fetch under vitest, including when a persona is set", async () => {
196
+ const fetchImpl = vi.fn<typeof fetch>(async () =>
197
+ Response.json({ objects: [], next: null, sweptAt: null, snapshot: null }),
198
+ );
199
+ const result = await readDataset(
200
+ { ...env, DEV_PERSONA: "hq-staff", DEV_REGISTRY_ERROR: "not_granted" },
201
+ "directory/branches",
202
+ "offices",
203
+ { fetchImpl },
204
+ );
205
+ expect(result).toMatchObject({ ok: true, data: { objects: [], next: null, sweptAt: null } });
206
+ expect(fetchImpl).toHaveBeenCalledOnce();
207
+ expect(String(fetchImpl.mock.calls[0]?.[0])).toBe("https://services.test/directory/branches/offices");
208
+
209
+ await expect(readDataset(env, "directory/branches", "offices")).rejects.toThrow(/injected fetch/);
210
+ await expect(
211
+ readDataset({ DEV_PERSONA: "none" }, "directory/branches", "offices", { fetchImpl }),
212
+ ).rejects.toThrow(/PPLE_SERVICES_URL/);
213
+ });
214
+ });
@@ -0,0 +1,166 @@
1
+ // serviceCall (ADR-0024). The Identity header is base64url of the UTF-8 JSON,
2
+ // so a Thai name round-trips. encodeIdentityHeader is the one encoder the
3
+ // dev wrapper and callRegistry share; serviceCall undoes it.
4
+
5
+ import { describe, expect, it } from "vitest";
6
+ import type { Identity } from "../shared/api";
7
+ import { encodeIdentityHeader } from "../src/identity-header";
8
+ import worker from "../src/index";
9
+ import { serviceCall } from "../src/registry";
10
+
11
+ const IDENTITY: Identity = {
12
+ actor: {
13
+ sub: "u-delegate",
14
+ name: "ชื่อนกทดสอบ",
15
+ mobile: "0810000014",
16
+ kyc: {
17
+ status: "verified",
18
+ verified: true,
19
+ verifiedAt: "2024-04-01T00:00:00.000Z",
20
+ source: "membership",
21
+ },
22
+ membership: { memberSince: "2020-05-01" },
23
+ },
24
+ effective: {
25
+ isDelegated: true,
26
+ sub: "347513243977122153",
27
+ activeRole: "province",
28
+ eligibleRoles: ["province"],
29
+ provinces: ["เชียงใหม่"],
30
+ extraRoles: ["exco", "province_co"],
31
+ },
32
+ };
33
+
34
+ describe("serviceCall", () => {
35
+ it("round-trips the shared encoder, including a Thai name", () => {
36
+ const encoded = encodeIdentityHeader(IDENTITY);
37
+ expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/);
38
+ expect(encoded).not.toContain("ชื่อ");
39
+
40
+ const call = serviceCall(
41
+ new Request("https://provider.invalid/offices", {
42
+ headers: {
43
+ "x-pple-consumer": "brave-purple-lion",
44
+ "x-pple-entry": "calm-amber-fox/branch-list",
45
+ "x-pple-identity": encoded,
46
+ "x-pple-user-token": "raw-token-must-not-surface",
47
+ authorization: "Bearer raw-token-must-not-surface",
48
+ },
49
+ }),
50
+ );
51
+
52
+ expect(call).toEqual({
53
+ consumer: "brave-purple-lion",
54
+ entry: "calm-amber-fox/branch-list",
55
+ identity: IDENTITY,
56
+ });
57
+ expect(JSON.stringify(call)).not.toContain("raw-token-must-not-surface");
58
+ });
59
+
60
+ it("round-trips a null effective sub", () => {
61
+ const identity: Identity = {
62
+ ...IDENTITY,
63
+ effective: { ...IDENTITY.effective, sub: null },
64
+ };
65
+ const call = serviceCall(
66
+ new Request("https://provider.invalid/", {
67
+ headers: {
68
+ "x-pple-consumer": "brave-purple-lion",
69
+ "x-pple-entry": "calm-amber-fox/branch-list",
70
+ "x-pple-identity": encodeIdentityHeader(identity),
71
+ },
72
+ }),
73
+ );
74
+ expect(call?.identity).toEqual(identity);
75
+ });
76
+
77
+ it("treats an Identity header with no effective sub as no Identity", () => {
78
+ const effective = {
79
+ isDelegated: IDENTITY.effective.isDelegated,
80
+ activeRole: IDENTITY.effective.activeRole,
81
+ eligibleRoles: IDENTITY.effective.eligibleRoles,
82
+ provinces: IDENTITY.effective.provinces,
83
+ extraRoles: IDENTITY.effective.extraRoles,
84
+ };
85
+ const call = serviceCall(
86
+ new Request("https://provider.invalid/", {
87
+ headers: {
88
+ "x-pple-consumer": "brave-purple-lion",
89
+ "x-pple-entry": "calm-amber-fox/branch-list",
90
+ "x-pple-identity": encodeIdentityHeader({ actor: IDENTITY.actor, effective } as Identity),
91
+ },
92
+ }),
93
+ );
94
+ expect(call?.identity).toBeNull();
95
+ });
96
+
97
+ it("treats an Identity header with no membership as no Identity", () => {
98
+ const stale = {
99
+ actor: {
100
+ sub: IDENTITY.actor.sub,
101
+ name: IDENTITY.actor.name,
102
+ mobile: IDENTITY.actor.mobile,
103
+ kyc: IDENTITY.actor.kyc,
104
+ },
105
+ effective: IDENTITY.effective,
106
+ };
107
+ const call = serviceCall(
108
+ new Request("https://provider.invalid/", {
109
+ headers: {
110
+ "x-pple-consumer": "brave-purple-lion",
111
+ "x-pple-entry": "calm-amber-fox/branch-list",
112
+ "x-pple-identity": encodeIdentityHeader(stale as Identity),
113
+ },
114
+ }),
115
+ );
116
+ expect(call?.identity).toBeNull();
117
+ });
118
+
119
+ it("returns the call with no Identity when the entry declares userToken none", () => {
120
+ expect(
121
+ serviceCall(
122
+ new Request("https://provider.invalid/", {
123
+ headers: {
124
+ "x-pple-consumer": "brave-purple-lion",
125
+ "x-pple-entry": "calm-amber-fox/branch-list",
126
+ "x-pple-user-token": "still-not-a-fact",
127
+ },
128
+ }),
129
+ ),
130
+ ).toEqual({
131
+ consumer: "brave-purple-lion",
132
+ entry: "calm-amber-fox/branch-list",
133
+ identity: null,
134
+ });
135
+ });
136
+
137
+ it("returns null when the request is not a platform registry call", () => {
138
+ expect(serviceCall(new Request("https://app.example/api/notes"))).toBeNull();
139
+ expect(
140
+ serviceCall(new Request("https://app.example/api/notes", { headers: { "x-pple-consumer": "only-one" } })),
141
+ ).toBeNull();
142
+ });
143
+
144
+ it("treats an identity header that is not the encoding as no Identity", () => {
145
+ expect(
146
+ serviceCall(
147
+ new Request("https://provider.invalid/", {
148
+ headers: {
149
+ "x-pple-consumer": "brave-purple-lion",
150
+ "x-pple-entry": "calm-amber-fox/branch-list",
151
+ "x-pple-identity": "not-json",
152
+ },
153
+ }),
154
+ ),
155
+ ).toEqual({
156
+ consumer: "brave-purple-lion",
157
+ entry: "calm-amber-fox/branch-list",
158
+ identity: null,
159
+ });
160
+ });
161
+
162
+ it("puts a registry slot on the Worker default export", () => {
163
+ expect(worker.registry).toEqual({});
164
+ expect(typeof worker.fetch).toBe("function");
165
+ });
166
+ });
@@ -2,13 +2,14 @@ import { fileURLToPath } from "node:url";
2
2
  import { cloudflare } from "@cloudflare/vite-plugin";
3
3
  import react from "@vitejs/plugin-react";
4
4
  import { defineConfig } from "vite";
5
+ import { overlayMtimePlugin } from "./overlay-mtime.mjs";
5
6
 
6
7
  // The Cloudflare plugin reads wrangler.jsonc, runs the Worker in the real
7
8
  // Workers runtime during `vite dev`, and builds a deployable bundle with
8
9
  // `vite build`. `vite dev` serves the React app and the /api/* Worker from a
9
10
  // single origin, so no CORS or proxy config is needed.
10
11
  export default defineConfig({
11
- plugins: [react(), cloudflare()],
12
+ plugins: [overlayMtimePlugin(), react(), cloudflare()],
12
13
  resolve: {
13
14
  alias: [
14
15
  // @pplethai/components ships a "development" export condition pointing at
@@ -4,6 +4,7 @@ import {
4
4
  readD1Migrations,
5
5
  } from "@cloudflare/vitest-pool-workers";
6
6
  import { defineConfig } from "vitest/config";
7
+ import { overlayMtimePlugin } from "./overlay-mtime.mjs";
7
8
 
8
9
  // Runs the smoke tests inside the real Workers runtime (workerd) with local
9
10
  // D1 and R2, so the tests exercise the same code paths as production. The D1
@@ -12,6 +13,7 @@ export default defineConfig(async () => {
12
13
  const migrations = await readD1Migrations(path.join(__dirname, "migrations"));
13
14
  return {
14
15
  plugins: [
16
+ overlayMtimePlugin(),
15
17
  cloudflareTest({
16
18
  wrangler: { configPath: "./wrangler.jsonc" },
17
19
  miniflare: {