@tribe-nest/forge 1.20.2 → 2.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.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/src/contexts/AppAuthContext.tsx +26 -0
  3. package/src/contexts/CartContext.tsx +132 -8
  4. package/src/data/queries/useCheckouts.ts +101 -0
  5. package/src/data/queries/useCollections.ts +24 -4
  6. package/src/data/queries/useFinalize.ts +41 -0
  7. package/src/data/queries/usePageActions.ts +2 -0
  8. package/src/index.ts +6 -1
  9. package/src/server/_tests/appUserPermissions.spec.ts +197 -0
  10. package/src/server/appAuth.ts +37 -2
  11. package/src/server/appUsers.ts +133 -0
  12. package/src/server/index.ts +17 -0
  13. package/src/server/jobs.ts +141 -6
  14. package/src/server/platform.ts +208 -0
  15. package/src/types/models.ts +26 -2
  16. package/src/ui/headless/checkout/useCheckout.ts +56 -9
  17. package/src/ui/headless/event/useEventCheckout.ts +36 -0
  18. package/src/ui/headless/funnel/Funnel.tsx +159 -0
  19. package/src/ui/headless/funnel/funnelSession.spec.ts +108 -0
  20. package/src/ui/headless/funnel/funnelSession.ts +88 -0
  21. package/src/ui/headless/funnel/index.ts +3 -0
  22. package/src/ui/headless/funnel/useFunnelStep.ts +70 -0
  23. package/src/ui/headless/index.ts +3 -0
  24. package/src/ui/index.ts +2 -0
  25. package/src/ui/styled/Addons.tsx +77 -0
  26. package/src/ui/styled/BundleConfirmation.tsx +161 -0
  27. package/src/ui/styled/Cart.tsx +66 -9
  28. package/src/ui/styled/CheckoutConfirmation.tsx +25 -1
  29. package/src/ui/styled/EventTickets.tsx +52 -7
  30. package/src/ui/styled/PageActions.tsx +34 -3
  31. package/src/ui/styled/ProductGrid.tsx +45 -9
  32. package/src/utils/formatDateTime.ts +25 -0
  33. package/src/utils/headMeta.ts +50 -0
