@pplethai/cli 0.1.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/README.md +161 -0
- package/askpass/pple-askpass.mjs +18 -0
- package/dist/api.js +62 -0
- package/dist/bin.js +16 -0
- package/dist/cli.js +103 -0
- package/dist/commands/auth.js +32 -0
- package/dist/commands/deploy.js +106 -0
- package/dist/commands/init.js +77 -0
- package/dist/commands/upgrade.js +29 -0
- package/dist/credentials.js +53 -0
- package/dist/deps.js +87 -0
- package/dist/device-flow.js +115 -0
- package/dist/errors.js +16 -0
- package/dist/git.js +127 -0
- package/dist/link.js +49 -0
- package/dist/node-deps.js +84 -0
- package/dist/npm.js +127 -0
- package/dist/scaffold.js +58 -0
- package/dist/session.js +29 -0
- package/dist/skills.js +86 -0
- package/dist/ui.js +12 -0
- package/dist/update.js +142 -0
- package/package.json +35 -0
- package/skills/pple-platform/SKILL.md +102 -0
- package/skills/pple-platform/evals/evals.json +29 -0
- package/skills/pple-platform/references/deployment.md +61 -0
- package/skills/pple-platform/references/design-system.md +50 -0
- package/skills/pple-platform/references/mini-app.md +74 -0
- package/skills/pple-platform/references/pple-id-auth.md +85 -0
- package/skills/pple-platform/references/project-scaffold.md +75 -0
- package/templates/golden-path/.dev.vars.example +17 -0
- package/templates/golden-path/.env.example +8 -0
- package/templates/golden-path/.github/workflows/ci.yml +22 -0
- package/templates/golden-path/CLAUDE.md +60 -0
- package/templates/golden-path/README.md +60 -0
- package/templates/golden-path/gitignore +6 -0
- package/templates/golden-path/index.html +16 -0
- package/templates/golden-path/migrations/0001_init.sql +9 -0
- package/templates/golden-path/package-lock.json +4283 -0
- package/templates/golden-path/package.json +38 -0
- package/templates/golden-path/shared/api.ts +56 -0
- package/templates/golden-path/src/auth.ts +53 -0
- package/templates/golden-path/src/db.ts +32 -0
- package/templates/golden-path/src/env.ts +19 -0
- package/templates/golden-path/src/files.ts +38 -0
- package/templates/golden-path/src/index.ts +104 -0
- package/templates/golden-path/src/notify.ts +64 -0
- package/templates/golden-path/test/apply-migrations.ts +4 -0
- package/templates/golden-path/test/env.d.ts +17 -0
- package/templates/golden-path/test/harness.ts +57 -0
- package/templates/golden-path/test/smoke.test.ts +155 -0
- package/templates/golden-path/tsconfig.json +20 -0
- package/templates/golden-path/tsconfig.worker.json +21 -0
- package/templates/golden-path/vite.config.ts +29 -0
- package/templates/golden-path/vitest.config.ts +26 -0
- package/templates/golden-path/web/App.tsx +250 -0
- package/templates/golden-path/web/NotInApp.tsx +23 -0
- package/templates/golden-path/web/app.css +112 -0
- package/templates/golden-path/web/main.tsx +40 -0
- package/templates/golden-path/web/pple.ts +73 -0
- package/templates/golden-path/web/vite-env.d.ts +10 -0
- package/templates/golden-path/wrangler.jsonc +53 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pple-app",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"//": "Golden-path template for a PPLE Platform Builder App. Rename this to your app slug. Everything runs on Cloudflare: a Hono Worker (API), D1 (database), R2 (files); the UI is a React SPA built with the People's Party design system.",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite dev",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"preview": "vite preview",
|
|
10
|
+
"typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.worker.json",
|
|
11
|
+
"test": "vitest run",
|
|
12
|
+
"test:watch": "vitest",
|
|
13
|
+
"db:migrate:local": "wrangler d1 migrations apply DB --local",
|
|
14
|
+
"db:migrate:remote": "wrangler d1 migrations apply DB --remote",
|
|
15
|
+
"deploy": "vite build && wrangler deploy"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@pplethai/components": "^1.4.1",
|
|
19
|
+
"@pplethai/pple-today-miniapp-sdk": "^0.1.4",
|
|
20
|
+
"hono": "^4.12.30",
|
|
21
|
+
"react": "^19.2.0",
|
|
22
|
+
"react-dom": "^19.2.0",
|
|
23
|
+
"react-hook-form": "^7.81.0",
|
|
24
|
+
"zod": "^4.4.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@cloudflare/vite-plugin": "^1.45.1",
|
|
28
|
+
"@cloudflare/vitest-pool-workers": "^0.18.6",
|
|
29
|
+
"@cloudflare/workers-types": "^5.20260714.1",
|
|
30
|
+
"@types/react": "^19.2.0",
|
|
31
|
+
"@types/react-dom": "^19.2.0",
|
|
32
|
+
"@vitejs/plugin-react": "^6.0.3",
|
|
33
|
+
"typescript": "^5.8.0",
|
|
34
|
+
"vite": "^8.1.5",
|
|
35
|
+
"vitest": "^4.1.10",
|
|
36
|
+
"wrangler": "^4.112.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The API contract shared by the Worker (src/) and the React app (web/).
|
|
2
|
+
// Defining request/response shapes once, as Zod schemas, means the server
|
|
3
|
+
// validates exactly what the client promises to send — and both sides share
|
|
4
|
+
// the inferred TypeScript types.
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
/** The signed-in user, as the Worker resolves it from the PPLE ID token. */
|
|
8
|
+
export const MeSchema = z.object({
|
|
9
|
+
sub: z.string(),
|
|
10
|
+
name: z.string().nullable(),
|
|
11
|
+
});
|
|
12
|
+
export type Me = z.infer<typeof MeSchema>;
|
|
13
|
+
|
|
14
|
+
/** A row in the example `notes` table (the D1 read/write demo). */
|
|
15
|
+
export const NoteSchema = z.object({
|
|
16
|
+
id: z.number(),
|
|
17
|
+
body: z.string(),
|
|
18
|
+
createdAt: z.string(),
|
|
19
|
+
});
|
|
20
|
+
export type Note = z.infer<typeof NoteSchema>;
|
|
21
|
+
|
|
22
|
+
/** Body for creating a note. */
|
|
23
|
+
export const CreateNoteSchema = z.object({
|
|
24
|
+
body: z.string().trim().min(1).max(500),
|
|
25
|
+
});
|
|
26
|
+
export type CreateNote = z.infer<typeof CreateNoteSchema>;
|
|
27
|
+
|
|
28
|
+
/** An uploaded file's metadata (the R2 demo). */
|
|
29
|
+
export const UploadedFileSchema = z.object({
|
|
30
|
+
key: z.string(),
|
|
31
|
+
name: z.string(),
|
|
32
|
+
size: z.number(),
|
|
33
|
+
uploadedAt: z.string(),
|
|
34
|
+
});
|
|
35
|
+
export type UploadedFile = z.infer<typeof UploadedFileSchema>;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A notification to send to this app's App Users. Content only — never phone
|
|
39
|
+
* numbers or user ids. The platform resolves the audience server-side (see
|
|
40
|
+
* src/notify.ts and docs/integration/today-sso-changes.md §6).
|
|
41
|
+
*
|
|
42
|
+
* Optional `linkPath` is a path-only deep link into *this* app (must start
|
|
43
|
+
* with `/`). The platform joins it to this app's known entry and rejects
|
|
44
|
+
* absolute URLs / cross-app destinations. Free-form destinations that could
|
|
45
|
+
* address another mini app are not accepted.
|
|
46
|
+
*/
|
|
47
|
+
export const NotificationSchema = z.object({
|
|
48
|
+
title: z.string().trim().min(1).max(120),
|
|
49
|
+
body: z.string().trim().min(1).max(500),
|
|
50
|
+
linkPath: z
|
|
51
|
+
.string()
|
|
52
|
+
.trim()
|
|
53
|
+
.regex(/^\/(?!\/).*/, "linkPath must be a path starting with a single /")
|
|
54
|
+
.optional(),
|
|
55
|
+
});
|
|
56
|
+
export type Notification = z.infer<typeof NotificationSchema>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// PPLE ID authentication.
|
|
2
|
+
//
|
|
3
|
+
// PPLE Today launches your mini-app with an access token in the URL; the
|
|
4
|
+
// frontend SDK captures it and sends it as a bearer token on every API call.
|
|
5
|
+
// The Worker validates that token server-side against the PPLE ID userinfo
|
|
6
|
+
// endpoint — the same seam the platform's own services use. There is no local
|
|
7
|
+
// session or cookie: each request carries the token.
|
|
8
|
+
|
|
9
|
+
export interface Identity {
|
|
10
|
+
sub: string;
|
|
11
|
+
name: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type AuthResult =
|
|
15
|
+
| { ok: true; identity: Identity }
|
|
16
|
+
| { ok: false; status: 401 | 502 };
|
|
17
|
+
|
|
18
|
+
interface AuthEnv {
|
|
19
|
+
SSO_BASE_URL: string;
|
|
20
|
+
/** Local-only bypass token; unset in production. See .dev.vars.example. */
|
|
21
|
+
DEV_AUTH?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the caller's identity from a bearer token. Distinguishes an
|
|
26
|
+
* invalid token (401) from PPLE ID being unreachable (502) so the client can
|
|
27
|
+
* tell "sign in again" apart from "try again later".
|
|
28
|
+
*/
|
|
29
|
+
export async function authenticate(
|
|
30
|
+
env: AuthEnv,
|
|
31
|
+
token: string,
|
|
32
|
+
fetchImpl: typeof fetch = globalThis.fetch.bind(globalThis),
|
|
33
|
+
): Promise<AuthResult> {
|
|
34
|
+
// Local development bypass: accept a fake user when the request carries the
|
|
35
|
+
// configured DEV_AUTH token. Never active in production, because the
|
|
36
|
+
// platform never provisions DEV_AUTH.
|
|
37
|
+
if (env.DEV_AUTH && token === env.DEV_AUTH) {
|
|
38
|
+
return { ok: true, identity: { sub: "dev-user", name: "Local Dev" } };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const res = await fetchImpl(`${env.SSO_BASE_URL}/oidc/v1/userinfo`, {
|
|
42
|
+
headers: { authorization: `Bearer ${token}` },
|
|
43
|
+
});
|
|
44
|
+
if (res.status === 401 || res.status === 403) return { ok: false, status: 401 };
|
|
45
|
+
if (!res.ok) return { ok: false, status: 502 };
|
|
46
|
+
|
|
47
|
+
const body = (await res.json().catch(() => ({}))) as {
|
|
48
|
+
sub?: string;
|
|
49
|
+
name?: string;
|
|
50
|
+
};
|
|
51
|
+
if (!body.sub) return { ok: false, status: 502 };
|
|
52
|
+
return { ok: true, identity: { sub: body.sub, name: body.name ?? null } };
|
|
53
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// D1 (SQLite) read/write example. D1 is your app's private database; the
|
|
2
|
+
// schema lives in ./migrations. Keep queries here so routes stay thin and the
|
|
3
|
+
// SQL is easy to find.
|
|
4
|
+
import type { Note } from "../shared/api";
|
|
5
|
+
|
|
6
|
+
interface NoteRow {
|
|
7
|
+
id: number;
|
|
8
|
+
body: string;
|
|
9
|
+
created_at: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function toNote(row: NoteRow): Note {
|
|
13
|
+
return { id: row.id, body: row.body, createdAt: row.created_at };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function listNotes(db: D1Database): Promise<Note[]> {
|
|
17
|
+
const { results } = await db
|
|
18
|
+
.prepare("SELECT id, body, created_at FROM notes ORDER BY id DESC LIMIT 50")
|
|
19
|
+
.all<NoteRow>();
|
|
20
|
+
return results.map(toNote);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function addNote(db: D1Database, body: string): Promise<Note> {
|
|
24
|
+
// Always bind values (never string-concatenate) — D1 uses prepared
|
|
25
|
+
// statements, which keeps user input from being interpreted as SQL.
|
|
26
|
+
const row = await db
|
|
27
|
+
.prepare("INSERT INTO notes (body) VALUES (?) RETURNING id, body, created_at")
|
|
28
|
+
.bind(body)
|
|
29
|
+
.first<NoteRow>();
|
|
30
|
+
if (!row) throw new Error("insert returned no row");
|
|
31
|
+
return toNote(row);
|
|
32
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Everything the Worker needs from its environment. The platform injects the
|
|
2
|
+
// real bindings and secrets at deploy time; local defaults live in
|
|
3
|
+
// wrangler.jsonc and .dev.vars.
|
|
4
|
+
export interface Env {
|
|
5
|
+
// Your app's private database and file storage.
|
|
6
|
+
DB: D1Database;
|
|
7
|
+
FILES: R2Bucket;
|
|
8
|
+
|
|
9
|
+
// PPLE ID — the Worker validates bearer tokens against this base URL.
|
|
10
|
+
SSO_BASE_URL: string;
|
|
11
|
+
|
|
12
|
+
// Audience-bound notifications (see src/notify.ts). Base is a var; the key
|
|
13
|
+
// is a platform-provisioned secret. Both may be empty locally.
|
|
14
|
+
NOTIFY_API_BASE: string;
|
|
15
|
+
NOTIFY_API_KEY?: string;
|
|
16
|
+
|
|
17
|
+
// Local development auth bypass. Never set in production.
|
|
18
|
+
DEV_AUTH?: string;
|
|
19
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// R2 (object storage) upload example. R2 is your app's private bucket for
|
|
2
|
+
// files — images, PDFs, exports. Store the human-facing filename in custom
|
|
3
|
+
// metadata so listings can show it; the object key itself is a random id.
|
|
4
|
+
import type { UploadedFile } from "../shared/api";
|
|
5
|
+
|
|
6
|
+
function toUploaded(
|
|
7
|
+
key: string,
|
|
8
|
+
name: string,
|
|
9
|
+
size: number,
|
|
10
|
+
uploaded: Date,
|
|
11
|
+
): UploadedFile {
|
|
12
|
+
return { key, name, size, uploadedAt: uploaded.toISOString() };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function putFile(
|
|
16
|
+
bucket: R2Bucket,
|
|
17
|
+
name: string,
|
|
18
|
+
body: ReadableStream | ArrayBuffer | string,
|
|
19
|
+
contentType?: string,
|
|
20
|
+
): Promise<UploadedFile> {
|
|
21
|
+
const key = crypto.randomUUID();
|
|
22
|
+
const obj = await bucket.put(key, body, {
|
|
23
|
+
httpMetadata: contentType ? { contentType } : undefined,
|
|
24
|
+
customMetadata: { name },
|
|
25
|
+
});
|
|
26
|
+
return toUploaded(obj.key, name, obj.size, obj.uploaded);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function listFiles(bucket: R2Bucket): Promise<UploadedFile[]> {
|
|
30
|
+
const listed = await bucket.list({ limit: 50, include: ["customMetadata"] });
|
|
31
|
+
return listed.objects.map((o) =>
|
|
32
|
+
toUploaded(o.key, o.customMetadata?.name ?? o.key, o.size, o.uploaded),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getFile(bucket: R2Bucket, key: string): Promise<R2ObjectBody | null> {
|
|
37
|
+
return bucket.get(key);
|
|
38
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The Worker: your app's API. Hono routes everything under /api/*; the React
|
|
2
|
+
// SPA is served for every other path (see wrangler.jsonc). Keep route
|
|
3
|
+
// handlers thin — the real work lives in the helpers (auth, db, files, notify)
|
|
4
|
+
// so each concern is easy to find and test.
|
|
5
|
+
import { Hono } from "hono";
|
|
6
|
+
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
7
|
+
import { CreateNoteSchema, NotificationSchema } from "../shared/api";
|
|
8
|
+
import { authenticate, type Identity } from "./auth";
|
|
9
|
+
import { addNote, listNotes } from "./db";
|
|
10
|
+
import type { Env } from "./env";
|
|
11
|
+
import { getFile, listFiles, putFile } from "./files";
|
|
12
|
+
import { sendNotification } from "./notify";
|
|
13
|
+
|
|
14
|
+
/** Test seam: real fetch by default, an injected fake in tests. */
|
|
15
|
+
export interface AppOverrides {
|
|
16
|
+
fetchImpl?: typeof fetch;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type AppContext = {
|
|
20
|
+
Bindings: Env;
|
|
21
|
+
Variables: { identity: Identity };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function createApp(overrides: AppOverrides = {}) {
|
|
25
|
+
const fetchImpl = overrides.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
26
|
+
|
|
27
|
+
const app = new Hono<AppContext>().basePath("/api");
|
|
28
|
+
|
|
29
|
+
// Liveness check — no auth, so uptime monitors and CI smoke tests can hit it.
|
|
30
|
+
app.get("/health", (c) => c.json({ ok: true }));
|
|
31
|
+
|
|
32
|
+
// Everything below requires a valid PPLE ID token.
|
|
33
|
+
app.use("*", async (c, next) => {
|
|
34
|
+
const token = (c.req.header("authorization") ?? "")
|
|
35
|
+
.replace(/^Bearer\s+/i, "")
|
|
36
|
+
.trim();
|
|
37
|
+
if (!token) return c.json({ error: "not_authenticated" }, 401);
|
|
38
|
+
const result = await authenticate(c.env, token, fetchImpl);
|
|
39
|
+
if (!result.ok) {
|
|
40
|
+
return c.json(
|
|
41
|
+
{ error: result.status === 401 ? "not_authenticated" : "sso_unavailable" },
|
|
42
|
+
result.status,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
c.set("identity", result.identity);
|
|
46
|
+
await next();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
app.get("/me", (c) => {
|
|
50
|
+
const { sub, name } = c.get("identity");
|
|
51
|
+
return c.json({ sub, name });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// --- D1 demo: a shared notes list ---
|
|
55
|
+
app.get("/notes", async (c) => c.json({ notes: await listNotes(c.env.DB) }));
|
|
56
|
+
|
|
57
|
+
app.post("/notes", async (c) => {
|
|
58
|
+
const parsed = CreateNoteSchema.safeParse(
|
|
59
|
+
await c.req.json().catch(() => null),
|
|
60
|
+
);
|
|
61
|
+
if (!parsed.success) return c.json({ error: "invalid_body" }, 400);
|
|
62
|
+
return c.json(await addNote(c.env.DB, parsed.data.body), 201);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// --- R2 demo: file upload/list/download ---
|
|
66
|
+
app.get("/files", async (c) => c.json({ files: await listFiles(c.env.FILES) }));
|
|
67
|
+
|
|
68
|
+
app.post("/files", async (c) => {
|
|
69
|
+
const form = await c.req.formData().catch(() => null);
|
|
70
|
+
const file = form?.get("file");
|
|
71
|
+
if (!(file instanceof File)) return c.json({ error: "no_file" }, 400);
|
|
72
|
+
const uploaded = await putFile(
|
|
73
|
+
c.env.FILES,
|
|
74
|
+
file.name,
|
|
75
|
+
file.stream(),
|
|
76
|
+
file.type,
|
|
77
|
+
);
|
|
78
|
+
return c.json(uploaded, 201);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
app.get("/files/:key", async (c) => {
|
|
82
|
+
const obj = await getFile(c.env.FILES, c.req.param("key"));
|
|
83
|
+
if (!obj) return c.json({ error: "not_found" }, 404);
|
|
84
|
+
const headers = new Headers();
|
|
85
|
+
obj.writeHttpMetadata(headers);
|
|
86
|
+
headers.set("etag", obj.httpEtag);
|
|
87
|
+
return new Response(obj.body, { headers });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// --- Notification demo: audience-bound send (content only) ---
|
|
91
|
+
app.post("/notify", async (c) => {
|
|
92
|
+
const parsed = NotificationSchema.safeParse(
|
|
93
|
+
await c.req.json().catch(() => null),
|
|
94
|
+
);
|
|
95
|
+
if (!parsed.success) return c.json({ error: "invalid_body" }, 400);
|
|
96
|
+
const result = await sendNotification(c.env, parsed.data, fetchImpl);
|
|
97
|
+
if (result.ok) return c.json({ ok: true });
|
|
98
|
+
return c.json({ error: result.error }, result.status as ContentfulStatusCode);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return app;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export default createApp();
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Audience-bound push notifications.
|
|
2
|
+
//
|
|
3
|
+
// Your app sends *content* — a title and body, never phone numbers or user
|
|
4
|
+
// ids — plus an optional path-only self-link. The platform resolves the
|
|
5
|
+
// recipients server-side (this app's App Users ∩ its current publication-tier
|
|
6
|
+
// audience), joins `linkPath` to this app's known entry, and enforces the
|
|
7
|
+
// per-app rate limit. This is the whole privacy model: an app can reach the
|
|
8
|
+
// people who use it, and no one else; and it can deep-link into itself, not
|
|
9
|
+
// into another team's mini app.
|
|
10
|
+
//
|
|
11
|
+
// See docs/integration/today-sso-changes.md §6 for the contract.
|
|
12
|
+
//
|
|
13
|
+
// Wiring: the platform provisions NOTIFY_API_BASE and this app's own
|
|
14
|
+
// NotificationApiKey as NOTIFY_API_KEY, so `POST {NOTIFY_API_BASE}/notifications`
|
|
15
|
+
// lands on PPLE Today's audience-bound send route. The key is the whole
|
|
16
|
+
// address — PPLE Today resolves it to this app and derives the audience from
|
|
17
|
+
// that, so the request carries neither an app id nor any recipient.
|
|
18
|
+
//
|
|
19
|
+
// The wire shape below is PPLE Today's, not this app's: your code passes the
|
|
20
|
+
// friendlier `{ title, body, linkPath? }` and this is where it becomes
|
|
21
|
+
// `{ content: { header, message }, linkPath? }`. Keeping that translation in
|
|
22
|
+
// one place means the rest of the app never has to know the platform's
|
|
23
|
+
// vocabulary.
|
|
24
|
+
import type { Notification } from "../shared/api";
|
|
25
|
+
|
|
26
|
+
interface NotifyEnv {
|
|
27
|
+
NOTIFY_API_BASE: string;
|
|
28
|
+
NOTIFY_API_KEY?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type NotifyResult =
|
|
32
|
+
| { ok: true }
|
|
33
|
+
| { ok: false; status: number; error: string };
|
|
34
|
+
|
|
35
|
+
export async function sendNotification(
|
|
36
|
+
env: NotifyEnv,
|
|
37
|
+
input: Notification,
|
|
38
|
+
fetchImpl: typeof fetch = globalThis.fetch.bind(globalThis),
|
|
39
|
+
): Promise<NotifyResult> {
|
|
40
|
+
if (!env.NOTIFY_API_BASE || !env.NOTIFY_API_KEY) {
|
|
41
|
+
// Not wired up (e.g. local dev without a key). Fail loud, not silent.
|
|
42
|
+
return { ok: false, status: 503, error: "notifications_not_configured" };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const res = await fetchImpl(`${env.NOTIFY_API_BASE}/notifications`, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: {
|
|
48
|
+
authorization: `Bearer ${env.NOTIFY_API_KEY}`,
|
|
49
|
+
"content-type": "application/json",
|
|
50
|
+
},
|
|
51
|
+
// Content (+ optional self-link). The audience is resolved by the platform.
|
|
52
|
+
body: JSON.stringify({
|
|
53
|
+
content: { header: input.title, message: input.body },
|
|
54
|
+
...(input.linkPath !== undefined ? { linkPath: input.linkPath } : {}),
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (res.ok) return { ok: true };
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
status: res.status,
|
|
62
|
+
error: res.status === 429 ? "rate_limited" : "send_failed",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Env } from "../src/env";
|
|
2
|
+
|
|
3
|
+
// The test runtime's `env` (from "cloudflare:test") is typed as Cloudflare.Env.
|
|
4
|
+
// Extend it with our app's bindings/vars plus the migrations the setup file
|
|
5
|
+
// applies, so tests get full type-checking of env.
|
|
6
|
+
declare global {
|
|
7
|
+
namespace Cloudflare {
|
|
8
|
+
interface Env extends /* app bindings + vars */ AppEnvForTests {
|
|
9
|
+
TEST_MIGRATIONS: D1Migration[];
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Alias so the `extends` clause reads clearly above.
|
|
15
|
+
type AppEnvForTests = Env;
|
|
16
|
+
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { env } from "cloudflare:test";
|
|
2
|
+
import type { Env } from "../src/env";
|
|
3
|
+
import { createApp } from "../src/index";
|
|
4
|
+
|
|
5
|
+
export const DEV_TOKEN = "dev-token";
|
|
6
|
+
|
|
7
|
+
export interface Fakes {
|
|
8
|
+
/** Handles GET {SSO_BASE_URL}/oidc/v1/userinfo */
|
|
9
|
+
sso?: (req: Request) => Response | Promise<Response>;
|
|
10
|
+
/** Handles POST {NOTIFY_API_BASE}/notifications */
|
|
11
|
+
notify?: (req: Request) => Response | Promise<Response>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A copy of the Worker wired to fake PPLE ID / notification services (via an
|
|
16
|
+
* injected fetch) and the real local D1 + R2 from the test runtime. Records
|
|
17
|
+
* the requests sent to the notification endpoint so tests can assert the
|
|
18
|
+
* content-only contract.
|
|
19
|
+
*/
|
|
20
|
+
export function makeHarness(fakes: Fakes = {}, envOverrides: Partial<Env> = {}) {
|
|
21
|
+
const calls = { notify: [] as Request[] };
|
|
22
|
+
|
|
23
|
+
const fetchImpl: typeof fetch = async (input, init) => {
|
|
24
|
+
const req = new Request(input as RequestInfo, init);
|
|
25
|
+
const url = new URL(req.url);
|
|
26
|
+
if (url.pathname.endsWith("/oidc/v1/userinfo")) {
|
|
27
|
+
return fakes.sso ? fakes.sso(req) : new Response(null, { status: 502 });
|
|
28
|
+
}
|
|
29
|
+
if (url.pathname.endsWith("/notifications")) {
|
|
30
|
+
calls.notify.push(req.clone());
|
|
31
|
+
return fakes.notify ? fakes.notify(req) : new Response(null, { status: 502 });
|
|
32
|
+
}
|
|
33
|
+
return new Response(`unexpected fetch: ${req.url}`, { status: 599 });
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const app = createApp({ fetchImpl });
|
|
37
|
+
|
|
38
|
+
const testEnv: Env = {
|
|
39
|
+
...env,
|
|
40
|
+
SSO_BASE_URL: "https://sso.test",
|
|
41
|
+
NOTIFY_API_BASE: "",
|
|
42
|
+
DEV_AUTH: DEV_TOKEN,
|
|
43
|
+
...envOverrides,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const request = (
|
|
47
|
+
path: string,
|
|
48
|
+
init: RequestInit = {},
|
|
49
|
+
token: string | null = DEV_TOKEN,
|
|
50
|
+
) => {
|
|
51
|
+
const headers = new Headers(init.headers);
|
|
52
|
+
if (token !== null) headers.set("authorization", `Bearer ${token}`);
|
|
53
|
+
return app.request(path, { ...init, headers }, testEnv);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return { app, request, testEnv, calls };
|
|
57
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { Note, UploadedFile } from "../shared/api";
|
|
3
|
+
import { makeHarness } from "./harness";
|
|
4
|
+
|
|
5
|
+
describe("health", () => {
|
|
6
|
+
it("is public and reports ok", async () => {
|
|
7
|
+
const h = makeHarness();
|
|
8
|
+
const res = await h.request("/api/health", {}, null);
|
|
9
|
+
expect(res.status).toBe(200);
|
|
10
|
+
expect(await res.json()).toEqual({ ok: true });
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("authentication", () => {
|
|
15
|
+
it("rejects requests without a token", async () => {
|
|
16
|
+
const h = makeHarness();
|
|
17
|
+
const res = await h.request("/api/me", {}, null);
|
|
18
|
+
expect(res.status).toBe(401);
|
|
19
|
+
expect(await res.json()).toMatchObject({ error: "not_authenticated" });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("accepts the dev-bypass token locally", async () => {
|
|
23
|
+
const h = makeHarness();
|
|
24
|
+
const res = await h.request("/api/me");
|
|
25
|
+
expect(res.status).toBe(200);
|
|
26
|
+
expect(await res.json()).toMatchObject({ sub: "dev-user" });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("validates real tokens against PPLE ID userinfo", async () => {
|
|
30
|
+
// No dev bypass here; an unknown token must be checked with PPLE ID.
|
|
31
|
+
const h = makeHarness(
|
|
32
|
+
{ sso: () => Response.json({ error: "invalid_token" }, { status: 401 }) },
|
|
33
|
+
{ DEV_AUTH: undefined },
|
|
34
|
+
);
|
|
35
|
+
const res = await h.request("/api/me", {}, "some-real-looking-token");
|
|
36
|
+
expect(res.status).toBe(401);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("resolves identity from a valid PPLE ID token", async () => {
|
|
40
|
+
const h = makeHarness(
|
|
41
|
+
{ sso: () => Response.json({ sub: "u-42", name: "Somchai" }) },
|
|
42
|
+
{ DEV_AUTH: undefined },
|
|
43
|
+
);
|
|
44
|
+
const res = await h.request("/api/me", {}, "valid-token");
|
|
45
|
+
expect(await res.json()).toMatchObject({ sub: "u-42", name: "Somchai" });
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("D1 read/write (notes)", () => {
|
|
50
|
+
it("stores a note and reads it back", async () => {
|
|
51
|
+
const h = makeHarness();
|
|
52
|
+
|
|
53
|
+
const created = await h.request("/api/notes", {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { "content-type": "application/json" },
|
|
56
|
+
body: JSON.stringify({ body: "hello d1" }),
|
|
57
|
+
});
|
|
58
|
+
expect(created.status).toBe(201);
|
|
59
|
+
expect(await created.json<Note>()).toMatchObject({ body: "hello d1" });
|
|
60
|
+
|
|
61
|
+
const listed = await h.request("/api/notes");
|
|
62
|
+
const { notes } = await listed.json<{ notes: Note[] }>();
|
|
63
|
+
expect(notes.some((n) => n.body === "hello d1")).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("rejects an empty note", async () => {
|
|
67
|
+
const h = makeHarness();
|
|
68
|
+
const res = await h.request("/api/notes", {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: { "content-type": "application/json" },
|
|
71
|
+
body: JSON.stringify({ body: " " }),
|
|
72
|
+
});
|
|
73
|
+
expect(res.status).toBe(400);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("R2 upload (files)", () => {
|
|
78
|
+
it("uploads a file and lists it", async () => {
|
|
79
|
+
const h = makeHarness();
|
|
80
|
+
|
|
81
|
+
const form = new FormData();
|
|
82
|
+
form.append("file", new File(["file contents"], "photo.txt", { type: "text/plain" }));
|
|
83
|
+
const uploaded = await h.request("/api/files", { method: "POST", body: form });
|
|
84
|
+
expect(uploaded.status).toBe(201);
|
|
85
|
+
const meta = await uploaded.json<UploadedFile>();
|
|
86
|
+
expect(meta).toMatchObject({ name: "photo.txt", size: 13 });
|
|
87
|
+
|
|
88
|
+
const listed = await h.request("/api/files");
|
|
89
|
+
const { files } = await listed.json<{ files: UploadedFile[] }>();
|
|
90
|
+
expect(files.some((f) => f.name === "photo.txt")).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("audience-bound notifications", () => {
|
|
95
|
+
it("reports not-configured when no key is set", async () => {
|
|
96
|
+
const h = makeHarness();
|
|
97
|
+
const res = await h.request("/api/notify", {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: { "content-type": "application/json" },
|
|
100
|
+
body: JSON.stringify({ title: "Hi", body: "there" }),
|
|
101
|
+
});
|
|
102
|
+
expect(res.status).toBe(503);
|
|
103
|
+
expect(await res.json()).toMatchObject({ error: "notifications_not_configured" });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("sends content only (never phone numbers) to the platform endpoint", async () => {
|
|
107
|
+
const h = makeHarness(
|
|
108
|
+
{ notify: () => new Response(null, { status: 202 }) },
|
|
109
|
+
// Shaped like what the platform actually binds, so the test exercises
|
|
110
|
+
// the real `${base}/notifications` the Worker will call in production.
|
|
111
|
+
{ NOTIFY_API_BASE: "https://today.test/external", NOTIFY_API_KEY: "app-key" },
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const res = await h.request("/api/notify", {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: { "content-type": "application/json" },
|
|
117
|
+
body: JSON.stringify({ title: "Event tonight", body: "Doors at 7" }),
|
|
118
|
+
});
|
|
119
|
+
expect(res.status).toBe(200);
|
|
120
|
+
|
|
121
|
+
expect(h.calls.notify).toHaveLength(1);
|
|
122
|
+
const sent = h.calls.notify[0]!;
|
|
123
|
+
expect(sent.headers.get("authorization")).toBe("Bearer app-key");
|
|
124
|
+
// PPLE Today's wire shape, which src/notify.ts translates into.
|
|
125
|
+
const payload = await sent.json<Record<string, unknown>>();
|
|
126
|
+
expect(payload).toEqual({
|
|
127
|
+
content: { header: "Event tonight", message: "Doors at 7" },
|
|
128
|
+
});
|
|
129
|
+
expect(JSON.stringify(payload)).not.toMatch(/phone|recipient|number/i);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("forwards optional linkPath on the platform wire shape", async () => {
|
|
133
|
+
const h = makeHarness(
|
|
134
|
+
{ notify: () => new Response(null, { status: 202 }) },
|
|
135
|
+
{ NOTIFY_API_BASE: "https://today.test/external", NOTIFY_API_KEY: "app-key" },
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const res = await h.request("/api/notify", {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: { "content-type": "application/json" },
|
|
141
|
+
body: JSON.stringify({
|
|
142
|
+
title: "Check-in open",
|
|
143
|
+
body: "Tap to open the form",
|
|
144
|
+
linkPath: "/check-in",
|
|
145
|
+
}),
|
|
146
|
+
});
|
|
147
|
+
expect(res.status).toBe(200);
|
|
148
|
+
|
|
149
|
+
const payload = await h.calls.notify[0]!.json<Record<string, unknown>>();
|
|
150
|
+
expect(payload).toEqual({
|
|
151
|
+
content: { header: "Check-in open", message: "Tap to open the form" },
|
|
152
|
+
linkPath: "/check-in",
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// TypeScript config for the React SPA (web/) and shared code. The Worker has
|
|
2
|
+
// its own config (tsconfig.worker.json) because it runs in the Workers
|
|
3
|
+
// runtime, not the browser. `npm run typecheck` checks both.
|
|
4
|
+
{
|
|
5
|
+
"compilerOptions": {
|
|
6
|
+
"target": "ES2022",
|
|
7
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
8
|
+
"module": "ESNext",
|
|
9
|
+
"moduleResolution": "bundler",
|
|
10
|
+
"types": [],
|
|
11
|
+
"jsx": "react-jsx",
|
|
12
|
+
"strict": true,
|
|
13
|
+
"noUncheckedIndexedAccess": true,
|
|
14
|
+
"noEmit": true,
|
|
15
|
+
"skipLibCheck": true,
|
|
16
|
+
"isolatedModules": true,
|
|
17
|
+
"forceConsistentCasingInFileNames": true
|
|
18
|
+
},
|
|
19
|
+
"include": ["web", "shared"]
|
|
20
|
+
}
|