@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.
- package/package.json +1 -1
- package/src/contexts/AppAuthContext.tsx +26 -0
- package/src/contexts/CartContext.tsx +132 -8
- package/src/data/queries/useCheckouts.ts +101 -0
- package/src/data/queries/useCollections.ts +24 -4
- package/src/data/queries/useFinalize.ts +41 -0
- package/src/data/queries/usePageActions.ts +2 -0
- package/src/index.ts +6 -1
- package/src/server/_tests/appUserPermissions.spec.ts +197 -0
- package/src/server/appAuth.ts +37 -2
- package/src/server/appUsers.ts +133 -0
- package/src/server/index.ts +17 -0
- package/src/server/jobs.ts +129 -30
- package/src/server/platform.ts +208 -0
- package/src/types/models.ts +26 -2
- package/src/ui/headless/checkout/useCheckout.ts +56 -9
- package/src/ui/headless/event/useEventCheckout.ts +36 -0
- package/src/ui/headless/funnel/Funnel.tsx +159 -0
- package/src/ui/headless/funnel/funnelSession.spec.ts +108 -0
- package/src/ui/headless/funnel/funnelSession.ts +88 -0
- package/src/ui/headless/funnel/index.ts +3 -0
- package/src/ui/headless/funnel/useFunnelStep.ts +70 -0
- package/src/ui/headless/index.ts +3 -0
- package/src/ui/index.ts +2 -0
- package/src/ui/styled/Addons.tsx +77 -0
- package/src/ui/styled/BundleConfirmation.tsx +161 -0
- package/src/ui/styled/Cart.tsx +66 -9
- package/src/ui/styled/CheckoutConfirmation.tsx +25 -1
- package/src/ui/styled/EventTickets.tsx +52 -7
- package/src/ui/styled/PageActions.tsx +34 -3
- package/src/ui/styled/ProductGrid.tsx +45 -9
- package/src/utils/formatDateTime.ts +25 -0
- 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
|
+
});
|
package/src/server/appAuth.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -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";
|
package/src/server/jobs.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
137
|
-
*
|
|
179
|
+
* Why a job did or didn't run — read `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 {
|
|
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
|
-
|
|
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
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
208
|
-
*
|
|
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
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
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?: {
|
|
225
|
-
|
|
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`
|