@@ -0,0 +1,197 @@
1
+ // Unit tests for the app-user permission helpers — the CLIENT side of the
2
+ // permission store (packages/forge/src/server/appUsers.ts + verifyAppUser's
3
+ // `require` in appAuth.ts). The platform's own rules are tested against a real
4
+ // database in apps/backend/src/routes/public/apps/_tests/appUserPermissions.spec.ts;
5
+ // what CANNOT be tested there is this half, which decides which header a
6
+ // credential becomes and whether a guard says yes.
7
+ //
8
+ // Both are security-relevant in a quiet way: a typo in the header name turns
9
+ // every grant into a 401 (visible), and a wrong `require` turns every guard into
10
+ // a yes (invisible). Hence the emphasis on the negative cases.
11
+ //
12
+ // Coverage:
13
+ // - credential → header mapping for all three principals
14
+ // - `Bearer ` normalization (bare token and full header value both work)
15
+ // - set vs add/remove body shapes
16
+ // - error surfacing: the platform's message reaches the caller, not "failed"
17
+ // - verifyAppUser: no token, bad response, permission held / not held / absent
18
+ // - verifyAppUser: `require` accepts one permission or any-of several
19
+
20
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
21
+ import { setAppUserPermissions, getAppUserPermissions } from "../appUsers";
22
+ import { verifyAppUser } from "../appAuth";
23
+
24
+ const cfg = { appId: "app-1", apiUrl: "https://api.test" };
25
+
26
+ type Call = { url: string; init: RequestInit };
27
+ let calls: Call[];
28
+
29
+ /** Stub `fetch`, recording every call and answering with `body`. */
30
+ function stubFetch(body: unknown, ok = true, status = 200) {
31
+ calls = [];
32
+ const fake = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
33
+ calls.push({ url: String(url), init: init ?? {} });
34
+ return { ok, status, json: async () => body } as unknown as Response;
35
+ });
36
+ vi.stubGlobal("fetch", fake);
37
+ return fake;
38
+ }
39
+
40
+ const headers = (i = 0) => (calls[i].init.headers ?? {}) as Record<string, string>;
41
+ const body = (i = 0) => JSON.parse(String(calls[i].init.body));
42
+
43
+ const request = (auth?: string) =>
44
+ new Request("https://app.test/api/thing", { headers: auth ? { authorization: auth } : {} });
45
+
46
+ afterEach(() => vi.unstubAllGlobals());
47
+
48
+ describe("setAppUserPermissions — credentials", () => {
49
+ beforeEach(() => stubFetch({ permissions: ["a"] }));
50
+
51
+ it("sends a job token as the app-sync header, NOT as a bearer", async () => {
52
+ await setAppUserPermissions(cfg, { jobToken: "sync-token" }, "assoc-1", { add: ["a"] });
53
+ expect(headers()["x-app-sync-token"]).toBe("sync-token");
54
+ // A job token is not a bearer credential; sending it as one would be
55
+ // rejected by the platform with an auth error that reads like a bug.
56
+ expect(headers().authorization).toBeUndefined();
57
+ });
58
+
59
+ it("sends a platform token as a bearer", async () => {
60
+ await setAppUserPermissions(cfg, { platformToken: "tnp_abc" }, "assoc-1", { add: ["a"] });
61
+ expect(headers().authorization).toBe("Bearer tnp_abc");
62
+ expect(headers()["x-app-sync-token"]).toBeUndefined();
63
+ });
64
+
65
+ it("accepts an admin token either bare or as a whole Authorization header", async () => {
66
+ // The admin lane usually has the full header straight off the request, and
67
+ // double-prefixing it would produce "Bearer Bearer …".
68
+ await setAppUserPermissions(cfg, { adminToken: "Bearer jwt-value" }, "assoc-1", { add: ["a"] });
69
+ expect(headers().authorization).toBe("Bearer jwt-value");
70
+
71
+ await setAppUserPermissions(cfg, { adminToken: "jwt-value" }, "assoc-1", { add: ["a"] });
72
+ expect(headers(1).authorization).toBe("Bearer jwt-value");
73
+ });
74
+ });
75
+
76
+ describe("setAppUserPermissions — request shape", () => {
77
+ beforeEach(() => stubFetch({ permissions: ["kept"] }));
78
+
79
+ it("PUTs to the app's own user, url-encoding the association id", async () => {
80
+ await setAppUserPermissions(cfg, { jobToken: "t" }, "assoc/1", { add: ["a"] });
81
+ expect(calls[0].url).toBe("https://api.test/public/apps/app-1/users/assoc%2F1/permissions");
82
+ expect(calls[0].init.method).toBe("PUT");
83
+ expect(headers()["content-type"]).toBe("application/json");
84
+ });
85
+
86
+ it("sends `permissions` for a full set and `add`/`remove` for a patch", async () => {
87
+ await setAppUserPermissions(cfg, { jobToken: "t" }, "a1", { set: ["only", "these"] });
88
+ expect(body()).toEqual({ permissions: ["only", "these"] });
89
+
90
+ await setAppUserPermissions(cfg, { jobToken: "t" }, "a1", { add: ["x"], remove: ["y"] });
91
+ expect(body(1)).toEqual({ add: ["x"], remove: ["y"] });
92
+ });
93
+
94
+ it("returns the resulting list", async () => {
95
+ expect(await setAppUserPermissions(cfg, { jobToken: "t" }, "a1", { add: ["x"] })).toEqual(["kept"]);
96
+ });
97
+
98
+ it("GETs without a body", async () => {
99
+ expect(await getAppUserPermissions(cfg, { jobToken: "t" }, "a1")).toEqual(["kept"]);
100
+ expect(calls[0].init.method).toBe("GET");
101
+ expect(calls[0].init.body).toBeUndefined();
102
+ });
103
+ });
104
+
105
+ describe("setAppUserPermissions — failures", () => {
106
+ it("surfaces the platform's own message", async () => {
107
+ // The refusals say WHY and what to do instead ("an app user cannot change
108
+ // permissions…"). Flattening that to "request failed" would strand the
109
+ // developer who hit it.
110
+ stubFetch({ message: "An app user cannot change permissions — not even their own." }, false, 403);
111
+ await expect(setAppUserPermissions(cfg, { jobToken: "t" }, "a1", { add: ["x"] })).rejects.toThrow(
112
+ /app user cannot change permissions/,
113
+ );
114
+ });
115
+
116
+ it("still throws when the error body is not JSON", async () => {
117
+ calls = [];
118
+ vi.stubGlobal(
119
+ "fetch",
120
+ vi.fn(async () => ({ ok: false, status: 500, json: async () => Promise.reject(new Error("nope")) })),
121
+ );
122
+ await expect(setAppUserPermissions(cfg, { jobToken: "t" }, "a1", { add: ["x"] })).rejects.toThrow(/500/);
123
+ });
124
+
125
+ it("treats a response with no permissions as an empty list, not undefined", async () => {
126
+ stubFetch({});
127
+ expect(await getAppUserPermissions(cfg, { jobToken: "t" }, "a1")).toEqual([]);
128
+ });
129
+ });
130
+
131
+ describe("verifyAppUser — require", () => {
132
+ const user = (permissions?: unknown) => ({
133
+ id: "acc-1",
134
+ associationId: "assoc-1",
135
+ email: "u@test",
136
+ firstName: null,
137
+ lastName: null,
138
+ status: "active",
139
+ appId: "app-1",
140
+ ...(permissions === undefined ? {} : { permissions }),
141
+ });
142
+
143
+ it("returns the user with permissions when no requirement is given", async () => {
144
+ stubFetch(user(["bookings.manage"]));
145
+ const result = await verifyAppUser(request("Bearer tok"), cfg);
146
+ expect(result).toMatchObject({ associationId: "assoc-1", permissions: ["bookings.manage"] });
147
+ });
148
+
149
+ it("passes when the user holds the required permission", async () => {
150
+ stubFetch(user(["bookings.manage", "reports.view"]));
151
+ expect(await verifyAppUser(request("Bearer tok"), cfg, { require: "bookings.manage" })).not.toBeNull();
152
+ });
153
+
154
+ it("REFUSES when the user is signed in but lacks it", async () => {
155
+ stubFetch(user(["reports.view"]));
156
+ expect(await verifyAppUser(request("Bearer tok"), cfg, { require: "bookings.manage" })).toBeNull();
157
+ });
158
+
159
+ it("treats an array requirement as ANY-of", async () => {
160
+ stubFetch(user(["reports.view"]));
161
+ expect(await verifyAppUser(request("Bearer tok"), cfg, { require: ["bookings.manage", "reports.view"] })).not.toBeNull();
162
+
163
+ stubFetch(user(["something.else"]));
164
+ expect(await verifyAppUser(request("Bearer tok"), cfg, { require: ["bookings.manage", "reports.view"] })).toBeNull();
165
+ });
166
+
167
+ it("holds nothing when the API sends no permissions (older backend)", async () => {
168
+ // A site on a newer Forge against an older API must degrade to "holds none"
169
+ // rather than throwing inside a guard — a crash there is a 500 on a route
170
+ // that should simply have said no.
171
+ stubFetch(user(undefined));
172
+ expect(await verifyAppUser(request("Bearer tok"), cfg)).toMatchObject({ permissions: [] });
173
+ expect(await verifyAppUser(request("Bearer tok"), cfg, { require: "anything" })).toBeNull();
174
+ });
175
+
176
+ it("returns null without a token, and never calls the platform", async () => {
177
+ const fetchMock = stubFetch(user([]));
178
+ expect(await verifyAppUser(request(), cfg, { require: "x" })).toBeNull();
179
+ expect(fetchMock).not.toHaveBeenCalled();
180
+ });
181
+
182
+ it("returns null when the platform rejects the token", async () => {
183
+ stubFetch({ message: "nope" }, false, 401);
184
+ expect(await verifyAppUser(request("Bearer bad"), cfg, { require: "x" })).toBeNull();
185
+ });
186
+
187
+ it("returns null when the platform is unreachable", async () => {
188
+ vi.stubGlobal(
189
+ "fetch",
190
+ vi.fn(async () => {
191
+ throw new Error("network down");
192
+ }),
193
+ );
194
+ // Fail CLOSED: an outage must not open a guarded route.
195
+ expect(await verifyAppUser(request("Bearer tok"), cfg, { require: "x" })).toBeNull();
196
+ });
197
+ });
@@ -40,6 +40,15 @@ export interface AppUserIdentity {
40
40
  lastName: string | null;
41
41
  status: string;
42
42
  appId: string | null;
43
+ /**
44
+ * The permissions YOUR app has given this user. Your own vocabulary — the
45
+ * platform stores these strings and never interprets them.
46
+ *
47
+ * Read here, on the server, from the platform's answer about the caller's
48
+ * token. That is what makes them trustworthy: nothing the browser sends can
49
+ * change them, and a revoked permission is gone on the very next request.
50
+ */
51
+ permissions: string[];
43
52
  }
