@tribe-nest/forge 1.14.0 → 1.16.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/server/appAuth.ts +107 -0
- package/src/server/index.ts +5 -0
- package/src/server/jobs.ts +30 -7
package/package.json
CHANGED
|
@@ -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
|
@@ -21,8 +21,17 @@ interface AppJobsStub {
|
|
|
21
21
|
export interface JobSchedule {
|
|
22
22
|
/** Stable job name — also the key in your job map + the `scheduleId` in the token. */
|
|
23
23
|
name: string;
|
|
24
|
-
/**
|
|
25
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Fixed interval in milliseconds (e.g. 60_000 = every minute). Provide this OR
|
|
26
|
+
* `cron`, not both. Best for "every N minutes/hours"; the first run lands one
|
|
27
|
+
* interval after the deploy.
|
|
28
|
+
*/
|
|
29
|
+
intervalMs?: number;
|
|
30
|
+
// A 5-field UTC cron expression: minute hour day-of-month month day-of-week.
|
|
31
|
+
// e.g. "0 3 * * *" = daily at 03:00 UTC; "0 0,6,12,18 * * *" = every 6 hours;
|
|
32
|
+
// "0 9 * * 1-5" = 09:00 UTC on weekdays. Provide this OR `intervalMs`. Use
|
|
33
|
+
// cron when a run must land at a specific wall-clock time; evaluation is UTC.
|
|
34
|
+
cron?: string;
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
/** Context passed to a job handler — carries the platform token so the handler
|
|
@@ -48,7 +57,11 @@ const stub = (env: AppJobsEnv, appId: string): AppJobsStub => env.JOBS.get(env.J
|
|
|
48
57
|
// A stable fingerprint of the desired schedule set, so the DO skips reconcile
|
|
49
58
|
// when nothing changed (sorted by name → order-independent).
|
|
50
59
|
function scheduleHash(schedules: JobSchedule[]): string {
|
|
51
|
-
return JSON.stringify(
|
|
60
|
+
return JSON.stringify(
|
|
61
|
+
[...schedules]
|
|
62
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
63
|
+
.map((s) => [s.name, s.intervalMs ?? null, s.cron ?? null]),
|
|
64
|
+
);
|
|
52
65
|
}
|
|
53
66
|
|
|
54
67
|
/**
|
|
@@ -73,9 +86,9 @@ export async function enqueueAppJob(
|
|
|
73
86
|
}
|
|
74
87
|
|
|
75
88
|
/**
|
|
76
|
-
* Handle the DO's alarm callback (`POST /
|
|
89
|
+
* Handle the DO's alarm callback (`POST /api/tn-jobs/run`). Verifies the platform
|
|
77
90
|
* token, looks up the named handler in your job map, and runs it with the app's
|
|
78
|
-
* `env` (so `env.SHOPIFY_TOKEN` etc. are available). Wire your `/
|
|
91
|
+
* `env` (so `env.SHOPIFY_TOKEN` etc. are available). Wire your `/api/tn-jobs/run` route
|
|
79
92
|
* to this.
|
|
80
93
|
*/
|
|
81
94
|
export async function handleAppJobRun(
|
|
@@ -115,8 +128,18 @@ export async function handleAppJobRun(
|
|
|
115
128
|
/**
|
|
116
129
|
* Persist APP-OWNED rows into one of the app's collections from a job handler —
|
|
117
130
|
* the sanctioned server-side write path (do NOT invent an endpoint). Authenticated
|
|
118
|
-
* by the run's token (from `JobContext`).
|
|
119
|
-
*
|
|
131
|
+
* by the run's token (from `JobContext`).
|
|
132
|
+
*
|
|
133
|
+
* IMPORTANT — this ALWAYS INSERTS (there is no upsert-by-key). A stable title does
|
|
134
|
+
* NOT make re-runs idempotent (slugs auto-dedupe to -2/-3…), so re-running WILL
|
|
135
|
+
* duplicate rows. For idempotency use `replace: true` (archives every existing
|
|
136
|
+
* app-owned row in the collection, then inserts) AND re-pull the FULL dataset each
|
|
137
|
+
* run. NEVER combine `replace: true` with an incremental window (e.g.
|
|
138
|
+
* updated_at_min / last-7-days) — it archives the rows outside the window too and
|
|
139
|
+
* you lose history. (True incremental upsert-by-key is not supported yet.)
|
|
140
|
+
*
|
|
141
|
+
* Handlers receive `(payload, env, ctx)` — pass `ctx` here; `cfg` is your own
|
|
142
|
+
* config built from `import.meta.env`.
|
|
120
143
|
*
|
|
121
144
|
* jobs = {
|
|
122
145
|
* "sync-shopify": async (_p, env, ctx) => {
|