@tribe-nest/forge 2.1.0 → 3.2.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/package.json +1 -1
- package/src/client/createForgeClient.ts +85 -2
- package/src/client/tokenStorage.ts +31 -0
- package/src/contexts/AppAuthContext.tsx +100 -15
- package/src/contexts/PublicAuthContext.tsx +46 -9
- package/src/data/queries/useCourseAccess.ts +1 -1
- package/src/index.ts +10 -2
- package/src/provider/ForgeProvider.tsx +30 -1
- package/src/server/_tests/platformEvents.spec.ts +315 -0
- package/src/server/index.ts +17 -0
- package/src/server/jobs.ts +128 -10
- package/src/server/platform.ts +234 -9
- package/src/server/platformEvents.generated.ts +422 -0
- package/src/types/models.ts +44 -3
- package/src/ui/headless/auth/useSignupForm.ts +69 -4
- package/src/ui/headless/work/useWorkPortal.ts +24 -21
- package/src/ui/styled/SignupForm.tsx +86 -35
- package/src/ui/styled/work/WorkInviteAccept.tsx +54 -5
- package/src/utils/_tests/safeRedirect.spec.ts +117 -0
- package/src/utils/safeRedirect.ts +41 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// The app side of platform event delivery: signature verification, the response
|
|
2
|
+
// contract, and the derived idempotency key.
|
|
3
|
+
//
|
|
4
|
+
// The platform's half is tested against a real database in
|
|
5
|
+
// apps/backend/src/services/admin/app/_tests/appEvents.spec.ts. What can only be
|
|
6
|
+
// tested here is what an APP does with a delivery — and every one of these is a
|
|
7
|
+
// quiet failure if it is wrong: a verifier that accepts anything is an open
|
|
8
|
+
// endpoint, a route that answers 200 too early loses the event for good, and a
|
|
9
|
+
// key that differs between attempts reintroduces the duplicate write the whole
|
|
10
|
+
// design exists to prevent.
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
13
|
+
import { handlePlatformEvent, verifyPlatformEvent, SIGNATURE_TOLERANCE_SECONDS } from "../platform";
|
|
14
|
+
|
|
15
|
+
const SECRET = "test-signing-secret";
|
|
16
|
+
const API = "https://api.test";
|
|
17
|
+
|
|
18
|
+
async function hmacHex(secret: string, body: string): Promise<string> {
|
|
19
|
+
const enc = new TextEncoder();
|
|
20
|
+
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
21
|
+
"sign",
|
|
22
|
+
]);
|
|
23
|
+
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(body));
|
|
24
|
+
return Array.from(new Uint8Array(sig))
|
|
25
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
26
|
+
.join("");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const eventBody = (over: Record<string, unknown> = {}) =>
|
|
30
|
+
JSON.stringify({
|
|
31
|
+
event: "order.paid",
|
|
32
|
+
eventId: "evt-1",
|
|
33
|
+
appId: "app-1",
|
|
34
|
+
profileId: "prof-1",
|
|
35
|
+
occurredAt: new Date().toISOString(),
|
|
36
|
+
data: { orderId: "o-1", total: 10 },
|
|
37
|
+
...over,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
async function signedRequest(
|
|
41
|
+
body: string,
|
|
42
|
+
opts: { scheme?: "v1" | "v2" | "none"; timestamp?: number; secret?: string } = {},
|
|
43
|
+
): Promise<Request> {
|
|
44
|
+
const secret = opts.secret ?? SECRET;
|
|
45
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
46
|
+
const scheme = opts.scheme ?? "v2";
|
|
47
|
+
if (scheme === "v1") {
|
|
48
|
+
headers["x-tribenest-signature"] = await hmacHex(secret, body);
|
|
49
|
+
} else if (scheme === "v2") {
|
|
50
|
+
const t = opts.timestamp ?? Math.floor(Date.now() / 1000);
|
|
51
|
+
headers["x-tribenest-signature-v2"] = `t=${t},v1=${await hmacHex(secret, `${t}.${body}`)}`;
|
|
52
|
+
}
|
|
53
|
+
return new Request("https://app.test/api/tn-events", { method: "POST", headers, body });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
afterEach(() => vi.restoreAllMocks());
|
|
57
|
+
|
|
58
|
+
describe("verifyPlatformEvent", () => {
|
|
59
|
+
it("accepts a correctly timestamped signature", async () => {
|
|
60
|
+
const body = eventBody();
|
|
61
|
+
const event = await verifyPlatformEvent(await signedRequest(body), SECRET);
|
|
62
|
+
expect(event.eventId).toBe("evt-1");
|
|
63
|
+
expect(event.event).toBe("order.paid");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Deployed apps bundle their own Forge; they must keep receiving events while
|
|
67
|
+
// the fleet moves onto the timestamped scheme.
|
|
68
|
+
it("still accepts the body-only signature deployed apps were built against", async () => {
|
|
69
|
+
const body = eventBody();
|
|
70
|
+
const event = await verifyPlatformEvent(await signedRequest(body, { scheme: "v1" }), SECRET);
|
|
71
|
+
expect(event.eventId).toBe("evt-1");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("SECURITY: refuses an unsigned body", async () => {
|
|
75
|
+
await expect(verifyPlatformEvent(await signedRequest(eventBody(), { scheme: "none" }), SECRET)).rejects.toThrow(
|
|
76
|
+
/missing/i,
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("SECURITY: refuses a signature made with a different secret", async () => {
|
|
81
|
+
const req = await signedRequest(eventBody(), { secret: "someone-elses-secret" });
|
|
82
|
+
await expect(verifyPlatformEvent(req, SECRET)).rejects.toThrow(/did not verify/i);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("SECURITY: refuses a body altered after signing", async () => {
|
|
86
|
+
const body = eventBody();
|
|
87
|
+
const signed = await signedRequest(body);
|
|
88
|
+
const tampered = new Request(signed.url, {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: signed.headers,
|
|
91
|
+
body: eventBody({ data: { orderId: "o-1", total: 999999 } }),
|
|
92
|
+
});
|
|
93
|
+
await expect(verifyPlatformEvent(tampered, SECRET)).rejects.toThrow(/did not verify/i);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// The reason the timestamp is in the signature at all: without it, one captured
|
|
97
|
+
// delivery could be pushed at the app for as long as the key lived.
|
|
98
|
+
it("SECURITY: refuses a correctly signed delivery that is too old to be current", async () => {
|
|
99
|
+
const stale = Math.floor(Date.now() / 1000) - (SIGNATURE_TOLERANCE_SECONDS + 60);
|
|
100
|
+
const req = await signedRequest(eventBody(), { timestamp: stale });
|
|
101
|
+
await expect(verifyPlatformEvent(req, SECRET)).rejects.toThrow(/time window/i);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("SECURITY: refuses a timestamp from the future by the same margin", async () => {
|
|
105
|
+
const ahead = Math.floor(Date.now() / 1000) + (SIGNATURE_TOLERANCE_SECONDS + 60);
|
|
106
|
+
const req = await signedRequest(eventBody(), { timestamp: ahead });
|
|
107
|
+
await expect(verifyPlatformEvent(req, SECRET)).rejects.toThrow(/time window/i);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("refuses a malformed signature header rather than trying to interpret it", async () => {
|
|
111
|
+
const req = new Request("https://app.test/api/tn-events", {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: { "x-tribenest-signature-v2": "garbage" },
|
|
114
|
+
body: eventBody(),
|
|
115
|
+
});
|
|
116
|
+
await expect(verifyPlatformEvent(req, SECRET)).rejects.toThrow(/malformed/i);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("handlePlatformEvent", () => {
|
|
121
|
+
const opts = (handlers: Parameters<typeof handlePlatformEvent>[1]["handlers"], extra = {}) => ({
|
|
122
|
+
secret: SECRET,
|
|
123
|
+
apiUrl: API,
|
|
124
|
+
token: "tnp_test",
|
|
125
|
+
handlers,
|
|
126
|
+
...extra,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("runs the handler for the event and answers 200", async () => {
|
|
130
|
+
const seen: string[] = [];
|
|
131
|
+
const res = await handlePlatformEvent(
|
|
132
|
+
await signedRequest(eventBody()),
|
|
133
|
+
opts({ "order.paid": async (e) => void seen.push(e.data.orderId) }),
|
|
134
|
+
);
|
|
135
|
+
expect(res.status).toBe(200);
|
|
136
|
+
expect(seen).toEqual(["o-1"]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("SECURITY: never runs a handler for a delivery that did not verify", async () => {
|
|
140
|
+
const handler = vi.fn();
|
|
141
|
+
const res = await handlePlatformEvent(
|
|
142
|
+
await signedRequest(eventBody(), { secret: "wrong" }),
|
|
143
|
+
opts({ "order.paid": handler }),
|
|
144
|
+
);
|
|
145
|
+
expect(res.status).toBe(401);
|
|
146
|
+
expect(handler).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// The event is finished from the platform's side once it sees a 200. Answering
|
|
150
|
+
// 200 for an event nothing handles is right; a 500 would retry it five times
|
|
151
|
+
// and then sit in the failed list forever.
|
|
152
|
+
it("acknowledges an event it has no handler for, instead of failing forever", async () => {
|
|
153
|
+
const res = await handlePlatformEvent(await signedRequest(eventBody()), opts({}));
|
|
154
|
+
expect(res.status).toBe(200);
|
|
155
|
+
expect(await res.json()).toMatchObject({ ignored: "order.paid" });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// The whole reason this route is platform-owned: a 200 must mean the work is
|
|
159
|
+
// done, so the response cannot go out until the handler's promise settles.
|
|
160
|
+
it("REGRESSION: does not answer until the handler has actually finished", async () => {
|
|
161
|
+
let finished = false;
|
|
162
|
+
const res = await handlePlatformEvent(
|
|
163
|
+
await signedRequest(eventBody()),
|
|
164
|
+
opts({
|
|
165
|
+
"order.paid": async () => {
|
|
166
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
167
|
+
finished = true;
|
|
168
|
+
},
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
expect(finished).toBe(true);
|
|
172
|
+
expect(res.status).toBe(200);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("answers 500 when the handler throws, so the platform retries", async () => {
|
|
176
|
+
const res = await handlePlatformEvent(
|
|
177
|
+
await signedRequest(eventBody()),
|
|
178
|
+
opts({
|
|
179
|
+
"order.paid": async () => {
|
|
180
|
+
throw new Error("downstream is down");
|
|
181
|
+
},
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
expect(res.status).toBe(500);
|
|
185
|
+
expect(await res.json()).toMatchObject({ error: "downstream is down", eventId: "evt-1" });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// An overrun is otherwise indistinguishable from the platform timing out, and
|
|
189
|
+
// the fix (move it to a job) is not guessable from a dropped connection.
|
|
190
|
+
it("names the fix when a handler runs past its budget", async () => {
|
|
191
|
+
const res = await handlePlatformEvent(
|
|
192
|
+
await signedRequest(eventBody()),
|
|
193
|
+
opts({ "order.paid": () => new Promise<void>(() => {}) }, { budgetMs: 20 }),
|
|
194
|
+
);
|
|
195
|
+
expect(res.status).toBe(500);
|
|
196
|
+
expect((await res.json()).error).toMatch(/enqueueAppJob/);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// The property that makes a retried handler safe. Asserted on the header the
|
|
201
|
+
// platform actually receives, because that is what the idempotency store keys on.
|
|
202
|
+
describe("idempotency inside a handler", () => {
|
|
203
|
+
const runWithFetch = async (handler: Parameters<typeof handlePlatformEvent>[1]["handlers"]) => {
|
|
204
|
+
const calls: Array<{ url: string; key: string | null; body: string }> = [];
|
|
205
|
+
vi.stubGlobal(
|
|
206
|
+
"fetch",
|
|
207
|
+
vi.fn(async (url: string, init: RequestInit) => {
|
|
208
|
+
const headers = new Headers(init.headers as HeadersInit);
|
|
209
|
+
calls.push({ url, key: headers.get("idempotency-key"), body: String(init.body) });
|
|
210
|
+
return new Response(JSON.stringify({ result: { ok: true } }), { status: 200 });
|
|
211
|
+
}),
|
|
212
|
+
);
|
|
213
|
+
try {
|
|
214
|
+
await handlePlatformEvent(await signedRequest(eventBody()), {
|
|
215
|
+
secret: SECRET,
|
|
216
|
+
apiUrl: API,
|
|
217
|
+
token: "tnp_test",
|
|
218
|
+
handlers: handler,
|
|
219
|
+
});
|
|
220
|
+
} finally {
|
|
221
|
+
vi.unstubAllGlobals();
|
|
222
|
+
}
|
|
223
|
+
return calls;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
it("sends an idempotency key the app never had to think about", async () => {
|
|
227
|
+
const calls = await runWithFetch({
|
|
228
|
+
"order.paid": async (_e, ctx) => {
|
|
229
|
+
await ctx.platform.run("blog.post.create", { title: "T" });
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
expect(calls).toHaveLength(1);
|
|
233
|
+
// Carries the event id so a human reading the audit trail can see where the
|
|
234
|
+
// write came from.
|
|
235
|
+
expect(calls[0].key).toMatch(/^evt_evt-1_[0-9a-f]{32}$/);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("REGRESSION: the same write on a redelivery produces the SAME key", async () => {
|
|
239
|
+
const write = {
|
|
240
|
+
"order.paid": async (_e: unknown, ctx: { platform: { run: (...a: never[]) => Promise<unknown> } }) => {
|
|
241
|
+
await (ctx.platform.run as (a: string, b: unknown) => Promise<unknown>)("blog.post.create", {
|
|
242
|
+
title: "T",
|
|
243
|
+
tags: ["a", "b"],
|
|
244
|
+
});
|
|
245
|
+
},
|
|
246
|
+
} as unknown as Parameters<typeof handlePlatformEvent>[1]["handlers"];
|
|
247
|
+
|
|
248
|
+
const first = await runWithFetch(write);
|
|
249
|
+
const second = await runWithFetch(write);
|
|
250
|
+
expect(first[0].key).toBe(second[0].key);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// A call counter would have done this too — until a handler looped or branched
|
|
254
|
+
// differently on the retry, and then every key after the first would shift.
|
|
255
|
+
it("is not affected by the ORDER the writes happen in", async () => {
|
|
256
|
+
const forwards = await runWithFetch({
|
|
257
|
+
"order.paid": async (_e, ctx) => {
|
|
258
|
+
await ctx.platform.run("blog.post.create", { title: "A" });
|
|
259
|
+
await ctx.platform.run("blog.post.create", { title: "B" });
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
const backwards = await runWithFetch({
|
|
263
|
+
"order.paid": async (_e, ctx) => {
|
|
264
|
+
await ctx.platform.run("blog.post.create", { title: "B" });
|
|
265
|
+
await ctx.platform.run("blog.post.create", { title: "A" });
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
expect(new Set(forwards.map((c) => c.key))).toEqual(new Set(backwards.map((c) => c.key)));
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("keys on the input, so two different writes are not collapsed into one", async () => {
|
|
272
|
+
const calls = await runWithFetch({
|
|
273
|
+
"order.paid": async (_e, ctx) => {
|
|
274
|
+
await ctx.platform.run("blog.post.create", { title: "A" });
|
|
275
|
+
await ctx.platform.run("blog.post.create", { title: "B" });
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
expect(calls[0].key).not.toBe(calls[1].key);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("keys on the action, so the same input to two actions is not collapsed", async () => {
|
|
282
|
+
const calls = await runWithFetch({
|
|
283
|
+
"order.paid": async (_e, ctx) => {
|
|
284
|
+
await ctx.platform.run("blog.post.create", { title: "A" });
|
|
285
|
+
await ctx.platform.run("blog.post.update", { title: "A" });
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
expect(calls[0].key).not.toBe(calls[1].key);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// Key order is not stable, so an unsorted stringify would hash the same write
|
|
292
|
+
// two different ways and silently allow the duplicate.
|
|
293
|
+
it("REGRESSION: object key order in the input does not change the key", async () => {
|
|
294
|
+
const a = await runWithFetch({
|
|
295
|
+
"order.paid": async (_e, ctx) => {
|
|
296
|
+
await ctx.platform.run("blog.post.create", { title: "T", content: "C" });
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
const b = await runWithFetch({
|
|
300
|
+
"order.paid": async (_e, ctx) => {
|
|
301
|
+
await ctx.platform.run("blog.post.create", { content: "C", title: "T" } as never);
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
expect(a[0].key).toBe(b[0].key);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("lets an app override the key when it genuinely needs the same write twice", async () => {
|
|
308
|
+
const calls = await runWithFetch({
|
|
309
|
+
"order.paid": async (_e, ctx) => {
|
|
310
|
+
await ctx.platform.run("blog.post.create", { title: "T" }, { idempotencyKey: "mine-1" });
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
expect(calls[0].key).toBe("mine-1");
|
|
314
|
+
});
|
|
315
|
+
});
|
package/src/server/index.ts
CHANGED
|
@@ -274,9 +274,26 @@ export function toPlainText(html?: string, max = 200): string | undefined {
|
|
|
274
274
|
export {
|
|
275
275
|
createPlatformClient,
|
|
276
276
|
verifyPlatformEvent,
|
|
277
|
+
handlePlatformEvent,
|
|
277
278
|
PlatformError,
|
|
279
|
+
HANDLER_BUDGET_MS,
|
|
280
|
+
SIGNATURE_TOLERANCE_SECONDS,
|
|
278
281
|
type PlatformClient,
|
|
279
282
|
type PlatformConfig,
|
|
280
283
|
type PlatformAction,
|
|
281
284
|
type PlatformEvent,
|
|
285
|
+
type TypedPlatformEvent,
|
|
286
|
+
type PlatformEventHandlers,
|
|
287
|
+
type HandlePlatformEventOptions,
|
|
282
288
|
} from "./platform";
|
|
289
|
+
|
|
290
|
+
// Generated from the platform's event catalog — the payload each event carries.
|
|
291
|
+
// `npm run generate:forge-events` in apps/backend rewrites it; a spec fails if
|
|
292
|
+
// the committed copy is stale.
|
|
293
|
+
export {
|
|
294
|
+
PLATFORM_EVENT_PERMISSIONS,
|
|
295
|
+
PLATFORM_EVENT_NAMES,
|
|
296
|
+
type PlatformEventMap,
|
|
297
|
+
type PlatformEventName,
|
|
298
|
+
type PlatformEventData,
|
|
299
|
+
} from "./platformEvents.generated";
|
package/src/server/jobs.ts
CHANGED
|
@@ -11,11 +11,22 @@ export interface AppJobsEnv {
|
|
|
11
11
|
idFromName: (name: string) => unknown;
|
|
12
12
|
get: (id: unknown) => AppJobsStub;
|
|
13
13
|
};
|
|
14
|
+
/**
|
|
15
|
+
* Per-app job credential, injected by the platform at deploy. Authenticates
|
|
16
|
+
* this app to the shared job runtime (C8) — without it the runtime cannot
|
|
17
|
+
* tell one app's calls from another's.
|
|
18
|
+
*/
|
|
19
|
+
JOBS_APP_TOKEN?: string;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
interface AppJobsStub {
|
|
17
|
-
reconcile: (
|
|
18
|
-
|
|
23
|
+
reconcile: (
|
|
24
|
+
script: string,
|
|
25
|
+
callerToken: string,
|
|
26
|
+
desired: JobSchedule[],
|
|
27
|
+
hash: string,
|
|
28
|
+
) => Promise<{ changed: boolean }>;
|
|
29
|
+
enqueue: (script: string, callerToken: string, name: string, payload: unknown, delayMs: number) => Promise<void>;
|
|
19
30
|
status: () => Promise<AppJobsStatus>;
|
|
20
31
|
}
|
|
21
32
|
|
|
@@ -79,10 +90,10 @@ export interface JobSchedule {
|
|
|
79
90
|
* interval after the deploy.
|
|
80
91
|
*/
|
|
81
92
|
intervalMs?: number;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
93
|
+
// A 5-field UTC cron expression: minute hour day-of-month month day-of-week.
|
|
94
|
+
// e.g. "0 3 * * *" = daily at 03:00 UTC; "0 0,6,12,18 * * *" = every 6 hours;
|
|
95
|
+
// "0 9 * * 1-5" = 09:00 UTC on weekdays. Provide this OR `intervalMs`. Use
|
|
96
|
+
// cron when a run must land at a specific wall-clock time; evaluation is UTC.
|
|
86
97
|
cron?: string;
|
|
87
98
|
}
|
|
88
99
|
|
|
@@ -135,6 +146,26 @@ export interface AppJobsConfig {
|
|
|
135
146
|
|
|
136
147
|
const stub = (env: AppJobsEnv, appId: string): AppJobsStub => env.JOBS.get(env.JOBS.idFromName(appId));
|
|
137
148
|
|
|
149
|
+
/**
|
|
150
|
+
* The per-app credential the platform injects at deploy (`JOBS_APP_TOKEN`).
|
|
151
|
+
*
|
|
152
|
+
* The job runtime authenticates the CALLER with this instead of trusting an
|
|
153
|
+
* `appId` argument (C8): every app has a binding to the shared namespace, so
|
|
154
|
+
* without it any app could address — and operate on — another app's jobs.
|
|
155
|
+
*
|
|
156
|
+
* Apps published before this existed have no token and will get a clear error
|
|
157
|
+
* rather than a silent no-op; republishing injects one.
|
|
158
|
+
*/
|
|
159
|
+
const callerToken = (env: AppJobsEnv): string => {
|
|
160
|
+
const token = (env as { JOBS_APP_TOKEN?: string }).JOBS_APP_TOKEN;
|
|
161
|
+
if (!token) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
"TribeNest jobs: this app has no JOBS_APP_TOKEN. Republish the app to receive one (it is injected at deploy).",
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return token;
|
|
167
|
+
};
|
|
168
|
+
|
|
138
169
|
// A stable fingerprint of the desired schedule set, so the DO skips reconcile
|
|
139
170
|
// when nothing changed (sorted by name → order-independent).
|
|
140
171
|
function scheduleHash(schedules: JobSchedule[]): string {
|
|
@@ -152,7 +183,7 @@ function scheduleHash(schedules: JobSchedule[]): string {
|
|
|
152
183
|
* one, a removed one is pruned.
|
|
153
184
|
*/
|
|
154
185
|
export async function reconcileAppJobs(env: AppJobsEnv, cfg: AppJobsConfig, schedules: JobSchedule[]): Promise<void> {
|
|
155
|
-
await stub(env, cfg.appId).reconcile(cfg.script ?? "",
|
|
186
|
+
await stub(env, cfg.appId).reconcile(cfg.script ?? "", callerToken(env), schedules, scheduleHash(schedules));
|
|
156
187
|
}
|
|
157
188
|
|
|
158
189
|
/**
|
|
@@ -172,7 +203,7 @@ export async function enqueueAppJob(
|
|
|
172
203
|
payload?: unknown,
|
|
173
204
|
delayMs = 0,
|
|
174
205
|
): Promise<void> {
|
|
175
|
-
await stub(env, cfg.appId).enqueue(cfg.script ?? "",
|
|
206
|
+
await stub(env, cfg.appId).enqueue(cfg.script ?? "", callerToken(env), name, payload ?? null, delayMs);
|
|
176
207
|
}
|
|
177
208
|
|
|
178
209
|
/**
|
|
@@ -330,7 +361,6 @@ export async function deleteAppCollection(
|
|
|
330
361
|
return res.json();
|
|
331
362
|
}
|
|
332
363
|
|
|
333
|
-
|
|
334
364
|
/**
|
|
335
365
|
* Read an app's OWN collection back from a JOB handler (the read counterpart to
|
|
336
366
|
* writeAppCollection), authenticated by the run's app-sync token. Reads at `app`
|
|
@@ -367,6 +397,94 @@ export async function aggregateAppCollection(
|
|
|
367
397
|
headers: { "content-type": "application/json", "x-app-sync-token": ctx.token },
|
|
368
398
|
body: JSON.stringify(input),
|
|
369
399
|
});
|
|
370
|
-
if (!res.ok)
|
|
400
|
+
if (!res.ok)
|
|
401
|
+
throw new Error(`aggregateAppCollection ${slug} failed: ${res.status} ${await res.text().catch(() => "")}`);
|
|
402
|
+
return res.json();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Upload a file and get back a hosted URL, in two steps.
|
|
407
|
+
*
|
|
408
|
+
* The two steps are not a formality. The bytes go to a STAGING key that does not
|
|
409
|
+
* survive: `finalizeAppUpload` moves the file to its real home, and anything
|
|
410
|
+
* never finalized is deleted within the hour. So the URL you can actually use is
|
|
411
|
+
* the one finalize returns — a staged URL will stop working, and the upload will
|
|
412
|
+
* not be counted against the profile's storage.
|
|
413
|
+
*
|
|
414
|
+
* const up = await createAppUpload(ctx, cfg, "cover.png", bytes.byteLength)
|
|
415
|
+
* await fetch(up.uploadUrl, { method: "PUT", headers: up.requiredHeaders, body: bytes })
|
|
416
|
+
* const media = await finalizeAppUpload(ctx, cfg, up.uploadId) // media.url is yours
|
|
417
|
+
*
|
|
418
|
+
* `size` is a hint so an over-quota upload fails before the transfer rather than
|
|
419
|
+
* after it; the size recorded is whatever storage reports once the bytes land.
|
|
420
|
+
* The file name decides the type, and script-capable types (html, svg) are
|
|
421
|
+
* refused.
|
|
422
|
+
*/
|
|
423
|
+
export async function createAppUpload(
|
|
424
|
+
ctx: JobContext,
|
|
425
|
+
cfg: AppJobsConfig,
|
|
426
|
+
fileName: string,
|
|
427
|
+
size?: number,
|
|
428
|
+
): Promise<{
|
|
429
|
+
uploadId: string;
|
|
430
|
+
uploadUrl: string;
|
|
431
|
+
requiredHeaders: Record<string, string>;
|
|
432
|
+
method: "PUT";
|
|
433
|
+
expiresAt: string;
|
|
434
|
+
}> {
|
|
435
|
+
const res = await fetch(`${cfg.apiUrl}/public/apps/${cfg.appId}/uploads`, {
|
|
436
|
+
method: "POST",
|
|
437
|
+
headers: { "content-type": "application/json", "x-app-sync-token": ctx.token },
|
|
438
|
+
body: JSON.stringify({ fileName, size }),
|
|
439
|
+
});
|
|
440
|
+
if (!res.ok) throw new Error(`createAppUpload failed: ${res.status} ${await res.text().catch(() => "")}`);
|
|
441
|
+
return res.json();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Complete an upload started with {@link createAppUpload} and get the media row.
|
|
446
|
+
*
|
|
447
|
+
* Safe to retry — finalizing twice returns the same media rather than storing the
|
|
448
|
+
* file twice. Takes no size: the platform reads it back from storage.
|
|
449
|
+
*/
|
|
450
|
+
export async function finalizeAppUpload(
|
|
451
|
+
ctx: JobContext,
|
|
452
|
+
cfg: AppJobsConfig,
|
|
453
|
+
uploadId: string,
|
|
454
|
+
): Promise<{ id: string; url: string; size: string; type: string; name: string }> {
|
|
455
|
+
const res = await fetch(`${cfg.apiUrl}/public/apps/${cfg.appId}/uploads/${uploadId}/finalize`, {
|
|
456
|
+
method: "POST",
|
|
457
|
+
headers: { "content-type": "application/json", "x-app-sync-token": ctx.token },
|
|
458
|
+
body: "{}",
|
|
459
|
+
});
|
|
460
|
+
if (!res.ok) throw new Error(`finalizeAppUpload failed: ${res.status} ${await res.text().catch(() => "")}`);
|
|
461
|
+
return res.json();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Send an email as this app.
|
|
466
|
+
*
|
|
467
|
+
* The From resolves to the app's own sending identity when the owner configured
|
|
468
|
+
* one, otherwise the profile's — so mail is attributed to the creator, not to
|
|
469
|
+
* TribeNest, without the app holding any mail credentials.
|
|
470
|
+
*
|
|
471
|
+
* await sendAppEmail(ctx, cfg, { to: user.email, subject: "Booked", html })
|
|
472
|
+
*
|
|
473
|
+
* Bounded on purpose: at most 50 recipients per call, and a daily per-app
|
|
474
|
+
* recipient budget, because the send is charged to the owner's allocation. For a
|
|
475
|
+
* campaign, use the platform's messaging tools instead — they handle consent and
|
|
476
|
+
* unsubscribes, which this deliberately does not.
|
|
477
|
+
*/
|
|
478
|
+
export async function sendAppEmail(
|
|
479
|
+
ctx: JobContext,
|
|
480
|
+
cfg: AppJobsConfig,
|
|
481
|
+
msg: { to: string | string[]; subject: string; html: string; replyTo?: string },
|
|
482
|
+
): Promise<{ sent: number }> {
|
|
483
|
+
const res = await fetch(`${cfg.apiUrl}/public/apps/${cfg.appId}/emails`, {
|
|
484
|
+
method: "POST",
|
|
485
|
+
headers: { "content-type": "application/json", "x-app-sync-token": ctx.token },
|
|
486
|
+
body: JSON.stringify(msg),
|
|
487
|
+
});
|
|
488
|
+
if (!res.ok) throw new Error(`sendAppEmail failed: ${res.status} ${await res.text().catch(() => "")}`);
|
|
371
489
|
return res.json();
|
|
372
490
|
}
|