@tribe-nest/forge 1.15.0 → 1.18.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/data/queries/useCollections.ts +58 -0
- package/src/server/appAuth.ts +107 -0
- package/src/server/index.ts +5 -0
- package/src/server/jobs.ts +81 -0
package/package.json
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
import { useForge } from "../../provider/ForgeProvider";
|
|
11
11
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
|
12
12
|
import { collectionParamsToQuery, collectionParamsKey, splitCollectionQuery, collectionQueryKey } from "../collectionParams";
|
|
13
|
+
import { APP_ACCESS_TOKEN_KEY } from "../../contexts/AppAuthContext";
|
|
13
14
|
|
|
14
15
|
// Generic data hooks for CMS collections. Collection-agnostic — a lawyers
|
|
15
16
|
// directory and a recipe index use the same three hooks. The bespoke rendering
|
|
@@ -119,6 +120,63 @@ export function useCollectionAggregate(
|
|
|
119
120
|
});
|
|
120
121
|
}
|
|
121
122
|
|
|
123
|
+
// Prefer the app-user token (useAppAuth) when the viewer is an app user;
|
|
124
|
+
// otherwise fall back to the Forge client's own token (an app-admin's TribeNest
|
|
125
|
+
// session). Both are accepted by the app read endpoint. Returns a per-request
|
|
126
|
+
// Axios config that overrides Authorization, or undefined to use the default.
|
|
127
|
+
function appAuthConfig(): { headers: Record<string, string> } | undefined {
|
|
128
|
+
const t = typeof localStorage !== "undefined" ? localStorage.getItem(APP_ACCESS_TOKEN_KEY) : null;
|
|
129
|
+
return t ? { headers: { Authorization: `Bearer ${t}` } } : undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Read an APP's OWN collection at `app` scope from the browser (a dashboard) —
|
|
134
|
+
* ALL rows, including `private`/unpublished ones. Authenticated as the logged-in
|
|
135
|
+
* app user (useAppAuth) or app admin. Use THIS (not useCollectionQuery) for
|
|
136
|
+
* app-owned/business data so the collection can stay `visibility: 'private'` and
|
|
137
|
+
* never be world-readable via the profile's public API.
|
|
138
|
+
*/
|
|
139
|
+
export function useAppCollectionQuery(
|
|
140
|
+
slug?: string,
|
|
141
|
+
query?: CollectionQuery,
|
|
142
|
+
options?: { enabled?: boolean; initialData?: CollectionSearchResult },
|
|
143
|
+
) {
|
|
144
|
+
const { client, appId } = useForge();
|
|
145
|
+
const { query: body } = splitCollectionQuery(query);
|
|
146
|
+
|
|
147
|
+
return useQuery<CollectionSearchResult>({
|
|
148
|
+
queryKey: ["app-collection-query", appId, slug, collectionQueryKey(query)],
|
|
149
|
+
queryFn: async () => {
|
|
150
|
+
const res = await client.post(`/public/apps/${appId}/collections/${slug}/query`, { query: body }, appAuthConfig());
|
|
151
|
+
return res.data;
|
|
152
|
+
},
|
|
153
|
+
enabled: (options?.enabled ?? true) && !!appId && !!client && !!slug,
|
|
154
|
+
initialData: options?.initialData,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Aggregate an APP's OWN collection from the browser (see useAppCollectionQuery). */
|
|
159
|
+
export function useAppCollectionAggregate(
|
|
160
|
+
slug?: string,
|
|
161
|
+
aggregate?: CollectionAggregate,
|
|
162
|
+
opts?: { where?: CollectionQuery["where"]; search?: string; enabled?: boolean },
|
|
163
|
+
) {
|
|
164
|
+
const { client, appId } = useForge();
|
|
165
|
+
|
|
166
|
+
return useQuery<CollectionAggregateBucket[]>({
|
|
167
|
+
queryKey: ["app-collection-aggregate", appId, slug, JSON.stringify({ aggregate, ...opts })],
|
|
168
|
+
queryFn: async () => {
|
|
169
|
+
const res = await client.post(
|
|
170
|
+
`/public/apps/${appId}/collections/${slug}/aggregate`,
|
|
171
|
+
{ where: opts?.where, search: opts?.search, aggregate },
|
|
172
|
+
appAuthConfig(),
|
|
173
|
+
);
|
|
174
|
+
return res.data.data;
|
|
175
|
+
},
|
|
176
|
+
enabled: (opts?.enabled ?? true) && !!appId && !!client && !!slug && !!aggregate,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
122
180
|
export type SubmitCollectionEntryInput = {
|
|
123
181
|
data: Record<string, unknown>;
|
|
124
182
|
/** For anonymous submissions, when the collection requires them. */
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Authenticate the CALLER of one of your app's OWN server routes (TanStack
|
|
2
|
+
// server handlers, e.g. a "run this job now" or "save settings" endpoint).
|
|
3
|
+
// SERVER-ONLY.
|
|
4
|
+
//
|
|
5
|
+
// Why this exists: `useAppAuth` / `useAppAdminGuard` are React hooks — they run
|
|
6
|
+
// in the browser and CANNOT gate a server route. Without a server-side check, a
|
|
7
|
+
// privileged route is either wide open or bolted shut behind a shared secret.
|
|
8
|
+
// Neither is right. Instead: the client already holds the caller's token (the
|
|
9
|
+
// app-user token, or the admin's TribeNest account token); forward it to your
|
|
10
|
+
// server route in the `Authorization` header, and verify it HERE. The app Worker
|
|
11
|
+
// is only a CLIENT — TribeNest is the sole authority on identity + access, so
|
|
12
|
+
// these helpers forward the token to the platform and trust its answer.
|
|
13
|
+
//
|
|
14
|
+
// NEVER gate a privileged app route with a hardcoded/shared secret. Verify the
|
|
15
|
+
// actual person making the request:
|
|
16
|
+
// - APP ADMIN → `verifyAppAdmin`: the owner or a team member with a Work grant
|
|
17
|
+
// on the app (operator actions: trigger a sync, edit config, rebuild data).
|
|
18
|
+
// - APP USER → `verifyAppUser`: an app-scoped account (`useAppAuth`); scope
|
|
19
|
+
// end-user actions to that user's own data.
|
|
20
|
+
|
|
21
|
+
export interface AppAuthConfig {
|
|
22
|
+
/** The app id (VITE_APP_ID). `jobsConfig` from src/lib/jobs.ts satisfies this. */
|
|
23
|
+
appId: string;
|
|
24
|
+
/** The TribeNest public API base (VITE_API_URL). */
|
|
25
|
+
apiUrl: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AppAdminIdentity {
|
|
29
|
+
isAdmin: true;
|
|
30
|
+
/** Highest permission the account holds on the app (view/comment/edit/manage). */
|
|
31
|
+
permission: string | null;
|
|
32
|
+
appName: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AppUserIdentity {
|
|
36
|
+
id: string;
|
|
37
|
+
associationId: string;
|
|
38
|
+
email: string;
|
|
39
|
+
firstName: string | null;
|
|
40
|
+
lastName: string | null;
|
|
41
|
+
status: string;
|
|
42
|
+
appId: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The bearer credential the client forwarded, or null. We pass it through as-is
|
|
46
|
+
// (the platform verifies it) — never parse or trust its contents here.
|
|
47
|
+
function bearer(request: Request): string | null {
|
|
48
|
+
const h = request.headers.get("authorization");
|
|
49
|
+
return h && /^Bearer\s+\S/i.test(h) ? h : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Verify the request comes from an APP ADMIN — the owner or a team member with a
|
|
54
|
+
* Work grant on the app. Forwards the caller's TribeNest account token to
|
|
55
|
+
* `GET /public/apps/:appId/admin-access`. Returns the admin identity, or `null`
|
|
56
|
+
* when there's no token / it's invalid / the account isn't an admin.
|
|
57
|
+
*
|
|
58
|
+
* export const Route = createFileRoute("/api/tn-jobs/trigger")({
|
|
59
|
+
* server: { handlers: { POST: async ({ request }) => {
|
|
60
|
+
* const admin = await verifyAppAdmin(request, jobsConfig);
|
|
61
|
+
* if (!admin) return new Response("forbidden", { status: 403 });
|
|
62
|
+
* const { job, payload } = await request.json();
|
|
63
|
+
* const { env } = await import("cloudflare:workers");
|
|
64
|
+
* await enqueueAppJob(env, jobsConfig, job, payload);
|
|
65
|
+
* return Response.json({ ok: true });
|
|
66
|
+
* } } },
|
|
67
|
+
* });
|
|
68
|
+
*
|
|
69
|
+
* The dashboard/admin UI calls this route with the admin's token in the
|
|
70
|
+
* `Authorization` header (the same token `usePublicAuth` holds) — so the button
|
|
71
|
+
* is authenticated as the person clicking it, no shared secret.
|
|
72
|
+
*/
|
|
73
|
+
export async function verifyAppAdmin(request: Request, cfg: AppAuthConfig): Promise<AppAdminIdentity | null> {
|
|
74
|
+
const auth = bearer(request);
|
|
75
|
+
if (!auth) return null;
|
|
76
|
+
try {
|
|
77
|
+
const res = await fetch(`${cfg.apiUrl}/public/apps/${cfg.appId}/admin-access`, {
|
|
78
|
+
headers: { authorization: auth },
|
|
79
|
+
});
|
|
80
|
+
if (!res.ok) return null;
|
|
81
|
+
const data = (await res.json()) as { isAdmin?: boolean; permission?: string | null; appName?: string | null };
|
|
82
|
+
return data.isAdmin ? { isAdmin: true, permission: data.permission ?? null, appName: data.appName ?? null } : null;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Verify the request comes from a logged-in APP USER (`useAppAuth` account).
|
|
90
|
+
* Forwards the app-user token to `GET /public/app-sessions/me`. Returns the
|
|
91
|
+
* user, or `null` when unauthenticated. Use to scope an end-user server action
|
|
92
|
+
* to that user (e.g. read/write only their own rows) — the returned
|
|
93
|
+
* `associationId` is the owner id the platform enforces on `mine`-scoped data.
|
|
94
|
+
*/
|
|
95
|
+
export async function verifyAppUser(request: Request, cfg: AppAuthConfig): Promise<AppUserIdentity | null> {
|
|
96
|
+
const auth = bearer(request);
|
|
97
|
+
if (!auth) return null;
|
|
98
|
+
try {
|
|
99
|
+
const res = await fetch(`${cfg.apiUrl}/public/app-sessions/me`, {
|
|
100
|
+
headers: { authorization: auth },
|
|
101
|
+
});
|
|
102
|
+
if (!res.ok) return null;
|
|
103
|
+
return (await res.json()) as AppUserIdentity;
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
// App background-job runtime (no-config wrapper over the JobsDO). Server-only.
|
|
16
16
|
export * from "./jobs";
|
|
17
17
|
|
|
18
|
+
// Authenticate the caller of an app's own server routes (admin / app-user).
|
|
19
|
+
// Server-only — the sanctioned alternative to gating a privileged route with a
|
|
20
|
+
// shared secret. See appAuth.ts.
|
|
21
|
+
export * from "./appAuth";
|
|
22
|
+
|
|
18
23
|
import { emptyContentDocument, type ContentDocument } from "../content/types";
|
|
19
24
|
import type {
|
|
20
25
|
IEvent,
|
package/src/server/jobs.ts
CHANGED
|
@@ -16,6 +16,28 @@ export interface AppJobsEnv {
|
|
|
16
16
|
interface AppJobsStub {
|
|
17
17
|
reconcile: (script: string, appId: string, desired: JobSchedule[], hash: string) => Promise<{ changed: boolean }>;
|
|
18
18
|
enqueue: (script: string, appId: string, name: string, payload: unknown, delayMs: number) => Promise<void>;
|
|
19
|
+
status: () => Promise<AppJobsStatus>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A snapshot of the app's job runtime — what's scheduled, how deep the queue is,
|
|
24
|
+
* and what happened on the LAST delivery attempt. Because jobs fail silently (no
|
|
25
|
+
* logs / dead-letter), this is how you SEE why one didn't run:
|
|
26
|
+
* - `lastDispatchAt` null while `alarmAt` is in the past → the alarm isn't firing;
|
|
27
|
+
* - `lastError` starting "dispatch threw:" → the callback never reached your app;
|
|
28
|
+
* - `lastDispatchStatus` "401" → your app was reached but rejected the token
|
|
29
|
+
* (the dispatch-worker's APP_SYNC_SECRET ≠ the backend JWT_SECRET).
|
|
30
|
+
*/
|
|
31
|
+
export interface AppJobsStatus {
|
|
32
|
+
appId: string | null;
|
|
33
|
+
now: number;
|
|
34
|
+
alarmAt: number | null;
|
|
35
|
+
schedules: Array<{ name: string; intervalMs: number | null; cron: string | null; nextRun: number }>;
|
|
36
|
+
queueDepth: number;
|
|
37
|
+
lastDispatchAt: number | null;
|
|
38
|
+
lastDispatchScript: string | null;
|
|
39
|
+
lastDispatchStatus: string | null;
|
|
40
|
+
lastError: string | null;
|
|
19
41
|
}
|
|
20
42
|
|
|
21
43
|
export interface JobSchedule {
|
|
@@ -85,6 +107,25 @@ export async function enqueueAppJob(
|
|
|
85
107
|
await stub(env, cfg.appId).enqueue(cfg.script, cfg.appId, name, payload ?? null, delayMs);
|
|
86
108
|
}
|
|
87
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Inspect the app's job runtime — the ONLY window into why a job did or didn't
|
|
112
|
+
* run (jobs are fire-and-forget with no logs). Call it from a server route
|
|
113
|
+
* (server-only, needs the JOBS binding) and gate it behind `verifyAppAdmin`:
|
|
114
|
+
*
|
|
115
|
+
* export const Route = createFileRoute("/api/tn-jobs/status")({
|
|
116
|
+
* server: { handlers: { GET: async ({ request }) => {
|
|
117
|
+
* if (!(await verifyAppAdmin(request, jobsConfig))) return new Response("forbidden", { status: 403 });
|
|
118
|
+
* const { env } = await import("cloudflare:workers");
|
|
119
|
+
* return Response.json(await appJobsStatus(env, jobsConfig));
|
|
120
|
+
* } } },
|
|
121
|
+
* });
|
|
122
|
+
*
|
|
123
|
+
* See {@link AppJobsStatus} for how to read the result.
|
|
124
|
+
*/
|
|
125
|
+
export async function appJobsStatus(env: AppJobsEnv, cfg: AppJobsConfig): Promise<AppJobsStatus> {
|
|
126
|
+
return stub(env, cfg.appId).status();
|
|
127
|
+
}
|
|
128
|
+
|
|
88
129
|
/**
|
|
89
130
|
* Handle the DO's alarm callback (`POST /api/tn-jobs/run`). Verifies the platform
|
|
90
131
|
* token, looks up the named handler in your job map, and runs it with the app's
|
|
@@ -165,3 +206,43 @@ export async function writeAppCollection(
|
|
|
165
206
|
if (!res.ok) throw new Error(`writeAppCollection ${slug} failed: ${res.status} ${await res.text().catch(() => "")}`);
|
|
166
207
|
return res.json();
|
|
167
208
|
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Read an app's OWN collection back from a JOB handler (the read counterpart to
|
|
212
|
+
* writeAppCollection), authenticated by the run's app-sync token. Reads at `app`
|
|
213
|
+
* scope — ALL rows, including `private`/unpublished ones — so the collection can
|
|
214
|
+
* stay `visibility: 'private'` (never world-readable) yet the app still reads it.
|
|
215
|
+
* `query` is the query-engine DSL ({ where?, sort?, select?, page?, limit?, … }).
|
|
216
|
+
* For reading app-owned data in the browser (a dashboard) use the client hook
|
|
217
|
+
* `useAppCollectionQuery` instead — this one needs the job token.
|
|
218
|
+
*/
|
|
219
|
+
export async function queryAppCollection(
|
|
220
|
+
ctx: JobContext,
|
|
221
|
+
cfg: AppJobsConfig,
|
|
222
|
+
slug: string,
|
|
223
|
+
query?: Record<string, unknown>,
|
|
224
|
+
): Promise<{ data: unknown[]; total: number; [k: string]: unknown }> {
|
|
225
|
+
const res = await fetch(`${cfg.apiUrl}/public/apps/${cfg.appId}/collections/${encodeURIComponent(slug)}/query`, {
|
|
226
|
+
method: "POST",
|
|
227
|
+
headers: { "content-type": "application/json", "x-app-sync-token": ctx.token },
|
|
228
|
+
body: JSON.stringify({ query: query ?? {} }),
|
|
229
|
+
});
|
|
230
|
+
if (!res.ok) throw new Error(`queryAppCollection ${slug} failed: ${res.status} ${await res.text().catch(() => "")}`);
|
|
231
|
+
return res.json();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Aggregate over an app's OWN collection from a job handler (see queryAppCollection). */
|
|
235
|
+
export async function aggregateAppCollection(
|
|
236
|
+
ctx: JobContext,
|
|
237
|
+
cfg: AppJobsConfig,
|
|
238
|
+
slug: string,
|
|
239
|
+
input: { aggregate: unknown; where?: unknown; search?: string },
|
|
240
|
+
): Promise<unknown> {
|
|
241
|
+
const res = await fetch(`${cfg.apiUrl}/public/apps/${cfg.appId}/collections/${encodeURIComponent(slug)}/aggregate`, {
|
|
242
|
+
method: "POST",
|
|
243
|
+
headers: { "content-type": "application/json", "x-app-sync-token": ctx.token },
|
|
244
|
+
body: JSON.stringify(input),
|
|
245
|
+
});
|
|
246
|
+
if (!res.ok) throw new Error(`aggregateAppCollection ${slug} failed: ${res.status} ${await res.text().catch(() => "")}`);
|
|
247
|
+
return res.json();
|
|
248
|
+
}
|