44
53
 
45
54
  // The bearer credential the client forwarded, or null. We pass it through as-is
@@ -91,8 +100,26 @@ export async function verifyAppAdmin(request: Request, cfg: AppAuthConfig): Prom
91
100
  * user, or `null` when unauthenticated. Use to scope an end-user server action
92
101
  * to that user (e.g. read/write only their own rows) — the returned
93
102
  * `associationId` is the owner id the platform enforces on `mine`-scoped data.
103
+ *
104
+ * Pass `require` to also demand one of YOUR app's permissions, so the check and
105
+ * the refusal are one line and can't drift apart:
106
+ *
107
+ * const user = await verifyAppUser(request, cfg, { require: "bookings.manage" })
108
+ * if (!user) return new Response("forbidden", { status: 403 })
109
+ *
110
+ * `require` returns null when the user is signed in but lacks the permission —
111
+ * same shape as "not signed in", because the route's answer is the same either
112
+ * way and distinguishing them tells a prober which permission to go hunting for.
113
+ * Read `user.permissions` yourself when you need the difference.
94
114
  */
95
- export async function verifyAppUser(request: Request, cfg: AppAuthConfig): Promise<AppUserIdentity | null> {
115
+ export async function verifyAppUser(
116
+ request: Request,
117
+ cfg: AppAuthConfig,
118
+ opts?: {
119
+ /** A permission (or any one of several) the user must hold. */
120
+ require?: string | string[];
121
+ },
122
+ ): Promise<AppUserIdentity | null> {
96
123
  const auth = bearer(request);
97
124
  if (!auth) return null;
98
125
  try {
@@ -100,7 +127,15 @@ export async function verifyAppUser(request: Request, cfg: AppAuthConfig): Promi
100
127
  headers: { authorization: auth },
101
128
  });
102
129
  if (!res.ok) return null;
103
- return (await res.json()) as AppUserIdentity;
130
+ const user = (await res.json()) as AppUserIdentity;
131
+ // Older API responses have no `permissions`; treat that as "holds none"
132
+ // rather than letting `undefined.includes` throw inside a guard.
133
+ user.permissions = Array.isArray(user.permissions) ? user.permissions : [];
134
+ if (opts?.require) {
135
+ const required = Array.isArray(opts.require) ? opts.require : [opts.require];
136
+ if (!required.some((p) => user.permissions.includes(p))) return null;
137
+ }
138
+ return user;
104
139
  } catch {
105
140
  return null;
106
141
  }
