@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.
- 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 +141 -6
- 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
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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 {
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
+
}
|