@tribe-nest/forge 2.2.0 → 3.4.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/client/createForgeClient.ts +85 -2
- package/src/client/tokenStorage.ts +31 -0
- package/src/contexts/AppAuthContext.tsx +100 -15
- package/src/contexts/PublicAuthContext.tsx +46 -9
- package/src/data/queries/useCheckouts.ts +84 -1
- package/src/data/queries/useCoachingAvailability.ts +18 -3
- package/src/data/queries/useCourseAccess.ts +1 -1
- package/src/data/queries/useCourses.ts +42 -1
- package/src/data/queries/useEvents.ts +15 -1
- package/src/data/queries/usePaymentFlow.ts +12 -0
- package/src/data/queries/useWebsite.ts +6 -0
- package/src/index.ts +19 -2
- package/src/provider/ForgeProvider.tsx +30 -1
- package/src/server/_tests/platformEvents.spec.ts +315 -0
- package/src/server/index.ts +17 -0
- package/src/server/jobs.ts +41 -10
- package/src/server/platform.ts +234 -9
- package/src/server/platformEvents.generated.ts +422 -0
- package/src/types/models.ts +110 -3
- package/src/ui/headless/auth/useSignupForm.ts +69 -4
- package/src/ui/headless/calendar/useAddToCalendar.ts +194 -0
- package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +169 -0
- package/src/ui/headless/checkout/bundleCoupon.ts +96 -0
- package/src/ui/headless/checkout/useCheckout.ts +156 -8
- package/src/ui/headless/coaching/useCoachingBooking.ts +53 -3
- package/src/ui/headless/coupon/_tests/couponFailureMessage.spec.ts +84 -0
- package/src/ui/headless/coupon/useCouponField.ts +164 -0
- package/src/ui/headless/course/useCourseCheckout.ts +113 -18
- package/src/ui/headless/event/useEventCheckout.ts +53 -2
- package/src/ui/headless/index.ts +15 -0
- package/src/ui/headless/work/useWorkPortal.ts +24 -21
- package/src/ui/index.ts +7 -0
- package/src/ui/shell/PoweredBy.tsx +60 -0
- package/src/ui/shell/TribeNestApp.tsx +15 -1
- package/src/ui/shell/shellGating.spec.ts +21 -1
- package/src/ui/shell/shellGating.ts +14 -0
- package/src/ui/styled/AddToCalendar.tsx +104 -0
- package/src/ui/styled/Checkout.tsx +45 -14
- package/src/ui/styled/CoachingBooking.tsx +28 -8
- package/src/ui/styled/CoachingConfirmation.tsx +12 -0
- package/src/ui/styled/CourseCheckout.tsx +49 -18
- package/src/ui/styled/DiscountCode.tsx +206 -0
- package/src/ui/styled/EventConfirmation.tsx +68 -22
- package/src/ui/styled/EventDetail.tsx +18 -5
- package/src/ui/styled/EventTickets.tsx +49 -5
- package/src/ui/styled/SignupForm.tsx +86 -35
- package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
- package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
- package/src/ui/styled/work/WorkInviteAccept.tsx +54 -5
- package/src/utils/_tests/safeRedirect.spec.ts +117 -0
- package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
- package/src/utils/safeRedirect.ts +41 -0
- package/src/utils/ticketOrderOutcome.ts +125 -0
package/src/server/platform.ts
CHANGED
|
@@ -16,11 +16,26 @@
|
|
|
16
16
|
* first, then use it for the part that genuinely needs owner-level access.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import type {
|
|
20
|
+
PlatformEventName,
|
|
21
|
+
PlatformEventData,
|
|
22
|
+
} from "./platformEvents.generated";
|
|
23
|
+
|
|
19
24
|
export type PlatformConfig = {
|
|
20
25
|
/** The TribeNest public API base (VITE_API_URL). */
|
|
21
26
|
apiUrl: string;
|
|
22
27
|
/** The app's platform token, injected as a server-only Worker binding. */
|
|
23
28
|
token: string;
|
|
29
|
+
/**
|
|
30
|
+
* The event being handled, when this client is used inside an event handler.
|
|
31
|
+
*
|
|
32
|
+
* Set for you by `handlePlatformEvent` — you should not need to pass it. Its
|
|
33
|
+
* only job is to make every write idempotent without you deriving a key: the
|
|
34
|
+
* key becomes a hash of this event, the action and the input, so the same
|
|
35
|
+
* write attempted again during a redelivery returns the first result instead of
|
|
36
|
+
* acting twice. See `run()`.
|
|
37
|
+
*/
|
|
38
|
+
eventId?: string;
|
|
24
39
|
};
|
|
25
40
|
|
|
26
41
|
export type PlatformAction = {
|
|
@@ -105,19 +120,26 @@ export function createPlatformClient(config: PlatformConfig) {
|
|
|
105
120
|
/**
|
|
106
121
|
* Run an action.
|
|
107
122
|
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
123
|
+
* INSIDE AN EVENT HANDLER you do not need an idempotency key — one is derived
|
|
124
|
+
* from the event, the action and the input, so a redelivered event cannot
|
|
125
|
+
* make the same write twice. That is the whole reason handlers can be
|
|
126
|
+
* retried safely; you get it by doing nothing.
|
|
127
|
+
*
|
|
128
|
+
* ANYWHERE ELSE a write REQUIRES `idempotencyKey`, derived from your own
|
|
129
|
+
* record id. Jobs retry, so a write without one duplicates.
|
|
130
|
+
*
|
|
131
|
+
* Pass an explicit key to override the derived one — only needed in the rare
|
|
132
|
+
* case where one handler genuinely has to make the SAME write twice.
|
|
112
133
|
*/
|
|
113
134
|
async run<T = unknown>(
|
|
114
135
|
id: string,
|
|
115
136
|
input: unknown,
|
|
116
137
|
opts: { idempotencyKey?: string; dryRun?: boolean } = {},
|
|
117
138
|
): Promise<T> {
|
|
139
|
+
const key = opts.idempotencyKey ?? (config.eventId ? await derivedKey(config.eventId, id, input) : undefined);
|
|
118
140
|
const body = await request(`/actions/${encodeURIComponent(id)}`, {
|
|
119
141
|
method: "POST",
|
|
120
|
-
headers:
|
|
142
|
+
headers: key ? { "idempotency-key": key } : {},
|
|
121
143
|
body: JSON.stringify({ input, ...(opts.dryRun ? { dryRun: true } : {}) }),
|
|
122
144
|
});
|
|
123
145
|
return body.result as T;
|
|
@@ -138,6 +160,44 @@ export function createPlatformClient(config: PlatformConfig) {
|
|
|
138
160
|
|
|
139
161
|
export type PlatformClient = ReturnType<typeof createPlatformClient>;
|
|
140
162
|
|
|
163
|
+
/**
|
|
164
|
+
* The idempotency key for a write made while handling an event.
|
|
165
|
+
*
|
|
166
|
+
* Built from the event, the action and the input — NOT from a call counter,
|
|
167
|
+
* which would shift the moment a handler looped or branched differently and
|
|
168
|
+
* stop matching on the retry. Two genuinely different writes hash differently;
|
|
169
|
+
* the same write attempted again hashes the same and is refused a second run.
|
|
170
|
+
*/
|
|
171
|
+
async function derivedKey(eventId: string, actionId: string, input: unknown): Promise<string> {
|
|
172
|
+
const digest = await sha256Hex(`${eventId}:${actionId}:${canonicalJson(input)}`);
|
|
173
|
+
// The column is varchar(200); a prefix is unique enough and keeps the audit
|
|
174
|
+
// trail readable, with the event id up front so a human can see where it came from.
|
|
175
|
+
return `evt_${eventId}_${digest.slice(0, 32)}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* JSON with object keys sorted, so `{a,b}` and `{b,a}` hash alike.
|
|
180
|
+
*
|
|
181
|
+
* Key order is not stable across runs — an unsorted stringify would produce a
|
|
182
|
+
* different key for the same write and quietly reintroduce the duplicate this
|
|
183
|
+
* exists to prevent.
|
|
184
|
+
*/
|
|
185
|
+
function canonicalJson(value: unknown): string {
|
|
186
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
187
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
188
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
189
|
+
.filter(([, v]) => v !== undefined)
|
|
190
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
191
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function sha256Hex(input: string): Promise<string> {
|
|
195
|
+
const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
|
|
196
|
+
return Array.from(new Uint8Array(bytes))
|
|
197
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
198
|
+
.join("");
|
|
199
|
+
}
|
|
200
|
+
|
|
141
201
|
// --------------------------------------------------------------------- events
|
|
142
202
|
|
|
143
203
|
export type PlatformEvent<T = Record<string, unknown>> = {
|
|
@@ -146,10 +206,29 @@ export type PlatformEvent<T = Record<string, unknown>> = {
|
|
|
146
206
|
eventId: string;
|
|
147
207
|
appId: string;
|
|
148
208
|
profileId: string;
|
|
209
|
+
/** When it HAPPENED — not when it was delivered. Stable across a redelivery. */
|
|
149
210
|
occurredAt: string;
|
|
150
211
|
data: T;
|
|
151
212
|
};
|
|
152
213
|
|
|
214
|
+
/** One named event, with its body typed from the catalog. */
|
|
215
|
+
export type TypedPlatformEvent<E extends PlatformEventName = PlatformEventName> = Omit<
|
|
216
|
+
PlatformEvent,
|
|
217
|
+
"event" | "data"
|
|
218
|
+
> & {
|
|
219
|
+
event: E;
|
|
220
|
+
data: PlatformEventData<E>;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* How long after signing a delivery is still accepted.
|
|
225
|
+
*
|
|
226
|
+
* Without a window a delivery captured off the wire could be pushed at the app
|
|
227
|
+
* for as long as the signing key lived. Generous enough to survive clock skew
|
|
228
|
+
* and a slow retry, short enough that a captured body stops being useful.
|
|
229
|
+
*/
|
|
230
|
+
export const SIGNATURE_TOLERANCE_SECONDS = 300;
|
|
231
|
+
|
|
153
232
|
/**
|
|
154
233
|
* Verify and parse a platform event delivered to your app.
|
|
155
234
|
*
|
|
@@ -173,17 +252,163 @@ export async function verifyPlatformEvent<T = Record<string, unknown>>(
|
|
|
173
252
|
request: Request,
|
|
174
253
|
secret: string,
|
|
175
254
|
): Promise<PlatformEvent<T>> {
|
|
176
|
-
const signature = request.headers.get("x-tribenest-signature");
|
|
177
|
-
if (!signature) throw new Error("Missing platform event signature.");
|
|
178
|
-
|
|
179
255
|
const body = await request.text();
|
|
256
|
+
|
|
257
|
+
// Preferred: signed over a timestamp as well as the body, so a captured
|
|
258
|
+
// delivery cannot be pushed at the app days later.
|
|
259
|
+
const v2 = request.headers.get("x-tribenest-signature-v2");
|
|
260
|
+
if (v2) {
|
|
261
|
+
const parts = Object.fromEntries(
|
|
262
|
+
v2.split(",").map((p) => {
|
|
263
|
+
const i = p.indexOf("=");
|
|
264
|
+
return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
|
|
265
|
+
}),
|
|
266
|
+
);
|
|
267
|
+
const timestamp = Number(parts.t);
|
|
268
|
+
if (!Number.isFinite(timestamp) || !parts.v1) {
|
|
269
|
+
throw new Error("Platform event signature is malformed.");
|
|
270
|
+
}
|
|
271
|
+
const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
|
|
272
|
+
if (age > SIGNATURE_TOLERANCE_SECONDS) {
|
|
273
|
+
throw new Error("Platform event signature is outside the accepted time window.");
|
|
274
|
+
}
|
|
275
|
+
const expected = await hmacHex(secret, `${timestamp}.${body}`);
|
|
276
|
+
if (!timingSafeEqual(parts.v1, expected)) {
|
|
277
|
+
throw new Error("Platform event signature did not verify.");
|
|
278
|
+
}
|
|
279
|
+
return JSON.parse(body) as PlatformEvent<T>;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Body-only signature. Still accepted so a site running an older Forge keeps
|
|
283
|
+
// receiving events through the rollout; the platform sends both headers.
|
|
284
|
+
const legacy = request.headers.get("x-tribenest-signature");
|
|
285
|
+
if (!legacy) throw new Error("Missing platform event signature.");
|
|
180
286
|
const expected = await hmacHex(secret, body);
|
|
181
|
-
if (!timingSafeEqual(
|
|
287
|
+
if (!timingSafeEqual(legacy, expected)) {
|
|
182
288
|
throw new Error("Platform event signature did not verify.");
|
|
183
289
|
}
|
|
184
290
|
return JSON.parse(body) as PlatformEvent<T>;
|
|
185
291
|
}
|
|
186
292
|
|
|
293
|
+
/**
|
|
294
|
+
* A map from event name to what your app does about it.
|
|
295
|
+
*
|
|
296
|
+
* `event.data` is typed from the event's name, so `handlers["order.paid"]` gets
|
|
297
|
+
* the order, its lines and the buyer without a cast.
|
|
298
|
+
*/
|
|
299
|
+
export type PlatformEventHandlers = {
|
|
300
|
+
[E in PlatformEventName]?: (
|
|
301
|
+
event: TypedPlatformEvent<E>,
|
|
302
|
+
ctx: { platform: PlatformClient },
|
|
303
|
+
) => Promise<void> | void;
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
export type HandlePlatformEventOptions = {
|
|
307
|
+
secret: string;
|
|
308
|
+
handlers: PlatformEventHandlers;
|
|
309
|
+
/** For the platform client handed to your handler. */
|
|
310
|
+
apiUrl: string;
|
|
311
|
+
token?: string;
|
|
312
|
+
/**
|
|
313
|
+
* How long a handler may run before the platform's own delivery attempt times
|
|
314
|
+
* out. Overrunning is reported as an error the agent can act on rather than an
|
|
315
|
+
* opaque timeout.
|
|
316
|
+
*/
|
|
317
|
+
budgetMs?: number;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
/** Handler budget. Under the platform's own 15s delivery timeout, deliberately. */
|
|
321
|
+
export const HANDLER_BUDGET_MS = 10_000;
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Verify a delivery, run the matching handler, and answer.
|
|
325
|
+
*
|
|
326
|
+
* THE RULE THIS ENFORCES: a 200 means the work is done. Your handler never
|
|
327
|
+
* writes the response — this does, and only after your handler's promise has
|
|
328
|
+
* settled. Once the platform has a 200 it will never send that event again, so
|
|
329
|
+
* answering early would lose it.
|
|
330
|
+
*
|
|
331
|
+
* Which means: do not fire a promise you don't await, and don't reach for
|
|
332
|
+
* `waitUntil`. If the work is slow, `enqueueAppJob` it and return — the job
|
|
333
|
+
* runtime has its own retries, and a 200 then honestly means "durably queued".
|
|
334
|
+
*
|
|
335
|
+
* An unknown event answers 200 and does nothing, so events you have not written
|
|
336
|
+
* a handler for are not retried forever.
|
|
337
|
+
*/
|
|
338
|
+
export async function handlePlatformEvent(
|
|
339
|
+
request: Request,
|
|
340
|
+
options: HandlePlatformEventOptions,
|
|
341
|
+
): Promise<Response> {
|
|
342
|
+
let event: PlatformEvent;
|
|
343
|
+
try {
|
|
344
|
+
event = await verifyPlatformEvent(request, options.secret);
|
|
345
|
+
} catch (err) {
|
|
346
|
+
// An unverified body is untrusted input from the open internet. Refuse it
|
|
347
|
+
// without saying which check failed.
|
|
348
|
+
return new Response(JSON.stringify({ error: (err as Error).message }), {
|
|
349
|
+
status: 401,
|
|
350
|
+
headers: { "content-type": "application/json" },
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const handler = options.handlers[event.event as PlatformEventName];
|
|
355
|
+
if (!handler) {
|
|
356
|
+
return new Response(JSON.stringify({ ok: true, ignored: event.event }), {
|
|
357
|
+
status: 200,
|
|
358
|
+
headers: { "content-type": "application/json" },
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const platform = createPlatformClient({
|
|
363
|
+
apiUrl: options.apiUrl,
|
|
364
|
+
token: options.token ?? "",
|
|
365
|
+
// This is what makes every write in the handler idempotent for free.
|
|
366
|
+
eventId: event.eventId,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const budget = options.budgetMs ?? HANDLER_BUDGET_MS;
|
|
370
|
+
try {
|
|
371
|
+
await withTimeout(
|
|
372
|
+
Promise.resolve(
|
|
373
|
+
(handler as (e: PlatformEvent, c: { platform: PlatformClient }) => Promise<void> | void)(event, {
|
|
374
|
+
platform,
|
|
375
|
+
}),
|
|
376
|
+
),
|
|
377
|
+
budget,
|
|
378
|
+
`Handler for "${event.event}" took longer than ${budget}ms. Move the slow part into ` +
|
|
379
|
+
`enqueueAppJob() and return — the platform's delivery attempt times out before this finishes.`,
|
|
380
|
+
);
|
|
381
|
+
} catch (err) {
|
|
382
|
+
// 500 so the platform retries. Writes already made carry keys derived from
|
|
383
|
+
// this same event, so the retry repeats none of them.
|
|
384
|
+
return new Response(JSON.stringify({ error: (err as Error).message, eventId: event.eventId }), {
|
|
385
|
+
status: 500,
|
|
386
|
+
headers: { "content-type": "application/json" },
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return new Response(JSON.stringify({ ok: true, eventId: event.eventId }), {
|
|
391
|
+
status: 200,
|
|
392
|
+
headers: { "content-type": "application/json" },
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
|
397
|
+
return new Promise((resolve, reject) => {
|
|
398
|
+
const timer = setTimeout(() => reject(new Error(message)), ms);
|
|
399
|
+
promise.then(
|
|
400
|
+
(value) => {
|
|
401
|
+
clearTimeout(timer);
|
|
402
|
+
resolve(value);
|
|
403
|
+
},
|
|
404
|
+
(err) => {
|
|
405
|
+
clearTimeout(timer);
|
|
406
|
+
reject(err);
|
|
407
|
+
},
|
|
408
|
+
);
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
187
412
|
async function hmacHex(secret: string, body: string): Promise<string> {
|
|
188
413
|
const enc = new TextEncoder();
|
|
189
414
|
const key = await crypto.subtle.importKey(
|