@@ -0,0 +1,133 @@
1
+ // Permissions for YOUR app's own users. SERVER-ONLY.
2
+ //
3
+ // ─────────────────────────────────────────────────────────────────────────────
4
+ // THE ONE RULE: NEVER SET PERMISSIONS FROM WHAT THE BROWSER ASKED FOR.
5
+ //
6
+ // A user's own token can't reach this API — the platform refuses it — so the
7
+ // only way to get self-escalation is to replay a user's request on the app's own
8
+ // credential. That is exactly what a route looks like when it's written without
9
+ // thinking about it:
10
+ //
11
+ // ❌ WRONG — any signed-in user can now make themselves an admin
12
+ // const { associationId, permissions } = await request.json()
13
+ // await setAppUserPermissions(cfg, { jobToken: ctx.token }, associationId, { set: permissions })
14
+ //
15
+ // ✅ RIGHT — a person with admin rights on the app decided this
16
+ // const admin = await verifyAppAdmin(request, cfg)
17
+ // if (!admin) return new Response("forbidden", { status: 403 })
18
+ // const { associationId, permissions } = await request.json()
19
+ // await setAppUserPermissions(cfg, { adminToken: request.headers.get("authorization")! },
20
+ // associationId, { set: permissions })
21
+ //
22
+ // ✅ ALSO RIGHT — your own code decided, with no user request involved
23
+ // await setAppUserPermissions(cfg, { jobToken: ctx.token }, buyer.associationId,
24
+ // { add: ["premium"] })
25
+ //
26
+ // The difference is not the credential. It is whether the DECISION came from
27
+ // your app or from the caller.
28
+ // ─────────────────────────────────────────────────────────────────────────────
29
+ //
30
+ // The permission strings are your own — "bookings.manage", "premium", whatever
31
+ // your code checks. TribeNest stores them against the user and hands them back
32
+ // with their identity (`verifyAppUser`, `useAppAuth`). It never reads them, so
33
+ // there is no catalog to register and nothing to keep in sync with your code.
34
+
35
+ import type { AppAuthConfig } from "./appAuth";
36
+
37
+ /**
38
+ * How this call proves it is allowed to change permissions. Three principals,
39
+ * and a user's own session token is not one of them.
40
+ */
41
+ export type AppUserPermissionCredential =
42
+ /** Inside a job handler: `{ jobToken: ctx.token }`. The app acting as itself. */
43
+ | { jobToken: string }
44
+ /** From your own server, outside a job: `{ platformToken: env.TN_APP_TOKEN }`. */
45
+ | { platformToken: string }
46
+ /** A person's token, AFTER `verifyAppAdmin` said they're an admin of this app. */
47
+ | { adminToken: string };
48
+
49
+ export type AppUserPermissionChange =
50
+ /** The complete list. What an admin screen sends. */
51
+ | { set: string[] }
52
+ /**
53
+ * A patch. What automation should send: granting `premium` must not silently
54
+ * drop something another path added between your read and your write.
55
+ */
56
+ | { add?: string[]; remove?: string[] };
57
+
58
+ function authHeaders(credential: AppUserPermissionCredential): Record<string, string> {
59
+ if ("jobToken" in credential) return { "x-app-sync-token": credential.jobToken };
60
+ const raw = "platformToken" in credential ? credential.platformToken : credential.adminToken;
61
+ // Accept a bare token or a whole `Authorization` header value, since the admin
62
+ // lane usually has the latter straight off the incoming request.
63
+ return { authorization: /^Bearer\s/i.test(raw) ? raw : `Bearer ${raw}` };
64
+ }
65
+
66
+ async function permissionsRequest(
67
+ cfg: AppAuthConfig,
68
+ credential: AppUserPermissionCredential,
69
+ associationId: string,
70
+ init: RequestInit,
71
+ ): Promise<string[]> {
72
+ const res = await fetch(
73
+ `${cfg.apiUrl}/public/apps/${cfg.appId}/users/${encodeURIComponent(associationId)}/permissions`,
74
+ { ...init, headers: { "content-type": "application/json", ...authHeaders(credential), ...(init.headers ?? {}) } },
75
+ );
76
+ const body = (await res.json().catch(() => ({}))) as { permissions?: string[]; message?: string };
77
+ if (!res.ok) {
78
+ throw new Error(body.message ?? `Could not update app-user permissions (${res.status})`);
79
+ }
80
+ return body.permissions ?? [];
81
+ }
82
+
83
+ /**
84
+ * Set or patch what one of your app's users may do. Returns the resulting list.
85
+ *
86
+ * NEVER TAKE THE PERMISSIONS FROM THE REQUEST BODY UNLESS YOU VERIFIED AN ADMIN
87
+ * FIRST. A user's own token cannot reach this API, so the only way to build
88
+ * self-escalation is to replay their request on your app's credential — which is
89
+ * what the obvious version of the route does:
90
+ *
91
+ * WRONG (any signed-in user just promoted themselves):
92
+ * const { associationId, permissions } = await request.json()
93
+ * await setAppUserPermissions(cfg, { jobToken: ctx.token }, associationId, { set: permissions })
94
+ *
95
+ * RIGHT (a person with admin rights on the app decided):
96
+ * const admin = await verifyAppAdmin(request, cfg)
97
+ * if (!admin) return new Response("forbidden", { status: 403 })
98
+ * await setAppUserPermissions(cfg, { adminToken: request.headers.get("authorization")! },
99
+ * associationId, { set: permissions })
100
+ *
101
+ * RIGHT (your own logic decided, no user request involved):
102
+ * await setAppUserPermissions(cfg, { jobToken: ctx.token }, buyer.associationId, { add: ["premium"] })
103
+ *
104
+ * Prefer `add`/`remove` over `set` in automation — the owner can grant from the
105
+ * dashboard too, and replacing the whole list would undo them. Idempotent, which
106
+ * matters because jobs and event deliveries retry.
107
+ */
108
+ export async function setAppUserPermissions(
109
+ cfg: AppAuthConfig,
110
+ credential: AppUserPermissionCredential,
111
+ associationId: string,
112
+ change: AppUserPermissionChange,
113
+ ): Promise<string[]> {
114
+ return permissionsRequest(cfg, credential, associationId, {
115
+ method: "PUT",
116
+ body: JSON.stringify("set" in change ? { permissions: change.set } : { add: change.add, remove: change.remove }),
117
+ });
118
+ }
119
+
120
+ /**
121
+ * What one of your app's users currently holds.
122
+ *
123
+ * For your ADMIN surface — "who has what". To authorize the CURRENT caller, use
124
+ * `verifyAppUser(request, cfg, { require })` instead: it reads the permissions of
125
+ * whoever is actually making the request, rather than an id the request supplied.
126
+ */
127
+ export async function getAppUserPermissions(
128
+ cfg: AppAuthConfig,
129
+ credential: AppUserPermissionCredential,
130
+ associationId: string,
131
+ ): Promise<string[]> {
132
+ return permissionsRequest(cfg, credential, associationId, { method: "GET" });
133
+ }
@@ -20,6 +20,11 @@ export * from "./jobs";
20
20
  // shared secret. See appAuth.ts.
