@tribe-nest/forge 1.20.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +129 -30
  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";
@@ -49,6 +49,19 @@ export interface AppJobsStatus {
49
49
  queued: Array<{ name: string; runAt: number; attempts: number }>;
50
50
  /** One-time jobs that exhausted their retries and were given up on, newest first. */
51
51
  deadLetters: Array<{ name: string; attempts: number; failedAt: number; error: string | null }>;
52
+ /**
53
+ * Recent runs, newest first — every job, not only the last one. A run showing
54
+ * `status: "running"` with an old `startedAt` means your app never answered and
55
+ * may STILL be working; `durationMs` stays null until a run completes.
56
+ */
57
+ recentRuns: Array<{
58
+ name: string;
59
+ script: string | null;
60
+ startedAt: number;
61
+ durationMs: number | null;
62
+ status: string;
63
+ error: string | null;
64
+ }>;
52
65
  lastDispatchAt: number | null;
53
66
  /** Which job the last delivery attempt was for — all jobs share one error slot. */
54
67
  lastDispatchJob: string | null;
@@ -78,9 +91,30 @@ export interface JobSchedule {
78
91
  export interface JobContext {
79
92
  /** The app-sync token this run was invoked with (forward it to writes). */
80
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;
81
104
  }
82
105
 
83
- 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;
84
118
 
85
119
  export interface AppJobsConfig {
86
120
  /** The app id (VITE_APP_ID). */
@@ -121,7 +155,16 @@ export async function reconcileAppJobs(env: AppJobsEnv, cfg: AppJobsConfig, sche
121
155
  await stub(env, cfg.appId).reconcile(cfg.script ?? "", cfg.appId, schedules, scheduleHash(schedules));
122
156
  }
123
157
 
124
- /** 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
+ */
125
168
  export async function enqueueAppJob(
126
169
  env: AppJobsEnv,
127
170
  cfg: AppJobsConfig,
@@ -133,8 +176,9 @@ export async function enqueueAppJob(
133
176
  }
134
177
 
135
178
  /**
136
- * Inspect the app's job runtime the ONLY window into why a job did or didn't
137
- * run (jobs are fire-and-forget with no logs). Call it from a server route
179
+ * Why a job did or didn't runread `recentRuns` (every run, with duration + outcome) and `deadLetters` (jobs that gave up), NOT `queueDepth`, which reads 0 whether a job succeeded or was never enqueued.
180
+ *
181
+ * The only window into a fire-and-forget runtime with no logs. Call it from a server route
138
182
  * (server-only, needs the JOBS binding) and gate it behind `verifyAppAdmin`:
139
183
  *
140
184
  * export const Route = createFileRoute("/api/tn-jobs/status")({
@@ -177,13 +221,27 @@ export async function handleAppJobRun(
177
221
  }
178
222
  if (!valid) return new Response("unauthorized", { status: 401 });
179
223
 
180
- 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
+ };
181
229
  const handler = body.job ? jobs[body.job] : undefined;
182
230
  if (!handler) return new Response("unknown job", { status: 404 });
183
231
  try {
184
232
  // Pass the verified token so the handler can write app-owned data back
185
- // (writeAppCollection) without re-minting anything.
186
- 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
+ }
187
245
  return new Response("ok");
188
246
  } catch (err) {
189
247
  // Non-2xx → the DO retries (one-time) / re-fires next interval (recurring).
@@ -192,46 +250,87 @@ export async function handleAppJobRun(
192
250
  }
193
251
 
194
252
  /**
195
- * Persist APP-OWNED rows into one of the app's collections from a job handler —
196
- * the sanctioned server-side write path (do NOT invent an endpoint). Authenticated
197
- * by the run's token (from `JobContext`).
253
+ * Write app-owned rows to a collection pass `{ upsertKey: 'yourIdField' }` so re-runs UPDATE instead of duplicating (without it every call inserts; `replace: true` archives everything first and is only for a complete dataset).
254
+ *
255
+ * The sanctioned server-side write path — do NOT invent an endpoint.
256
+ *
257
+ * PICK ONE OF THREE MODES:
258
+ *
259
+ * 1. `upsertKey` — INCREMENTAL, and what you almost always want. Rows are matched
260
+ * on the field(s) you name and updated in place; anything new is inserted.
261
+ * Nothing else is touched, so you can sync just what changed:
198
262
  *
199
- * IMPORTANT this ALWAYS INSERTS (there is no upsert-by-key). A stable title does
200
- * NOT make re-runs idempotent (slugs auto-dedupe to -2/-3…), so re-running WILL
201
- * duplicate rows. For idempotency use `replace: true` (archives every existing
202
- * app-owned row in the collection, then inserts) AND re-pull the FULL dataset each
203
- * run. NEVER combine `replace: true` with an incremental window (e.g.
204
- * updated_at_min / last-7-days) — it archives the rows outside the window too and
205
- * you lose history. (True incremental upsert-by-key is not supported yet.)
263
+ * await writeAppCollection(ctx, cfg, "orders", changed, { upsertKey: "orderId" })
206
264
  *
207
- * Handlers receive `(payload, env, ctx)` pass `ctx` here; `cfg` is your own
208
- * config built from `import.meta.env`.
265
+ * Use several fields when one isn't unique: `{ upsertKey: ["shop", "orderId"] }`.
266
+ * A row missing any key field is inserted rather than matched, so an incomplete
267
+ * record can never collide with another.
209
268
  *
210
- * jobs = {
211
- * "sync-shopify": async (_p, env, ctx) => {
212
- * const orders = await fetchShopify(env);
213
- * await writeAppCollection(ctx, cfg, "shopify-orders",
214
- * orders.map(o => ({ data: { orderId: o.id, total: o.total, utmSource: o.utm } })),
215
- * { replace: true });
216
- * },
217
- * };
269
+ * 2. `replace: true` — archives EVERY existing row, then inserts what you sent.
270
+ * Only correct when the payload is the COMPLETE dataset. Combining it with a
271
+ * time window ("last 7 days") archives everything outside that window and
272
+ * destroys your history. It also grows with your data: a full replace of a
273
+ * large collection is a big write and will eventually time out. Prefer
274
+ * `upsertKey`.
275
+ *
276
+ * 3. Neither — plain insert. Every call adds rows, so re-running duplicates them.
277
+ * Fine for append-only logs, wrong for a sync.
278
+ *
279
+ * A stable title does NOT make re-runs idempotent (slugs auto-dedupe to -2/-3…);
280
+ * only `upsertKey` does.
281
+ *
282
+ * Write at most 1000 rows per call — page larger syncs.
218
283
  */
219
284
  export async function writeAppCollection(
220
285
  ctx: JobContext,
221
286
  cfg: AppJobsConfig,
222
287
  slug: string,
223
288
  entries: Array<{ data: Record<string, unknown>; status?: "draft" | "published" }>,
224
- opts?: { replace?: boolean },
225
- ): Promise<{ created: number }> {
289
+ opts?: {
290
+ /** Archive every existing row first. Only for a COMPLETE dataset — see above. */
291
+ replace?: boolean;
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
+ */
297
+ upsertKey?: string | string[];
298
+ },
299
+ ): Promise<{ created: number; updated: number; archived: number }> {
226
300
  const res = await fetch(`${cfg.apiUrl}/public/apps/${cfg.appId}/collections/${encodeURIComponent(slug)}/entries`, {
227
301
  method: "POST",
228
302
  headers: { "content-type": "application/json", "x-app-sync-token": ctx.token },
229
- body: JSON.stringify({ entries, replace: opts?.replace === true }),
303
+ body: JSON.stringify({ entries, replace: opts?.replace === true, upsertKey: opts?.upsertKey }),
230
304
  });
231
305
  if (!res.ok) throw new Error(`writeAppCollection ${slug} failed: ${res.status} ${await res.text().catch(() => "")}`);
232
306
  return res.json();
233
307
  }
234
308
 
309
+ /**
310
+ * Delete app-owned rows by key — use this instead of `replace: true` when you only need to drop some records (rows are archived, not destroyed).
311
+ *
312
+ * await deleteAppCollection(ctx, cfg, "orders", "orderId", ["1001", "1002"])
313
+ *
314
+ * Rows are archived, not destroyed, so a mistaken sync can be recovered. Use this
315
+ * instead of `replace: true` when you only need to drop a few records.
316
+ */
317
+ export async function deleteAppCollection(
318
+ ctx: JobContext,
319
+ cfg: AppJobsConfig,
320
+ slug: string,
321
+ upsertKey: string | string[],
322
+ keys: string[],
323
+ ): Promise<{ deleted: number }> {
324
+ const res = await fetch(`${cfg.apiUrl}/public/apps/${cfg.appId}/collections/${encodeURIComponent(slug)}/entries`, {
325
+ method: "POST",
326
+ headers: { "content-type": "application/json", "x-app-sync-token": ctx.token },
327
+ body: JSON.stringify({ upsertKey, deleteKeys: keys }),
328
+ });
329
+ if (!res.ok) throw new Error(`deleteAppCollection ${slug} failed: ${res.status} ${await res.text().catch(() => "")}`);
330
+ return res.json();
331
+ }
332
+
333
+
235
334
  /**
236
335
  * Read an app's OWN collection back from a JOB handler (the read counterpart to
237
336
  * writeAppCollection), authenticated by the run's app-sync token. Reads at `app`