@pplethai/cli 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +117 -16
- package/dist/bin.js +32 -48
- package/package.json +2 -4
- package/templates/golden-path/AGENTS.md +1 -0
- package/templates/golden-path/CLAUDE.md +44 -24
- package/templates/golden-path/gitignore +5 -0
- package/templates/golden-path/shared/api.ts +17 -0
- package/templates/golden-path/src/auth.ts +27 -0
- package/templates/golden-path/src/dev-personas.ts +67 -3
- package/templates/golden-path/src/env.ts +6 -0
- package/templates/golden-path/src/legal-identity.ts +161 -0
- package/templates/golden-path/test/legal-identity.test.ts +256 -0
- package/templates/golden-path/wrangler.jsonc +14 -1
- package/skills/pple-platform/SKILL.md +0 -114
- package/skills/pple-platform/evals/evals.json +0 -29
- package/skills/pple-platform/references/deployment.md +0 -129
- package/skills/pple-platform/references/design-system.md +0 -55
- package/skills/pple-platform/references/mini-app.md +0 -111
- package/skills/pple-platform/references/pple-id-auth.md +0 -99
- package/skills/pple-platform/references/project-scaffold.md +0 -75
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// KYC Status on the actor, and getLegalIdentity (issue #216). Every refusal
|
|
2
|
+
// the helper can return is named here. The offline path never yields a
|
|
3
|
+
// citizen ID that would pass the Thai checksum.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { DEV_PERSONAS } from "../src/dev-personas";
|
|
7
|
+
import {
|
|
8
|
+
getLegalIdentity,
|
|
9
|
+
kycStartUrl,
|
|
10
|
+
OFFLINE_INVALID_ID,
|
|
11
|
+
OFFLINE_LEGAL_NAME,
|
|
12
|
+
thaiIdChecksumValid,
|
|
13
|
+
type LegalIdentityReason,
|
|
14
|
+
} from "../src/legal-identity";
|
|
15
|
+
import { makeHarness } from "./harness";
|
|
16
|
+
|
|
17
|
+
describe("actor.kyc", () => {
|
|
18
|
+
it("gives every Persona a KYC Status, and members are verified via membership", () => {
|
|
19
|
+
for (const persona of Object.values(DEV_PERSONAS)) {
|
|
20
|
+
expect(persona.actor.kyc).toEqual(
|
|
21
|
+
expect.objectContaining({
|
|
22
|
+
status: expect.any(String),
|
|
23
|
+
verified: expect.any(Boolean),
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
expect("kyc" in persona.effective).toBe(false);
|
|
27
|
+
}
|
|
28
|
+
expect(DEV_PERSONAS["hq-staff"]!.actor.kyc).toMatchObject({
|
|
29
|
+
status: "verified",
|
|
30
|
+
verified: true,
|
|
31
|
+
source: "membership",
|
|
32
|
+
});
|
|
33
|
+
expect(DEV_PERSONAS.unverified!.actor.kyc.status).toBe("none");
|
|
34
|
+
expect(DEV_PERSONAS["kyc-pending"]!.actor.kyc.status).toBe("pending_review");
|
|
35
|
+
expect(DEV_PERSONAS["kyc-verified-nonmember"]!.actor.kyc).toMatchObject({
|
|
36
|
+
status: "verified",
|
|
37
|
+
source: "kyc",
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("keeps the delegate's KYC Status, not the supervisor's", async () => {
|
|
42
|
+
const delegate = makeHarness({}, { DEV_PERSONA: "delegate" });
|
|
43
|
+
const supervisor = makeHarness({}, { DEV_PERSONA: "province-coordinator" });
|
|
44
|
+
const delegateMe = await (await delegate.request("/api/me")).json<{
|
|
45
|
+
actor: { kyc: { verifiedAt: string; source: string } };
|
|
46
|
+
effective: Record<string, unknown>;
|
|
47
|
+
}>();
|
|
48
|
+
const supervisorMe = await (await supervisor.request("/api/me")).json<{
|
|
49
|
+
actor: { kyc: { verifiedAt: string } };
|
|
50
|
+
}>();
|
|
51
|
+
expect(delegateMe.actor.kyc.verifiedAt).toBe("2024-04-01T00:00:00.000Z");
|
|
52
|
+
expect(delegateMe.actor.kyc.verifiedAt).not.toBe(supervisorMe.actor.kyc.verifiedAt);
|
|
53
|
+
expect(delegateMe.actor.kyc.source).toBe("membership");
|
|
54
|
+
expect(delegateMe.effective.kyc).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("parses userinfo kyc onto the actor only, including a delegated session", async () => {
|
|
58
|
+
const h = makeHarness(
|
|
59
|
+
{
|
|
60
|
+
sso: () =>
|
|
61
|
+
Response.json({
|
|
62
|
+
sub: "u-delegate",
|
|
63
|
+
name: "Nok",
|
|
64
|
+
kyc: {
|
|
65
|
+
status: "verified",
|
|
66
|
+
verified: true,
|
|
67
|
+
verifiedAt: "2024-06-01T00:00:00.000Z",
|
|
68
|
+
source: "kyc",
|
|
69
|
+
},
|
|
70
|
+
ad: {
|
|
71
|
+
user: { id: 9, role: "province", mobile: "0810000009" },
|
|
72
|
+
actualPpleUser: { id: 14, role: "delegate", mobile: "0810000014" },
|
|
73
|
+
activeRole: "province",
|
|
74
|
+
eligibleRoles: ["province"],
|
|
75
|
+
eligibleProvinces: ["เชียงใหม่"],
|
|
76
|
+
},
|
|
77
|
+
}),
|
|
78
|
+
},
|
|
79
|
+
{ DEV_PERSONA: "none" },
|
|
80
|
+
);
|
|
81
|
+
const me = await (await h.request("/api/me", {}, "valid-token")).json<{
|
|
82
|
+
actor: { kyc: { source: string; verifiedAt: string } };
|
|
83
|
+
effective: Record<string, unknown>;
|
|
84
|
+
}>();
|
|
85
|
+
expect(me.actor.kyc).toEqual({
|
|
86
|
+
status: "verified",
|
|
87
|
+
verified: true,
|
|
88
|
+
verifiedAt: "2024-06-01T00:00:00.000Z",
|
|
89
|
+
source: "kyc",
|
|
90
|
+
});
|
|
91
|
+
expect(me.effective.kyc).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("defaults a userinfo payload with no kyc to none", async () => {
|
|
95
|
+
const h = makeHarness(
|
|
96
|
+
{
|
|
97
|
+
sso: () =>
|
|
98
|
+
Response.json({
|
|
99
|
+
sub: "u-42",
|
|
100
|
+
name: "Somchai",
|
|
101
|
+
ad: {
|
|
102
|
+
user: { id: 7, role: "hq", mobile: "0810000007" },
|
|
103
|
+
actualPpleUser: { id: 7, role: "hq", mobile: "0810000007" },
|
|
104
|
+
activeRole: "hq",
|
|
105
|
+
eligibleRoles: ["hq"],
|
|
106
|
+
eligibleProvinces: ["all"],
|
|
107
|
+
},
|
|
108
|
+
}),
|
|
109
|
+
},
|
|
110
|
+
{ DEV_PERSONA: "none" },
|
|
111
|
+
);
|
|
112
|
+
const me = await (await h.request("/api/me", {}, "valid-token")).json<{
|
|
113
|
+
actor: { kyc: { status: string; verified: boolean; source: null } };
|
|
114
|
+
}>();
|
|
115
|
+
expect(me.actor.kyc).toEqual({
|
|
116
|
+
status: "none",
|
|
117
|
+
verified: false,
|
|
118
|
+
verifiedAt: null,
|
|
119
|
+
source: null,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("getLegalIdentity offline", () => {
|
|
125
|
+
it("never returns a checksum-valid citizen ID", async () => {
|
|
126
|
+
expect(thaiIdChecksumValid(OFFLINE_INVALID_ID)).toBe(false);
|
|
127
|
+
// A known-valid check digit for the same first 12 digits is 8.
|
|
128
|
+
expect(thaiIdChecksumValid("1100000000008")).toBe(true);
|
|
129
|
+
|
|
130
|
+
for (const name of Object.keys(DEV_PERSONAS)) {
|
|
131
|
+
const result = await getLegalIdentity({ DEV_PERSONA: name }, "ignored");
|
|
132
|
+
if (!result.ok) {
|
|
133
|
+
expect(result.reason).toBe("not_verified");
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
expect(result.legalIdentity.name).toBe(OFFLINE_LEGAL_NAME);
|
|
137
|
+
if (result.legalIdentity.idCardNumber !== null) {
|
|
138
|
+
expect(thaiIdChecksumValid(result.legalIdentity.idCardNumber)).toBe(false);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const member = await getLegalIdentity({ DEV_PERSONA: "hq-staff" }, "t");
|
|
143
|
+
expect(member).toEqual({
|
|
144
|
+
ok: true,
|
|
145
|
+
legalIdentity: { source: "membership", name: OFFLINE_LEGAL_NAME, idCardNumber: null },
|
|
146
|
+
});
|
|
147
|
+
const card = await getLegalIdentity({ DEV_PERSONA: "kyc-verified-nonmember" }, "t");
|
|
148
|
+
expect(card).toEqual({
|
|
149
|
+
ok: true,
|
|
150
|
+
legalIdentity: { source: "kyc", name: OFFLINE_LEGAL_NAME, idCardNumber: OFFLINE_INVALID_ID },
|
|
151
|
+
});
|
|
152
|
+
expect(await getLegalIdentity({ DEV_PERSONA: "unverified" }, "t")).toEqual({
|
|
153
|
+
ok: false,
|
|
154
|
+
reason: "not_verified",
|
|
155
|
+
});
|
|
156
|
+
expect(await getLegalIdentity({ DEV_PERSONA: "kyc-pending" }, "t")).toEqual({
|
|
157
|
+
ok: false,
|
|
158
|
+
reason: "not_verified",
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("getLegalIdentity against Platform Services", () => {
|
|
164
|
+
const env = {
|
|
165
|
+
DEV_PERSONA: "none",
|
|
166
|
+
PPLE_SERVICES_URL: "https://services.test",
|
|
167
|
+
PPLE_APP_KEY: "app-key",
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
it("is not_configured until both bindings exist, and does not call out", async () => {
|
|
171
|
+
const fetchImpl = vi.fn<typeof fetch>();
|
|
172
|
+
expect(await getLegalIdentity({ DEV_PERSONA: "none" }, "tok", fetchImpl)).toEqual({
|
|
173
|
+
ok: false,
|
|
174
|
+
reason: "not_configured",
|
|
175
|
+
});
|
|
176
|
+
expect(
|
|
177
|
+
await getLegalIdentity({ DEV_PERSONA: "none", PPLE_SERVICES_URL: "https://services.test" }, "tok", fetchImpl),
|
|
178
|
+
).toEqual({ ok: false, reason: "not_configured" });
|
|
179
|
+
expect(fetchImpl).not.toHaveBeenCalled();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("names every error the service can return", async () => {
|
|
183
|
+
const reasons: LegalIdentityReason[] = [
|
|
184
|
+
"invalid_app_key",
|
|
185
|
+
"not_granted",
|
|
186
|
+
"not_verified",
|
|
187
|
+
"user_token_invalid",
|
|
188
|
+
"identity_not_enabled",
|
|
189
|
+
"upstream_unavailable",
|
|
190
|
+
];
|
|
191
|
+
for (const reason of reasons) {
|
|
192
|
+
const status = reason === "invalid_app_key" || reason === "user_token_invalid" ? 401 : 403;
|
|
193
|
+
const fetchImpl = vi.fn<typeof fetch>(async () => Response.json({ error: reason }, { status }));
|
|
194
|
+
expect(await getLegalIdentity(env, "user-token", fetchImpl)).toEqual({ ok: false, reason });
|
|
195
|
+
const [url, init] = fetchImpl.mock.calls[0]!;
|
|
196
|
+
expect(url).toBe("https://services.test/legal-identity");
|
|
197
|
+
expect(new Headers(init?.headers).get("authorization")).toBe("Bearer app-key");
|
|
198
|
+
expect(JSON.parse(String(init?.body))).toEqual({ token: "user-token" });
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("returns the Legal Identity the service answered with", async () => {
|
|
203
|
+
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
|
204
|
+
Response.json({ source: "kyc", name: "นาย ก", idCardNumber: "1100000000008" }),
|
|
205
|
+
);
|
|
206
|
+
expect(await getLegalIdentity(env, "user-token", fetchImpl)).toEqual({
|
|
207
|
+
ok: true,
|
|
208
|
+
legalIdentity: { source: "kyc", name: "นาย ก", idCardNumber: "1100000000008" },
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("treats a transport failure and a body-less error as upstream_unavailable", async () => {
|
|
213
|
+
const down = vi.fn<typeof fetch>(async () => {
|
|
214
|
+
throw new Error("network");
|
|
215
|
+
});
|
|
216
|
+
expect(await getLegalIdentity(env, "user-token", down)).toEqual({
|
|
217
|
+
ok: false,
|
|
218
|
+
reason: "upstream_unavailable",
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const odd = vi.fn<typeof fetch>(async () => new Response("nope", { status: 502 }));
|
|
222
|
+
expect(await getLegalIdentity(env, "user-token", odd)).toEqual({
|
|
223
|
+
ok: false,
|
|
224
|
+
reason: "upstream_unavailable",
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("local KYC redirect", () => {
|
|
230
|
+
it("sends a local click straight back with ?kyc=cancelled", () => {
|
|
231
|
+
const back = kycStartUrl({
|
|
232
|
+
ssoBaseUrl: "https://id.example",
|
|
233
|
+
clientId: "zitadel-app",
|
|
234
|
+
returnTo: "https://app.example/checkout?step=1#pay",
|
|
235
|
+
dev: true,
|
|
236
|
+
});
|
|
237
|
+
const url = new URL(back);
|
|
238
|
+
expect(url.origin + url.pathname).toBe("https://app.example/checkout");
|
|
239
|
+
expect(url.searchParams.get("step")).toBe("1");
|
|
240
|
+
expect(url.searchParams.get("kyc")).toBe("cancelled");
|
|
241
|
+
expect(url.hash).toBe("#pay");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("points production at PPLE ID /kyc", () => {
|
|
245
|
+
expect(
|
|
246
|
+
kycStartUrl({
|
|
247
|
+
ssoBaseUrl: "https://id.example",
|
|
248
|
+
clientId: "zitadel-app",
|
|
249
|
+
returnTo: "https://app.example/checkout",
|
|
250
|
+
dev: false,
|
|
251
|
+
}),
|
|
252
|
+
).toBe(
|
|
253
|
+
"https://id.example/kyc?client_id=zitadel-app&return_to=https%3A%2F%2Fapp.example%2Fcheckout",
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
|
|
21
21
|
// The React SPA (built to dist/client by Vite) is served for any request
|
|
22
22
|
// that isn't worker-first. "/api/*" is the app's API; "/.pple/*" is reserved
|
|
23
|
-
// for the platform deploy probe
|
|
23
|
+
// for the platform (the deploy probe and scheduled jobs) — don't add routes
|
|
24
|
+
// under it. "single-page-application" makes every
|
|
24
25
|
// other path return index.html so client-side routing works.
|
|
25
26
|
"assets": {
|
|
26
27
|
"not_found_handling": "single-page-application",
|
|
@@ -46,6 +47,18 @@
|
|
|
46
47
|
}
|
|
47
48
|
],
|
|
48
49
|
|
|
50
|
+
// Scheduled jobs (Cron Schedules, "งานตั้งเวลา"). Declare when the app runs
|
|
51
|
+
// its own code, in UTC (Bangkok is UTC+7: 08:00 in Bangkok is "0 1 * * *"),
|
|
52
|
+
// and export `scheduled()` beside `fetch` in src/index.ts. The platform fires
|
|
53
|
+
// them, not Cloudflare, so only standard five-field cron works (lists,
|
|
54
|
+
// ranges, steps, JAN-DEC months, and weekdays as NAMES only — MON-FRI, never
|
|
55
|
+
// digits, which count differently from Cloudflare's docs), at most 3 schedules per app and
|
|
56
|
+
// no closer than 5 minutes. Delivery is at-most-once: no catch-up and no
|
|
57
|
+
// retry, so write jobs that are safe to repeat. Deploying is the only way to
|
|
58
|
+
// change this list; `pple cron` shows, pauses and runs them. Uncomment to use:
|
|
59
|
+
//
|
|
60
|
+
// "triggers": { "crons": ["0 1 * * *"] },
|
|
61
|
+
|
|
49
62
|
"vars": {
|
|
50
63
|
// PPLE ID (identity). The Worker validates bearer tokens against this.
|
|
51
64
|
"SSO_BASE_URL": "https://id.peoplesparty.or.th",
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: pple-platform
|
|
3
|
-
description: >
|
|
4
|
-
Build Builder Apps on the PPLE Platform — the People's Party (พรรคประชาชน)
|
|
5
|
-
digital infrastructure: PPLE ID single sign-on, PPLE Today mini-apps and
|
|
6
|
-
audience-bound push notifications, the @pplethai/components design system,
|
|
7
|
-
and managed Cloudflare Workers + D1 + R2 hosting via the pple CLI. Use this
|
|
8
|
-
skill whenever a task mentions PPLE, PPLE ID, PPLE Today, mini-app,
|
|
9
|
-
พรรคประชาชน, People's Party, @pplethai packages, Builder App, party
|
|
10
|
-
internal tools, or when scaffolding, authenticating, styling, notifying, or
|
|
11
|
-
deploying any app in the PPLE ecosystem — even if the user doesn't name the
|
|
12
|
-
platform explicitly.
|
|
13
|
-
---
|
|
14
|
-
|
|
15
|
-
# Building on the PPLE Platform
|
|
16
|
-
|
|
17
|
-
The PPLE Platform lets party workforce members (Builders) ship mini-apps
|
|
18
|
-
without holding cloud or GitHub credentials. Every new **Builder App** plugs
|
|
19
|
-
into three pillars on party-managed infrastructure:
|
|
20
|
-
|
|
21
|
-
1. **PPLE ID** — OIDC single sign-on. End users authenticate via the mini-app
|
|
22
|
-
SDK (launch token inside **PPLE Today**, or the SDK's PPLE ID browser
|
|
23
|
-
login); the Worker validates the bearer token against the OIDC `userinfo`
|
|
24
|
-
endpoint. Builders also use PPLE ID for the Console (browser login) and
|
|
25
|
-
`pple auth login` (device flow).
|
|
26
|
-
2. **Mini-app integration** — apps run inside PPLE Today's WebView using
|
|
27
|
-
`@pplethai/pple-today-miniapp-sdk` (and the same SDK in a plain browser),
|
|
28
|
-
and send audience-bound notifications — a **Broadcast** to everyone this
|
|
29
|
-
app can reach, or a **Direct** send to people it names — through the
|
|
30
|
-
platform-provisioned notify helper.
|
|
31
|
-
3. **PPLE Design System** — `@pplethai/components`, a shadcn/ui-based React
|
|
32
|
-
library with party branding. Docs: https://components.pplethai.org/
|
|
33
|
-
|
|
34
|
-
## Standard tech stack (Builder Apps)
|
|
35
|
-
|
|
36
|
-
Use this stack for every new Builder App unless the user is working on a
|
|
37
|
-
central-team core service — uniformity is deliberate (ADR-0002):
|
|
38
|
-
|
|
39
|
-
| Layer | Choice |
|
|
40
|
-
| ------------ | ------ |
|
|
41
|
-
| Runtime | One Cloudflare Worker serves `/api/*` and the React SPA |
|
|
42
|
-
| Frontend | Vite + React 19 + TypeScript + `@pplethai/components` |
|
|
43
|
-
| Backend | Hono + TypeScript on the Workers runtime |
|
|
44
|
-
| Database | D1 (SQLite) via bound queries + SQL migrations |
|
|
45
|
-
| Files | R2 |
|
|
46
|
-
| Validation | Zod schemas in `shared/` |
|
|
47
|
-
| Auth | PPLE ID (OIDC `userinfo`); local `DEV_AUTH` bypass only |
|
|
48
|
-
| Notifications| `src/notify.ts` — every send states its audience (`all` or `direct`) |
|
|
49
|
-
| Deploy | `pple deploy` (platform build → tenant Worker); never personal cloud |
|
|
50
|
-
|
|
51
|
-
**Not the Builder App path** (do not resurrect these for new apps): Express +
|
|
52
|
-
Prisma + PostgreSQL, Docker / Cloud Build / Artifact Registry, or a separate
|
|
53
|
-
Cloudflare Pages project for the SPA. Those belong to older reference apps
|
|
54
|
-
and central-team services, not the Workers golden path.
|
|
55
|
-
|
|
56
|
-
Source of truth for new apps:
|
|
57
|
-
|
|
58
|
-
- **`pple-platform/template`** — the golden-path seed `pple init` copies.
|
|
59
|
-
Prefer this over obsolete examples (`pple-erp`, `mini-app-example`).
|
|
60
|
-
|
|
61
|
-
## Which reference to read
|
|
62
|
-
|
|
63
|
-
Read only the file(s) relevant to the task at hand:
|
|
64
|
-
|
|
65
|
-
| Task | Read |
|
|
66
|
-
| ---- | ---- |
|
|
67
|
-
| Start a new app / local layout & commands | [references/project-scaffold.md](references/project-scaffold.md) |
|
|
68
|
-
| Login, tokens, browser PPLE ID flow, DEV_AUTH | [references/pple-id-auth.md](references/pple-id-auth.md) |
|
|
69
|
-
| Mini-app SDK behavior, push notifications | [references/mini-app.md](references/mini-app.md) |
|
|
70
|
-
| UI, styling, components | [references/design-system.md](references/design-system.md) |
|
|
71
|
-
| Create → init → deploy; status / usage (`pple info`) | [references/deployment.md](references/deployment.md) |
|
|
72
|
-
|
|
73
|
-
## Golden path for a new app
|
|
74
|
-
|
|
75
|
-
1. **Sign in on the machine**: `pple auth login` (PPLE ID device flow; no
|
|
76
|
-
personal cloud credentials — ADR-0005).
|
|
77
|
-
2. **Create + scaffold** — pick one path (both use the same Provisioner
|
|
78
|
-
create; the App Slug is always platform-generated):
|
|
79
|
-
- **CLI create**: `pple init` (prompts for App Name) or
|
|
80
|
-
`pple init --name "<App Name>"` (non-interactive) — creates the app,
|
|
81
|
-
waits until it is ready, then scaffolds and writes the App Link.
|
|
82
|
-
- **Console create**: create in the Console (`console.ppleth.ai`), then
|
|
83
|
-
`pple init <slug>` to scaffold and link the folder.
|
|
84
|
-
3. **Build** against the template helpers (`auth`, `db`, `files`, `notify`)
|
|
85
|
-
and `@pplethai/components` (see the reference files).
|
|
86
|
-
4. **Ship**: `pple deploy` — the platform builds and puts the app live.
|
|
87
|
-
5. **Status / usage**: `pple info` (from a linked folder, or
|
|
88
|
-
`pple info <slug>`) — read-only overview, usage vs Resource Limits,
|
|
89
|
-
recent deploys, env key list. Invites, Requests, and collaborators stay
|
|
90
|
-
Console-only (see [deployment.md](references/deployment.md)).
|
|
91
|
-
|
|
92
|
-
## Invariants — things that are true no matter what you build
|
|
93
|
-
|
|
94
|
-
- **Mini-apps authenticate through the mini-app SDK.** Inside PPLE Today the
|
|
95
|
-
SDK reads the launch-URL token; in a plain browser it runs the PPLE ID
|
|
96
|
-
OIDC login. Do not add a NotInApp guard or a custom browser sign-in page.
|
|
97
|
-
Local development uses `DEV_AUTH` in `.dev.vars` only — never provisioned
|
|
98
|
-
in production. (Builders separately hold tokens from Console login and
|
|
99
|
-
`pple auth login`.)
|
|
100
|
-
- **Never mention git, branches, remotes, GitHub, Cloudflare accounts, or
|
|
101
|
-
personal deploy credentials** in the happy path. Talk about the app's
|
|
102
|
-
code and `pple init` / `pple deploy` only.
|
|
103
|
-
- **Notifications are audience-bound.** Every send states its audience:
|
|
104
|
-
Broadcast `{ kind: "all" }` or Direct `{ kind: "direct", recipients }`
|
|
105
|
-
(by `sub` or `phone`). Naming narrows the reachable set and never widens
|
|
106
|
-
it; `not_reachable` is one collapsed status — do not decompose it.
|
|
107
|
-
Self-links only (`linkPath` into this app). Shape in `shared/api.ts`;
|
|
108
|
-
details in [mini-app.md](references/mini-app.md).
|
|
109
|
-
- **Stack is Workers + D1 + R2.** Bind D1 parameters; never concatenate SQL.
|
|
110
|
-
One Worker, same origin — no CORS between SPA and API.
|
|
111
|
-
- **`DEV_AUTH` must be unset in production.** Its absence is what forces real
|
|
112
|
-
PPLE ID validation.
|
|
113
|
-
- **Design tokens are CSS variables** from `@pplethai/components/styles.css`.
|
|
114
|
-
Prefer design-system components over hardcoding brand colors.
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"skill_name": "pple-platform",
|
|
3
|
-
"evals": [
|
|
4
|
-
{
|
|
5
|
-
"id": 1,
|
|
6
|
-
"prompt": "I created an app called brave-otter in the Console. Help me get it running locally and understand the project layout so I can add features with my AI agent.",
|
|
7
|
-
"expected_output": "pple auth login → pple init brave-otter; single-package Workers template (src/ Hono Worker, web/ React, shared/ Zod, migrations/ D1); npm install, .dev.vars from example, db:migrate:local, npm run dev; DEV_AUTH local bypass; no Express/Prisma/Pages scaffold",
|
|
8
|
-
"files": []
|
|
9
|
-
},
|
|
10
|
-
{
|
|
11
|
-
"id": 2,
|
|
12
|
-
"prompt": "When a reimbursement is approved, send a push notification to that one person. We already have their PPLE ID sub. Use the PPLE notification service the platform wired up.",
|
|
13
|
-
"expected_output": "Use src/notify.ts sendNotification with required audience { kind: \"direct\", recipients: [{ sub }] } (or { phone }); title+body; optional path-only linkPath self-link into this app; naming narrows the reachable set and never widens it; not_reachable is one collapsed status — do not decompose it; never omit audience; never reimplement the wire POST; never free-form / cross-app destinations; never legacy /external/notifications/send with PHONE_NUMBER; Broadcast to everyone this app can reach is { kind: \"all\" }",
|
|
14
|
-
"files": []
|
|
15
|
-
},
|
|
16
|
-
{
|
|
17
|
-
"id": 3,
|
|
18
|
-
"prompt": "How do I put my changes live? Set up deployment for this Builder App.",
|
|
19
|
-
"expected_output": "pple deploy (after pple auth login); managed Workers+D1+R2; no Dockerfile, Cloud Build, Artifact Registry, or separate Cloudflare Pages project; no git/GitHub credential instructions in the happy path",
|
|
20
|
-
"files": []
|
|
21
|
-
},
|
|
22
|
-
{
|
|
23
|
-
"id": 4,
|
|
24
|
-
"prompt": "The app should work when I open it in Chrome on my laptop. Add a normal browser login with PPLE ID.",
|
|
25
|
-
"expected_output": "Use the mini-app SDK's existing browser PPLE ID flow (web/pple.ts bootstrap → sdk.init); do not add a custom OAuth login page or a NotInApp / open-in-Today-only guard; local DEV_AUTH only for vite dev — do not claim tokens only exist inside PPLE Today as a platform-wide absolute (Console/CLI hold Builder tokens; end users can also sign in via the SDK browser flow)",
|
|
26
|
-
"files": []
|
|
27
|
-
}
|
|
28
|
-
]
|
|
29
|
-
}
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
# Deployment (managed Workers hosting)
|
|
2
|
-
|
|
3
|
-
Builder Apps run only on party-managed Cloudflare infrastructure
|
|
4
|
-
(ADR-0001, ADR-0002). Builders never hold cloud or GitHub credentials
|
|
5
|
-
(ADR-0005). The happy path uses the Console and the `pple` CLI — not git
|
|
6
|
-
vocabulary, Docker, Cloud Build, or personal Cloudflare/Pages projects.
|
|
7
|
-
|
|
8
|
-
Ops detail for create, status, and what stays Console-only lives here
|
|
9
|
-
(ADR-0006: the Skill owns deeper platform guidance; `CLAUDE.md` stays
|
|
10
|
-
short).
|
|
11
|
-
|
|
12
|
-
## Happy path
|
|
13
|
-
|
|
14
|
-
```bash
|
|
15
|
-
pple auth login # PPLE ID device flow on this machine
|
|
16
|
-
|
|
17
|
-
# Create + scaffold — either path works:
|
|
18
|
-
pple init # prompt for App Name → create → wait → scaffold
|
|
19
|
-
# or: pple init --name "My App" # non-interactive create (agents/scripts)
|
|
20
|
-
# or: create in Console (console.ppleth.ai), then:
|
|
21
|
-
# pple init <slug> # scaffold an app that already exists
|
|
22
|
-
|
|
23
|
-
# …edit locally with npm run dev…
|
|
24
|
-
pple deploy -m "short note" # send changes; platform builds and goes live
|
|
25
|
-
pple info # read-only status, usage vs limits, deploys
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
What the Builder hears: the app's name and live URL (e.g.
|
|
29
|
-
`https://brave-otter.ppleth.ai`). Not branches, remotes, registries, or
|
|
30
|
-
source-control jargon.
|
|
31
|
-
|
|
32
|
-
| Command | Meaning for the Builder |
|
|
33
|
-
| ------- | ----------------------- |
|
|
34
|
-
| `pple auth login` | Sign in with PPLE ID |
|
|
35
|
-
| `pple init` / `pple init --name "…"` | Create a new app, wait until ready, scaffold + App Link |
|
|
36
|
-
| `pple init <slug>` | Set up an existing app's code on this computer |
|
|
37
|
-
| `pple deploy` | Send changes and put them live |
|
|
38
|
-
| `pple info` / `pple info <slug>` | Read-only status, usage vs Resource Limits, deploys |
|
|
39
|
-
| `pple env list` / `set` / `delete` | Manage env vars and secrets (effect on next deploy) |
|
|
40
|
-
| `pple whoami` / `pple auth logout` | Check / forget this machine's sign-in |
|
|
41
|
-
|
|
42
|
-
## Creating an app (`pple init`)
|
|
43
|
-
|
|
44
|
-
Same Provisioner create as the Console (`POST /apps`). The **App Slug** is
|
|
45
|
-
always platform-generated — Builders choose only the **App Name**.
|
|
46
|
-
|
|
47
|
-
| Invocation | Behavior |
|
|
48
|
-
| ---------- | -------- |
|
|
49
|
-
| `pple init` (TTY, no App Link in cwd) | Prompt for App Name → create → poll until ready → scaffold |
|
|
50
|
-
| `pple init --name "<App Name>"` | Non-interactive create → poll → scaffold (agents/scripts) |
|
|
51
|
-
| `pple init` non-TTY without `--name` or slug | Error clearly — do not hang waiting on stdin |
|
|
52
|
-
| `pple init <slug>` | Scaffold an app that already exists (e.g. Console-created) |
|
|
53
|
-
| `pple init` inside a folder with an App Link | **Refuse** — already linked; use `pple info` / `pple deploy`, or `cd` elsewhere to create another app |
|
|
54
|
-
|
|
55
|
-
After create, the CLI **blocks/polls** until the app is ready to scaffold
|
|
56
|
-
(repo present, not stuck in `provisioning`). On timeout it fails loudly and
|
|
57
|
-
prints the App Slug so the Builder can retry with `pple init <slug>`. If
|
|
58
|
-
scaffold fails after a successful create, the app still exists on the
|
|
59
|
-
platform (same as Console-then-init).
|
|
60
|
-
|
|
61
|
-
## Status and usage (`pple info`)
|
|
62
|
-
|
|
63
|
-
Read-only terminal mirror of the Console app dashboard for one Builder App.
|
|
64
|
-
**No mutations.**
|
|
65
|
-
|
|
66
|
-
- Resolve the app from the **App Link** (`.pple/app.json`) when run inside
|
|
67
|
-
an app folder, **or** from `pple info <slug>`.
|
|
68
|
-
- No App Link and no slug → clear error: `cd` into the app, pass a slug, or
|
|
69
|
-
use the Console.
|
|
70
|
-
- Prints safe dashboard surfaces:
|
|
71
|
-
- Overview (App Name, App Slug, URL, status, Publication Tier, role, …)
|
|
72
|
-
- Usage vs Resource Limits — treat `null` usage fields as unavailable,
|
|
73
|
-
not zero (same semantics as Console)
|
|
74
|
-
- Recent deploys
|
|
75
|
-
- Env key list — secrets: key present only, never values
|
|
76
|
-
- Owner-only surfaces (publication / collaborators / request history as
|
|
77
|
-
the Console shows): include when the caller is Owner; **omit** for
|
|
78
|
-
Collaborators (same gates as Console — do not fail the whole command)
|
|
79
|
-
|
|
80
|
-
## Console vs CLI
|
|
81
|
-
|
|
82
|
-
| Need | Where |
|
|
83
|
-
| ---- | ----- |
|
|
84
|
-
| Create app | Console **or** `pple init` / `pple init --name` |
|
|
85
|
-
| Scaffold / link locally | `pple init <slug>` (or create path above) |
|
|
86
|
-
| Deploy | `pple deploy` |
|
|
87
|
-
| Read status / usage / deploys / env keys | `pple info` (or Console) |
|
|
88
|
-
| Env vars & secrets | Console **or** `pple env` |
|
|
89
|
-
| Beta invites, Publication Tier / PublishRequest | **Console only** |
|
|
90
|
-
| File ScopeRequest / LimitRequest / ChangeRequest / RetireRequest | **Console only** |
|
|
91
|
-
| Manage Collaborators | **Console only** |
|
|
92
|
-
| Dataset catalog / App Scope browsing | **Console only** |
|
|
93
|
-
| Platform Admin approval queue | **Console only** |
|
|
94
|
-
|
|
95
|
-
Point Builders at the Console (`console.ppleth.ai`) for anything in the
|
|
96
|
-
Console-only rows — do not invent CLI flags for those.
|
|
97
|
-
|
|
98
|
-
## What the platform owns
|
|
99
|
-
|
|
100
|
-
- **Hosting**: Workers for Platforms dispatch namespace; one tenant Worker per
|
|
101
|
-
app serving API + static assets; D1 database; R2 bucket.
|
|
102
|
-
- **Identity bindings**: `SSO_BASE_URL`, OAuth client env for the SPA,
|
|
103
|
-
audience-bound `NOTIFY_API_BASE` / `NOTIFY_API_KEY`.
|
|
104
|
-
- **The app's code**: stored and shipped by the platform. `pple init` /
|
|
105
|
-
`pple deploy` authenticate under the hood — the Builder never configures
|
|
106
|
-
a source-control account or pastes a deploy token.
|
|
107
|
-
- **Config / secrets**: set in the Console or via `pple env`; applied as
|
|
108
|
-
Worker bindings on the **next** deploy. Secrets are write-only (set/unset,
|
|
109
|
-
never read back).
|
|
110
|
-
|
|
111
|
-
## Local vs production auth
|
|
112
|
-
|
|
113
|
-
- Local: `DEV_AUTH` in `.dev.vars` (see [pple-id-auth.md](pple-id-auth.md)).
|
|
114
|
-
- Production: `DEV_AUTH` must be absent. The platform never provisions it.
|
|
115
|
-
|
|
116
|
-
## Archived / do-not-use for Builder Apps
|
|
117
|
-
|
|
118
|
-
These were the previous golden path. Do not set them up for new Builder Apps:
|
|
119
|
-
|
|
120
|
-
- Separate **Cloudflare Pages** project for a Vite SPA + Dockerized Express API
|
|
121
|
-
- **Google Cloud Build** / Kaniko / Artifact Registry Docker deploys
|
|
122
|
-
- Asking the Builder to sign into GitHub, create a Cloudflare account, or
|
|
123
|
-
paste long-lived deploy tokens
|
|
124
|
-
- `api/Dockerfile` + `api/cloudbuild.yaml` monorepo layouts copied from
|
|
125
|
-
older Express-era apps
|
|
126
|
-
|
|
127
|
-
If the user is maintaining a central-team service that still uses those
|
|
128
|
-
tools, say so explicitly — that is outside the Builder App path this skill
|
|
129
|
-
steers.
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
# PPLE Design System (@pplethai/components)
|
|
2
|
-
|
|
3
|
-
`@pplethai/components` is the party's React component library, built on
|
|
4
|
-
shadcn/ui. Live docs: **https://components.pplethai.org/**
|
|
5
|
-
|
|
6
|
-
Builder Apps start with the golden-path template (`pple-platform/template`),
|
|
7
|
-
which already imports the library. Prefer editing that wiring over
|
|
8
|
-
reinventing a design-system setup from `pple-erp` or other older apps.
|
|
9
|
-
|
|
10
|
-
## Consumer setup (already in the template)
|
|
11
|
-
|
|
12
|
-
```tsx
|
|
13
|
-
// web/main.tsx — design-system CSS first, then local layout
|
|
14
|
-
import "@pplethai/components/styles.css";
|
|
15
|
-
import "./app.css";
|
|
16
|
-
|
|
17
|
-
import { Button, Card, CardContent, Navbar } from "@pplethai/components";
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
Package entry points:
|
|
21
|
-
|
|
22
|
-
| Import | Contents |
|
|
23
|
-
| ------ | -------- |
|
|
24
|
-
| `@pplethai/components` | UI primitives, layout, brand (`Navbar`, `Logo`, …), `cn()`, toasts |
|
|
25
|
-
| `@pplethai/components/form` | react-hook-form helpers when you need forms |
|
|
26
|
-
| `@pplethai/components/styles.css` | Brand tokens — import once |
|
|
27
|
-
|
|
28
|
-
Vite note (template already pins this): the package's `"development"` export
|
|
29
|
-
can point at unpublished source. Keep the template's resolve alias that pins
|
|
30
|
-
the bare `@pplethai/components` import to `dist/index.js`.
|
|
31
|
-
|
|
32
|
-
## Layout conventions in Builder Apps
|
|
33
|
-
|
|
34
|
-
- Put **page layout** in `web/app.css` (mobile-first, safe-area padding for
|
|
35
|
-
the WebView). Do not fight the design-system stylesheet with a second
|
|
36
|
-
full Tailwind stack unless you need it.
|
|
37
|
-
- Top-level chrome uses Shared `<Navbar>` (see `web/App.tsx`). Do not hand-roll
|
|
38
|
-
a header/app bar with `Logo`. With a client router, pass `renderHomeLink` /
|
|
39
|
-
`renderLink` (as in pple-erp); otherwise default `<a href>` links are fine.
|
|
40
|
-
Navbar is `position: fixed` below the `md` breakpoint — pad the page content
|
|
41
|
-
accordingly (the template's `.screen` already does).
|
|
42
|
-
- Compose screens from design-system components (`Card`, `Button`, `Input`,
|
|
43
|
-
`Textarea`, `Badge`, `Alert`, …) like the demo cards in `web/App.tsx`.
|
|
44
|
-
- **Colors and type** come from CSS variables in `styles.css`
|
|
45
|
-
(`--primary`, fonts, gradients). Prefer components and those tokens over
|
|
46
|
-
hardcoded hex brand colors.
|
|
47
|
-
- Typography: headings **Anakotmai**, body **IBM Plex Sans Thai Looped** —
|
|
48
|
-
both arrive with `styles.css`.
|
|
49
|
-
|
|
50
|
-
## Filling gaps
|
|
51
|
-
|
|
52
|
-
When the library lacks a component, generate a local shadcn/ui piece into
|
|
53
|
-
your app and keep tokens aligned — or stick to plain markup + `app.css`.
|
|
54
|
-
Prefer `@pplethai/components` whenever an equivalent exists so apps stay
|
|
55
|
-
visually consistent.
|