21
21
  export * from "./appAuth";
22
22
 
23
+ // Permissions your app gives its OWN users — your vocabulary, stored by the
24
+ // platform and never interpreted by it. Server-only, and read the rule at the
25
+ // top of appUsers.ts before wiring a route to it.
26
+ export * from "./appUsers";
27
+
23
28
  import { emptyContentDocument, type ContentDocument } from "../content/types";
24
29
  import type {
25
30
  IEvent,
@@ -263,3 +268,15 @@ export function toPlainText(html?: string, max = 200): string | undefined {
263
268
  if (!text) return undefined;
264
269
  return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
265
270
  }
271
+
272
+ // Platform access + events (docs/app-platform-access-initiative.md). SERVER ONLY —
273
+ // the client carries the app's platform credential.
274
+ export {
275
+ createPlatformClient,
276
+ verifyPlatformEvent,
277
+ PlatformError,
278
+ type PlatformClient,
279
+ type PlatformConfig,
280
+ type PlatformAction,
281
+ type PlatformEvent,
282
+ } from "./platform";
@@ -91,9 +91,30 @@ export interface JobSchedule {
91
91
  export interface JobContext {
92
92
  /** The app-sync token this run was invoked with (forward it to writes). */
93
93
  token: string;
94
+ /**
95
+ * Milliseconds left before the platform stops waiting for this run.
96
+ *
97
+ * CHECK THIS IN ANY LOOP THAT DOES REAL WORK. A run that overruns is cut off
98
+ * mid-flight: the platform never learns the outcome, so it will NOT be retried
99
+ * automatically (retrying might redo work you already committed) and it lands in
100
+ * `deadLetters` for the owner to decide about. Commit what you have and stop
101
+ * while there is still time — see `partial` below.
102
+ */
103
+ remainingMs: () => number;
94
104
  }
95
105
 
96
- export type JobHandler = (payload: unknown, env: unknown, ctx: JobContext) => Promise<void> | void;
106
+ /**
107
+ * What a handler can report back.
108
+ *
109
+ * Return nothing for "done". Return `{ partial: true }` when you committed some
110
+ * work and stopped deliberately — because you were running out of time, or the
111
+ * upstream paged. That is NOT a failure: it will not be retried, and it shows as
112
+ * `partial` rather than as an error on the owner's dashboard. Enqueue the next
113
+ * slice yourself; the cursor lives with you, not with us.
114
+ */
115
+ export type JobResult = void | { partial: true; message?: string };
116
+
117
+ export type JobHandler = (payload: unknown, env: unknown, ctx: JobContext) => Promise<JobResult> | JobResult;
97
118
 
98
119
  export interface AppJobsConfig {
99
120
  /** The app id (VITE_APP_ID). */
@@ -134,7 +155,16 @@ export async function reconcileAppJobs(env: AppJobsEnv, cfg: AppJobsConfig, sche
134
155
  await stub(env, cfg.appId).reconcile(cfg.script ?? "", cfg.appId, schedules, scheduleHash(schedules));
135
156
  }
136
157
 
137
- /** Enqueue a durable one-time job (survives redeploys, retried on failure). */
158
+ /**
159
+ * Enqueue a durable one-time job (survives redeploys, retried on failure).
160
+ *
161
+ * `delayMs` means NOT BEFORE — it is a floor, not a schedule. Queued jobs run one
162
+ * after another, so once several are due they are dispatched back to back and any
163
+ * stagger you gave them is gone: fan out ten with `delayMs: i * 4000` and, if each
164
+ * takes a minute, they run a minute apart, not four seconds. Do NOT use it to pace
165
+ * against a third party's rate limit — handle that inside the handler, or chain the
166
+ * work by enqueueing the next slice when one finishes.
167
+ */
138
168
  export async function enqueueAppJob(
139
169
  env: AppJobsEnv,
140
170
  cfg: AppJobsConfig,
@@ -191,13 +221,27 @@ export async function handleAppJobRun(
191
221
  }
192
222
  if (!valid) return new Response("unauthorized", { status: 401 });
193
223
 
194
- const body = (await request.json().catch(() => ({}))) as { job?: string; payload?: unknown };
224
+ const body = (await request.json().catch(() => ({}))) as {
225
+ job?: string;
226
+ payload?: unknown;
227
+ deadlineAt?: number;
228
+ };
195
229
  const handler = body.job ? jobs[body.job] : undefined;
196
230
  if (!handler) return new Response("unknown job", { status: 404 });
197
231
  try {
198
232
  // Pass the verified token so the handler can write app-owned data back
199
- // (writeAppCollection) without re-minting anything.
200
- await handler(body.payload ?? null, env, { token });
233
+ // (writeAppCollection) without re-minting anything, plus how long it has —
234
+ // so a long job can commit and stop instead of being cut off mid-write.
235
+ const deadlineAt = typeof body.deadlineAt === "number" ? body.deadlineAt : undefined;
236
+ const result = await handler(body.payload ?? null, env, {
237
+ token,
238
+ remainingMs: () => (deadlineAt ? Math.max(0, deadlineAt - Date.now()) : Number.POSITIVE_INFINITY),
239
+ });
240
+ if (result && typeof result === "object" && result.partial) {
241
+ // Reported to the platform as its own outcome so a correctly-behaving
242
+ // resumable job doesn't read as an error.
243
+ return new Response(result.message ?? "partial", { headers: { "x-tn-job-status": "partial" } });
244
+ }
201
245
  return new Response("ok");
202
246
  } catch (err) {
203
247
  // Non-2xx → the DO retries (one-time) / re-fires next interval (recurring).
@@ -245,7 +289,11 @@ export async function writeAppCollection(
245
289
  opts?: {
246
290
  /** Archive every existing row first. Only for a COMPLETE dataset — see above. */
247
291
  replace?: boolean;
248
- /** Field(s) identifying a row, so existing rows are updated instead of duplicated. */
292
+ /**
293
+ * Field(s) identifying a row, so existing rows are updated instead of
294
+ * duplicated. Mark the key field FILTERABLE on the collection — matching is
295
+ * then an indexed lookup; otherwise it still works but scans.
296
+ */
249
297
  upsertKey?: string | string[];
250
298
  },
251
299
  ): Promise<{ created: number; updated: number; archived: number }> {
@@ -322,3 +370,90 @@ export async function aggregateAppCollection(
322
370
  if (!res.ok) throw new Error(`aggregateAppCollection ${slug} failed: ${res.status} ${await res.text().catch(() => "")}`);
323
371
  return res.json();
324
372
  }
373
+
374
+ /**
375
+ * Upload a file and get back a hosted URL, in two steps.
376
+ *
377
+ * The two steps are not a formality. The bytes go to a STAGING key that does not
378
+ * survive: `finalizeAppUpload` moves the file to its real home, and anything
379
+ * never finalized is deleted within the hour. So the URL you can actually use is
380
+ * the one finalize returns — a staged URL will stop working, and the upload will
381
+ * not be counted against the profile's storage.
382
+ *
383
+ * const up = await createAppUpload(ctx, cfg, "cover.png", bytes.byteLength)
384
+ * await fetch(up.uploadUrl, { method: "PUT", headers: up.requiredHeaders, body: bytes })
385
+ * const media = await finalizeAppUpload(ctx, cfg, up.uploadId) // media.url is yours
386
+ *
387
+ * `size` is a hint so an over-quota upload fails before the transfer rather than
388
+ * after it; the size recorded is whatever storage reports once the bytes land.
389
+ * The file name decides the type, and script-capable types (html, svg) are
390
+ * refused.
391
+ */
392
+ export async function createAppUpload(
393
+ ctx: JobContext,
394
+ cfg: AppJobsConfig,
395
+ fileName: string,
396
+ size?: number,
397
+ ): Promise<{
398
+ uploadId: string;
399
+ uploadUrl: string;
400
+ requiredHeaders: Record<string, string>;
401
+ method: "PUT";
402
+ expiresAt: string;
403
+ }> {
404
+ const res = await fetch(`${cfg.apiUrl}/public/apps/${cfg.appId}/uploads`, {
405
+ method: "POST",
406
+ headers: { "content-type": "application/json", "x-app-sync-token": ctx.token },
407
+ body: JSON.stringify({ fileName, size }),
408
+ });
409
+ if (!res.ok) throw new Error(`createAppUpload failed: ${res.status} ${await res.text().catch(() => "")}`);
410
+ return res.json();
411
+ }
412
+
413
+ /**
414
+ * Complete an upload started with {@link createAppUpload} and get the media row.
415
+ *
416
+ * Safe to retry — finalizing twice returns the same media rather than storing the
417
+ * file twice. Takes no size: the platform reads it back from storage.
418
+ */
419
+ export async function finalizeAppUpload(
420
+ ctx: JobContext,
421
+ cfg: AppJobsConfig,
422
+ uploadId: string,
423
+ ): Promise<{ id: string; url: string; size: string; type: string; name: string }> {
424
+ const res = await fetch(`${cfg.apiUrl}/public/apps/${cfg.appId}/uploads/${uploadId}/finalize`, {
425
+ method: "POST",
426
+ headers: { "content-type": "application/json", "x-app-sync-token": ctx.token },
427
+ body: "{}",
428
+ });
429
+ if (!res.ok) throw new Error(`finalizeAppUpload failed: ${res.status} ${await res.text().catch(() => "")}`);
430
+ return res.json();
431
+ }
432
+
433
+ /**
434
+ * Send an email as this app.
435
+ *
436
+ * The From resolves to the app's own sending identity when the owner configured
437
+ * one, otherwise the profile's — so mail is attributed to the creator, not to
438
+ * TribeNest, without the app holding any mail credentials.
439
+ *
440
+ * await sendAppEmail(ctx, cfg, { to: user.email, subject: "Booked", html })
441
+ *
442
+ * Bounded on purpose: at most 50 recipients per call, and a daily per-app
443
+ * recipient budget, because the send is charged to the owner's allocation. For a
444
+ * campaign, use the platform's messaging tools instead — they handle consent and
445
+ * unsubscribes, which this deliberately does not.
446
+ */
447
+ export async function sendAppEmail(
448
+ ctx: JobContext,
449
+ cfg: AppJobsConfig,
450
+ msg: { to: string | string[]; subject: string; html: string; replyTo?: string },
451
+ ): Promise<{ sent: number }> {
452
+ const res = await fetch(`${cfg.apiUrl}/public/apps/${cfg.appId}/emails`, {
453
+ method: "POST",
454
+ headers: { "content-type": "application/json", "x-app-sync-token": ctx.token },
455
+ body: JSON.stringify(msg),
456
+ });
457
+ if (!res.ok) throw new Error(`sendAppEmail failed: ${res.status} ${await res.text().catch(() => "")}`);
458
+ return res.json();
459
+ }