@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IEvent } from "../../types/models";
|
|
1
|
+
import type { AppliedDiscountCoupon, IEvent } from "../../types/models";
|
|
2
2
|
import { useForge } from "../../provider/ForgeProvider";
|
|
3
3
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
4
4
|
|
|
@@ -48,12 +48,26 @@ export type CreateEventOrderInput = {
|
|
|
48
48
|
lastName?: string;
|
|
49
49
|
questionnaire?: unknown;
|
|
50
50
|
attributionRefId?: string;
|
|
51
|
+
/**
|
|
52
|
+
* The code the buyer typed. Tickets have no apply-coupon endpoint — this call
|
|
53
|
+
* IS where a code is redeemed, so a refused one fails the whole request with
|
|
54
|
+
* the reason as its message and no order is created.
|
|
55
|
+
*/
|
|
56
|
+
couponCode?: string;
|
|
51
57
|
};
|
|
52
58
|
|
|
53
59
|
export type CreateEventOrderResult = {
|
|
54
60
|
orderId: string;
|
|
55
61
|
isFreeCheckout?: boolean;
|
|
62
|
+
/** NET of any discount. */
|
|
56
63
|
totalAmount?: number;
|
|
64
|
+
// Additive (S.3) — present on every response, discounted or not.
|
|
65
|
+
/** GROSS, before any discount. */
|
|
66
|
+
subTotal?: number;
|
|
67
|
+
discountAmount?: number;
|
|
68
|
+
couponId?: string | null;
|
|
69
|
+
/** Every discount that applied, entered OR automatic. */
|
|
70
|
+
appliedCoupons?: AppliedDiscountCoupon[];
|
|
57
71
|
};
|
|
58
72
|
|
|
59
73
|
/** Create an event ticket order (the step before start-payment). */
|
|
@@ -76,5 +76,17 @@ export function usePaymentFlow(opts: UsePaymentFlowOptions) {
|
|
|
76
76
|
startedRef.current = true;
|
|
77
77
|
return mutateAsync(override);
|
|
78
78
|
},
|
|
79
|
+
/**
|
|
80
|
+
* Forget the last result so nothing downstream keeps quoting it.
|
|
81
|
+
*
|
|
82
|
+
* `mutation.data` outlives the thing it described: drop a discount code and
|
|
83
|
+
* the charge that code produced is still sitting here, so a summary reading
|
|
84
|
+
* `result.totalAmount` would go on showing the discounted figure against an
|
|
85
|
+
* order that no longer has the discount.
|
|
86
|
+
*/
|
|
87
|
+
reset: () => {
|
|
88
|
+
startedRef.current = false;
|
|
89
|
+
mutation.reset();
|
|
90
|
+
},
|
|
79
91
|
};
|
|
80
92
|
}
|
|
@@ -29,6 +29,12 @@ export interface SiteConfig {
|
|
|
29
29
|
* installable manifest + head tags. `null` when the creator has no pwaConfig
|
|
30
30
|
* (minimal name-only manifest, not installable). Screenshots are optional. */
|
|
31
31
|
pwa?: PwaConfig | null;
|
|
32
|
+
/** Suppresses the "Powered by TribeNest" badge on released builds. Server-side
|
|
33
|
+
* on purpose: the tenant owns `__root.tsx`, so anything expressed as a prop is
|
|
34
|
+
* an opt-out. Absent/false today — this is the lever a white-label plan
|
|
35
|
+
* entitlement flips, and it works without waiting for a Forge bump to reach
|
|
36
|
+
* every site. */
|
|
37
|
+
hideTribeNestBadge?: boolean;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
/** The PWA identity block on `site-config` (mirrors `pwaConfig` on the profile). */
|
package/src/index.ts
CHANGED
|
@@ -11,7 +11,12 @@ export type { ForgeClientOptions } from "./client/createForgeClient";
|
|
|
11
11
|
// + theme). Most sites import just this.
|
|
12
12
|
export { ForgeProvider, type ForgeProviderProps } from "./provider/ForgeAppProvider";
|
|
13
13
|
// Low-level pieces for hand-composing the tree.
|
|
14
|
-
export {
|
|
14
|
+
export {
|
|
15
|
+
ForgeClientProvider,
|
|
16
|
+
useForge,
|
|
17
|
+
type ForgeClientProviderProps,
|
|
18
|
+
type ForgeContextValue,
|
|
19
|
+
} from "./provider/ForgeProvider";
|
|
15
20
|
export { SiteConfigProvider, useInitialSiteConfig } from "./provider/SiteConfigProvider";
|
|
16
21
|
|
|
17
22
|
// All domain types + enums/consts (models) and WebPage.
|
|
@@ -30,6 +35,17 @@ export {
|
|
|
30
35
|
PublicAuthContext,
|
|
31
36
|
} from "./contexts/PublicAuthContext";
|
|
32
37
|
export type { AuthActions } from "./contexts/PublicAuthContext";
|
|
38
|
+
export { PUBLIC_REFRESH_TOKEN_KEY, APP_REFRESH_TOKEN_KEY } from "./client/tokenStorage";
|
|
39
|
+
export { safeRedirectPath } from "./utils/safeRedirect";
|
|
40
|
+
// Ticket-confirmation copy decision — shared so the Forge block and the client
|
|
41
|
+
// app's finalise page can never tell a refunded buyer two different stories.
|
|
42
|
+
export {
|
|
43
|
+
getTicketOrderOutcome,
|
|
44
|
+
isTicketOrderRefunded,
|
|
45
|
+
TICKET_ORDER_REFUNDED_COPY,
|
|
46
|
+
type TicketOrderOutcome,
|
|
47
|
+
type TicketOrderOutcomeInput,
|
|
48
|
+
} from "./utils/ticketOrderOutcome";
|
|
33
49
|
// App-user auth (mini-apps) + the /admin guard.
|
|
34
50
|
export {
|
|
35
51
|
useAppAuth,
|
|
@@ -37,7 +53,8 @@ export {
|
|
|
37
53
|
APP_ACCESS_TOKEN_KEY,
|
|
38
54
|
type AppAuthUser,
|
|
39
55
|
type AppSignupInput,
|
|
40
|
-
type
|
|
56
|
+
type AppAuthChallenge,
|
|
57
|
+
type AppAuthResult,
|
|
41
58
|
} from "./contexts/AppAuthContext";
|
|
42
59
|
export { useAppAdminGuard, type AppAdminGuardResult } from "./contexts/useAppAdminGuard";
|
|
43
60
|
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
|
2
2
|
import type { AxiosInstance } from "axios";
|
|
3
3
|
import { createForgeClient } from "../client/createForgeClient";
|
|
4
|
+
import {
|
|
5
|
+
PUBLIC_ACCESS_TOKEN_KEY,
|
|
6
|
+
PUBLIC_REFRESH_TOKEN_KEY,
|
|
7
|
+
clearTokenPair,
|
|
8
|
+
readStoredToken,
|
|
9
|
+
storeTokenPair,
|
|
10
|
+
} from "../client/tokenStorage";
|
|
4
11
|
|
|
5
12
|
export interface ForgeContextValue {
|
|
6
13
|
/** Base URL of the TribeNest public API. */
|
|
@@ -69,7 +76,29 @@ export const ForgeClientProvider = ({
|
|
|
69
76
|
}, []);
|
|
70
77
|
|
|
71
78
|
const client = useMemo(
|
|
72
|
-
() =>
|
|
79
|
+
() =>
|
|
80
|
+
createForgeClient({
|
|
81
|
+
baseURL: apiUrl,
|
|
82
|
+
publishableKey,
|
|
83
|
+
getToken: () => tokenRef.current,
|
|
84
|
+
// Fan/member lane (backend §7): 15-minute access tokens, refresh token
|
|
85
|
+
// rotated per exchange. On a dead chain the stored pair is cleared so
|
|
86
|
+
// the next /me lands cleanly logged-out instead of looping on 401s.
|
|
87
|
+
refresh: {
|
|
88
|
+
path: "/public/sessions/refresh",
|
|
89
|
+
getRefreshToken: () => readStoredToken(PUBLIC_REFRESH_TOKEN_KEY),
|
|
90
|
+
onRefreshed: ({ token: next, refreshToken }) => {
|
|
91
|
+
tokenRef.current = next;
|
|
92
|
+
setTokenState(next);
|
|
93
|
+
storeTokenPair(PUBLIC_ACCESS_TOKEN_KEY, PUBLIC_REFRESH_TOKEN_KEY, next, refreshToken);
|
|
94
|
+
},
|
|
95
|
+
onFailed: () => {
|
|
96
|
+
tokenRef.current = null;
|
|
97
|
+
setTokenState(null);
|
|
98
|
+
clearTokenPair(PUBLIC_ACCESS_TOKEN_KEY, PUBLIC_REFRESH_TOKEN_KEY);
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
}),
|
|
73
102
|
[apiUrl, publishableKey],
|
|
74
103
|
);
|
|
75
104
|
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// The app side of platform event delivery: signature verification, the response
|
|
2
|
+
// contract, and the derived idempotency key.
|
|
3
|
+
//
|
|
4
|
+
// The platform's half is tested against a real database in
|
|
5
|
+
// apps/backend/src/services/admin/app/_tests/appEvents.spec.ts. What can only be
|
|
6
|
+
// tested here is what an APP does with a delivery — and every one of these is a
|
|
7
|
+
// quiet failure if it is wrong: a verifier that accepts anything is an open
|
|
8
|
+
// endpoint, a route that answers 200 too early loses the event for good, and a
|
|
9
|
+
// key that differs between attempts reintroduces the duplicate write the whole
|
|
10
|
+
// design exists to prevent.
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
13
|
+
import { handlePlatformEvent, verifyPlatformEvent, SIGNATURE_TOLERANCE_SECONDS } from "../platform";
|
|
14
|
+
|
|
15
|
+
const SECRET = "test-signing-secret";
|
|
16
|
+
const API = "https://api.test";
|
|
17
|
+
|
|
18
|
+
async function hmacHex(secret: string, body: string): Promise<string> {
|
|
19
|
+
const enc = new TextEncoder();
|
|
20
|
+
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
21
|
+
"sign",
|
|
22
|
+
]);
|
|
23
|
+
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(body));
|
|
24
|
+
return Array.from(new Uint8Array(sig))
|
|
25
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
26
|
+
.join("");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const eventBody = (over: Record<string, unknown> = {}) =>
|
|
30
|
+
JSON.stringify({
|
|
31
|
+
event: "order.paid",
|
|
32
|
+
eventId: "evt-1",
|
|
33
|
+
appId: "app-1",
|
|
34
|
+
profileId: "prof-1",
|
|
35
|
+
occurredAt: new Date().toISOString(),
|
|
36
|
+
data: { orderId: "o-1", total: 10 },
|
|
37
|
+
...over,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
async function signedRequest(
|
|
41
|
+
body: string,
|
|
42
|
+
opts: { scheme?: "v1" | "v2" | "none"; timestamp?: number; secret?: string } = {},
|
|
43
|
+
): Promise<Request> {
|
|
44
|
+
const secret = opts.secret ?? SECRET;
|
|
45
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
46
|
+
const scheme = opts.scheme ?? "v2";
|
|
47
|
+
if (scheme === "v1") {
|
|
48
|
+
headers["x-tribenest-signature"] = await hmacHex(secret, body);
|
|
49
|
+
} else if (scheme === "v2") {
|
|
50
|
+
const t = opts.timestamp ?? Math.floor(Date.now() / 1000);
|
|
51
|
+
headers["x-tribenest-signature-v2"] = `t=${t},v1=${await hmacHex(secret, `${t}.${body}`)}`;
|
|
52
|
+
}
|
|
53
|
+
return new Request("https://app.test/api/tn-events", { method: "POST", headers, body });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
afterEach(() => vi.restoreAllMocks());
|
|
57
|
+
|
|
58
|
+
describe("verifyPlatformEvent", () => {
|
|
59
|
+
it("accepts a correctly timestamped signature", async () => {
|
|
60
|
+
const body = eventBody();
|
|
61
|
+
const event = await verifyPlatformEvent(await signedRequest(body), SECRET);
|
|
62
|
+
expect(event.eventId).toBe("evt-1");
|
|
63
|
+
expect(event.event).toBe("order.paid");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Deployed apps bundle their own Forge; they must keep receiving events while
|
|
67
|
+
// the fleet moves onto the timestamped scheme.
|
|
68
|
+
it("still accepts the body-only signature deployed apps were built against", async () => {
|
|
69
|
+
const body = eventBody();
|
|
70
|
+
const event = await verifyPlatformEvent(await signedRequest(body, { scheme: "v1" }), SECRET);
|
|
71
|
+
expect(event.eventId).toBe("evt-1");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("SECURITY: refuses an unsigned body", async () => {
|
|
75
|
+
await expect(verifyPlatformEvent(await signedRequest(eventBody(), { scheme: "none" }), SECRET)).rejects.toThrow(
|
|
76
|
+
/missing/i,
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("SECURITY: refuses a signature made with a different secret", async () => {
|
|
81
|
+
const req = await signedRequest(eventBody(), { secret: "someone-elses-secret" });
|
|
82
|
+
await expect(verifyPlatformEvent(req, SECRET)).rejects.toThrow(/did not verify/i);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("SECURITY: refuses a body altered after signing", async () => {
|
|
86
|
+
const body = eventBody();
|
|
87
|
+
const signed = await signedRequest(body);
|
|
88
|
+
const tampered = new Request(signed.url, {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: signed.headers,
|
|
91
|
+
body: eventBody({ data: { orderId: "o-1", total: 999999 } }),
|
|
92
|
+
});
|
|
93
|
+
await expect(verifyPlatformEvent(tampered, SECRET)).rejects.toThrow(/did not verify/i);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// The reason the timestamp is in the signature at all: without it, one captured
|
|
97
|
+
// delivery could be pushed at the app for as long as the key lived.
|
|
98
|
+
it("SECURITY: refuses a correctly signed delivery that is too old to be current", async () => {
|
|
99
|
+
const stale = Math.floor(Date.now() / 1000) - (SIGNATURE_TOLERANCE_SECONDS + 60);
|
|
100
|
+
const req = await signedRequest(eventBody(), { timestamp: stale });
|
|
101
|
+
await expect(verifyPlatformEvent(req, SECRET)).rejects.toThrow(/time window/i);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("SECURITY: refuses a timestamp from the future by the same margin", async () => {
|
|
105
|
+
const ahead = Math.floor(Date.now() / 1000) + (SIGNATURE_TOLERANCE_SECONDS + 60);
|
|
106
|
+
const req = await signedRequest(eventBody(), { timestamp: ahead });
|
|
107
|
+
await expect(verifyPlatformEvent(req, SECRET)).rejects.toThrow(/time window/i);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("refuses a malformed signature header rather than trying to interpret it", async () => {
|
|
111
|
+
const req = new Request("https://app.test/api/tn-events", {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: { "x-tribenest-signature-v2": "garbage" },
|
|
114
|
+
body: eventBody(),
|
|
115
|
+
});
|
|
116
|
+
await expect(verifyPlatformEvent(req, SECRET)).rejects.toThrow(/malformed/i);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("handlePlatformEvent", () => {
|
|
121
|
+
const opts = (handlers: Parameters<typeof handlePlatformEvent>[1]["handlers"], extra = {}) => ({
|
|
122
|
+
secret: SECRET,
|
|
123
|
+
apiUrl: API,
|
|
124
|
+
token: "tnp_test",
|
|
125
|
+
handlers,
|
|
126
|
+
...extra,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("runs the handler for the event and answers 200", async () => {
|
|
130
|
+
const seen: string[] = [];
|
|
131
|
+
const res = await handlePlatformEvent(
|
|
132
|
+
await signedRequest(eventBody()),
|
|
133
|
+
opts({ "order.paid": async (e) => void seen.push(e.data.orderId) }),
|
|
134
|
+
);
|
|
135
|
+
expect(res.status).toBe(200);
|
|
136
|
+
expect(seen).toEqual(["o-1"]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("SECURITY: never runs a handler for a delivery that did not verify", async () => {
|
|
140
|
+
const handler = vi.fn();
|
|
141
|
+
const res = await handlePlatformEvent(
|
|
142
|
+
await signedRequest(eventBody(), { secret: "wrong" }),
|
|
143
|
+
opts({ "order.paid": handler }),
|
|
144
|
+
);
|
|
145
|
+
expect(res.status).toBe(401);
|
|
146
|
+
expect(handler).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// The event is finished from the platform's side once it sees a 200. Answering
|
|
150
|
+
// 200 for an event nothing handles is right; a 500 would retry it five times
|
|
151
|
+
// and then sit in the failed list forever.
|
|
152
|
+
it("acknowledges an event it has no handler for, instead of failing forever", async () => {
|
|
153
|
+
const res = await handlePlatformEvent(await signedRequest(eventBody()), opts({}));
|
|
154
|
+
expect(res.status).toBe(200);
|
|
155
|
+
expect(await res.json()).toMatchObject({ ignored: "order.paid" });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// The whole reason this route is platform-owned: a 200 must mean the work is
|
|
159
|
+
// done, so the response cannot go out until the handler's promise settles.
|
|
160
|
+
it("REGRESSION: does not answer until the handler has actually finished", async () => {
|
|
161
|
+
let finished = false;
|
|
162
|
+
const res = await handlePlatformEvent(
|
|
163
|
+
await signedRequest(eventBody()),
|
|
164
|
+
opts({
|
|
165
|
+
"order.paid": async () => {
|
|
166
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
167
|
+
finished = true;
|
|
168
|
+
},
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
expect(finished).toBe(true);
|
|
172
|
+
expect(res.status).toBe(200);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("answers 500 when the handler throws, so the platform retries", async () => {
|
|
176
|
+
const res = await handlePlatformEvent(
|
|
177
|
+
await signedRequest(eventBody()),
|
|
178
|
+
opts({
|
|
179
|
+
"order.paid": async () => {
|
|
180
|
+
throw new Error("downstream is down");
|
|
181
|
+
},
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
expect(res.status).toBe(500);
|
|
185
|
+
expect(await res.json()).toMatchObject({ error: "downstream is down", eventId: "evt-1" });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// An overrun is otherwise indistinguishable from the platform timing out, and
|
|
189
|
+
// the fix (move it to a job) is not guessable from a dropped connection.
|
|
190
|
+
it("names the fix when a handler runs past its budget", async () => {
|
|
191
|
+
const res = await handlePlatformEvent(
|
|
192
|
+
await signedRequest(eventBody()),
|
|
193
|
+
opts({ "order.paid": () => new Promise<void>(() => {}) }, { budgetMs: 20 }),
|
|
194
|
+
);
|
|
195
|
+
expect(res.status).toBe(500);
|
|
196
|
+
expect((await res.json()).error).toMatch(/enqueueAppJob/);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// The property that makes a retried handler safe. Asserted on the header the
|
|
201
|
+
// platform actually receives, because that is what the idempotency store keys on.
|
|
202
|
+
describe("idempotency inside a handler", () => {
|
|
203
|
+
const runWithFetch = async (handler: Parameters<typeof handlePlatformEvent>[1]["handlers"]) => {
|
|
204
|
+
const calls: Array<{ url: string; key: string | null; body: string }> = [];
|
|
205
|
+
vi.stubGlobal(
|
|
206
|
+
"fetch",
|
|
207
|
+
vi.fn(async (url: string, init: RequestInit) => {
|
|
208
|
+
const headers = new Headers(init.headers as HeadersInit);
|
|
209
|
+
calls.push({ url, key: headers.get("idempotency-key"), body: String(init.body) });
|
|
210
|
+
return new Response(JSON.stringify({ result: { ok: true } }), { status: 200 });
|
|
211
|
+
}),
|
|
212
|
+
);
|
|
213
|
+
try {
|
|
214
|
+
await handlePlatformEvent(await signedRequest(eventBody()), {
|
|
215
|
+
secret: SECRET,
|
|
216
|
+
apiUrl: API,
|
|
217
|
+
token: "tnp_test",
|
|
218
|
+
handlers: handler,
|
|
219
|
+
});
|
|
220
|
+
} finally {
|
|
221
|
+
vi.unstubAllGlobals();
|
|
222
|
+
}
|
|
223
|
+
return calls;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
it("sends an idempotency key the app never had to think about", async () => {
|
|
227
|
+
const calls = await runWithFetch({
|
|
228
|
+
"order.paid": async (_e, ctx) => {
|
|
229
|
+
await ctx.platform.run("blog.post.create", { title: "T" });
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
expect(calls).toHaveLength(1);
|
|
233
|
+
// Carries the event id so a human reading the audit trail can see where the
|
|
234
|
+
// write came from.
|
|
235
|
+
expect(calls[0].key).toMatch(/^evt_evt-1_[0-9a-f]{32}$/);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("REGRESSION: the same write on a redelivery produces the SAME key", async () => {
|
|
239
|
+
const write = {
|
|
240
|
+
"order.paid": async (_e: unknown, ctx: { platform: { run: (...a: never[]) => Promise<unknown> } }) => {
|
|
241
|
+
await (ctx.platform.run as (a: string, b: unknown) => Promise<unknown>)("blog.post.create", {
|
|
242
|
+
title: "T",
|
|
243
|
+
tags: ["a", "b"],
|
|
244
|
+
});
|
|
245
|
+
},
|
|
246
|
+
} as unknown as Parameters<typeof handlePlatformEvent>[1]["handlers"];
|
|
247
|
+
|
|
248
|
+
const first = await runWithFetch(write);
|
|
249
|
+
const second = await runWithFetch(write);
|
|
250
|
+
expect(first[0].key).toBe(second[0].key);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// A call counter would have done this too — until a handler looped or branched
|
|
254
|
+
// differently on the retry, and then every key after the first would shift.
|
|
255
|
+
it("is not affected by the ORDER the writes happen in", async () => {
|
|
256
|
+
const forwards = await runWithFetch({
|
|
257
|
+
"order.paid": async (_e, ctx) => {
|
|
258
|
+
await ctx.platform.run("blog.post.create", { title: "A" });
|
|
259
|
+
await ctx.platform.run("blog.post.create", { title: "B" });
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
const backwards = await runWithFetch({
|
|
263
|
+
"order.paid": async (_e, ctx) => {
|
|
264
|
+
await ctx.platform.run("blog.post.create", { title: "B" });
|
|
265
|
+
await ctx.platform.run("blog.post.create", { title: "A" });
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
expect(new Set(forwards.map((c) => c.key))).toEqual(new Set(backwards.map((c) => c.key)));
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("keys on the input, so two different writes are not collapsed into one", async () => {
|
|
272
|
+
const calls = await runWithFetch({
|
|
273
|
+
"order.paid": async (_e, ctx) => {
|
|
274
|
+
await ctx.platform.run("blog.post.create", { title: "A" });
|
|
275
|
+
await ctx.platform.run("blog.post.create", { title: "B" });
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
expect(calls[0].key).not.toBe(calls[1].key);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("keys on the action, so the same input to two actions is not collapsed", async () => {
|
|
282
|
+
const calls = await runWithFetch({
|
|
283
|
+
"order.paid": async (_e, ctx) => {
|
|
284
|
+
await ctx.platform.run("blog.post.create", { title: "A" });
|
|
285
|
+
await ctx.platform.run("blog.post.update", { title: "A" });
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
expect(calls[0].key).not.toBe(calls[1].key);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// Key order is not stable, so an unsorted stringify would hash the same write
|
|
292
|
+
// two different ways and silently allow the duplicate.
|
|
293
|
+
it("REGRESSION: object key order in the input does not change the key", async () => {
|
|
294
|
+
const a = await runWithFetch({
|
|
295
|
+
"order.paid": async (_e, ctx) => {
|
|
296
|
+
await ctx.platform.run("blog.post.create", { title: "T", content: "C" });
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
const b = await runWithFetch({
|
|
300
|
+
"order.paid": async (_e, ctx) => {
|
|
301
|
+
await ctx.platform.run("blog.post.create", { content: "C", title: "T" } as never);
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
expect(a[0].key).toBe(b[0].key);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("lets an app override the key when it genuinely needs the same write twice", async () => {
|
|
308
|
+
const calls = await runWithFetch({
|
|
309
|
+
"order.paid": async (_e, ctx) => {
|
|
310
|
+
await ctx.platform.run("blog.post.create", { title: "T" }, { idempotencyKey: "mine-1" });
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
expect(calls[0].key).toBe("mine-1");
|
|
314
|
+
});
|
|
315
|
+
});
|
package/src/server/index.ts
CHANGED
|
@@ -274,9 +274,26 @@ export function toPlainText(html?: string, max = 200): string | undefined {
|
|
|
274
274
|
export {
|
|
275
275
|
createPlatformClient,
|
|
276
276
|
verifyPlatformEvent,
|
|
277
|
+
handlePlatformEvent,
|
|
277
278
|
PlatformError,
|
|
279
|
+
HANDLER_BUDGET_MS,
|
|
280
|
+
SIGNATURE_TOLERANCE_SECONDS,
|
|
278
281
|
type PlatformClient,
|
|
279
282
|
type PlatformConfig,
|
|
280
283
|
type PlatformAction,
|
|
281
284
|
type PlatformEvent,
|
|
285
|
+
type TypedPlatformEvent,
|
|
286
|
+
type PlatformEventHandlers,
|
|
287
|
+
type HandlePlatformEventOptions,
|
|
282
288
|
} from "./platform";
|
|
289
|
+
|
|
290
|
+
// Generated from the platform's event catalog — the payload each event carries.
|
|
291
|
+
// `npm run generate:forge-events` in apps/backend rewrites it; a spec fails if
|
|
292
|
+
// the committed copy is stale.
|
|
293
|
+
export {
|
|
294
|
+
PLATFORM_EVENT_PERMISSIONS,
|
|
295
|
+
PLATFORM_EVENT_NAMES,
|
|
296
|
+
type PlatformEventMap,
|
|
297
|
+
type PlatformEventName,
|
|
298
|
+
type PlatformEventData,
|
|
299
|
+
} from "./platformEvents.generated";
|
package/src/server/jobs.ts
CHANGED
|
@@ -11,11 +11,22 @@ export interface AppJobsEnv {
|
|
|
11
11
|
idFromName: (name: string) => unknown;
|
|
12
12
|
get: (id: unknown) => AppJobsStub;
|
|
13
13
|
};
|
|
14
|
+
/**
|
|
15
|
+
* Per-app job credential, injected by the platform at deploy. Authenticates
|
|
16
|
+
* this app to the shared job runtime (C8) — without it the runtime cannot
|
|
17
|
+
* tell one app's calls from another's.
|
|
18
|
+
*/
|
|
19
|
+
JOBS_APP_TOKEN?: string;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
interface AppJobsStub {
|
|
17
|
-
reconcile: (
|
|
18
|
-
|
|
23
|
+
reconcile: (
|
|
24
|
+
script: string,
|
|
25
|
+
callerToken: string,
|
|
26
|
+
desired: JobSchedule[],
|
|
27
|
+
hash: string,
|
|
28
|
+
) => Promise<{ changed: boolean }>;
|
|
29
|
+
enqueue: (script: string, callerToken: string, name: string, payload: unknown, delayMs: number) => Promise<void>;
|
|
19
30
|
status: () => Promise<AppJobsStatus>;
|
|
20
31
|
}
|
|
21
32
|
|
|
@@ -79,10 +90,10 @@ export interface JobSchedule {
|
|
|
79
90
|
* interval after the deploy.
|
|
80
91
|
*/
|
|
81
92
|
intervalMs?: number;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
93
|
+
// A 5-field UTC cron expression: minute hour day-of-month month day-of-week.
|
|
94
|
+
// e.g. "0 3 * * *" = daily at 03:00 UTC; "0 0,6,12,18 * * *" = every 6 hours;
|
|
95
|
+
// "0 9 * * 1-5" = 09:00 UTC on weekdays. Provide this OR `intervalMs`. Use
|
|
96
|
+
// cron when a run must land at a specific wall-clock time; evaluation is UTC.
|
|
86
97
|
cron?: string;
|
|
87
98
|
}
|
|
88
99
|
|
|
@@ -135,6 +146,26 @@ export interface AppJobsConfig {
|
|
|
135
146
|
|
|
136
147
|
const stub = (env: AppJobsEnv, appId: string): AppJobsStub => env.JOBS.get(env.JOBS.idFromName(appId));
|
|
137
148
|
|
|
149
|
+
/**
|
|
150
|
+
* The per-app credential the platform injects at deploy (`JOBS_APP_TOKEN`).
|
|
151
|
+
*
|
|
152
|
+
* The job runtime authenticates the CALLER with this instead of trusting an
|
|
153
|
+
* `appId` argument (C8): every app has a binding to the shared namespace, so
|
|
154
|
+
* without it any app could address — and operate on — another app's jobs.
|
|
155
|
+
*
|
|
156
|
+
* Apps published before this existed have no token and will get a clear error
|
|
157
|
+
* rather than a silent no-op; republishing injects one.
|
|
158
|
+
*/
|
|
159
|
+
const callerToken = (env: AppJobsEnv): string => {
|
|
160
|
+
const token = (env as { JOBS_APP_TOKEN?: string }).JOBS_APP_TOKEN;
|
|
161
|
+
if (!token) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
"TribeNest jobs: this app has no JOBS_APP_TOKEN. Republish the app to receive one (it is injected at deploy).",
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return token;
|
|
167
|
+
};
|
|
168
|
+
|
|
138
169
|
// A stable fingerprint of the desired schedule set, so the DO skips reconcile
|
|
139
170
|
// when nothing changed (sorted by name → order-independent).
|
|
140
171
|
function scheduleHash(schedules: JobSchedule[]): string {
|
|
@@ -152,7 +183,7 @@ function scheduleHash(schedules: JobSchedule[]): string {
|
|
|
152
183
|
* one, a removed one is pruned.
|
|
153
184
|
*/
|
|
154
185
|
export async function reconcileAppJobs(env: AppJobsEnv, cfg: AppJobsConfig, schedules: JobSchedule[]): Promise<void> {
|
|
155
|
-
await stub(env, cfg.appId).reconcile(cfg.script ?? "",
|
|
186
|
+
await stub(env, cfg.appId).reconcile(cfg.script ?? "", callerToken(env), schedules, scheduleHash(schedules));
|
|
156
187
|
}
|
|
157
188
|
|
|
158
189
|
/**
|
|
@@ -172,7 +203,7 @@ export async function enqueueAppJob(
|
|
|
172
203
|
payload?: unknown,
|
|
173
204
|
delayMs = 0,
|
|
174
205
|
): Promise<void> {
|
|
175
|
-
await stub(env, cfg.appId).enqueue(cfg.script ?? "",
|
|
206
|
+
await stub(env, cfg.appId).enqueue(cfg.script ?? "", callerToken(env), name, payload ?? null, delayMs);
|
|
176
207
|
}
|
|
177
208
|
|
|
178
209
|
/**
|
|
@@ -330,7 +361,6 @@ export async function deleteAppCollection(
|
|
|
330
361
|
return res.json();
|
|
331
362
|
}
|
|
332
363
|
|
|
333
|
-
|
|
334
364
|
/**
|
|
335
365
|
* Read an app's OWN collection back from a JOB handler (the read counterpart to
|
|
336
366
|
* writeAppCollection), authenticated by the run's app-sync token. Reads at `app`
|
|
@@ -367,7 +397,8 @@ export async function aggregateAppCollection(
|
|
|
367
397
|
headers: { "content-type": "application/json", "x-app-sync-token": ctx.token },
|
|
368
398
|
body: JSON.stringify(input),
|
|
369
399
|
});
|
|
370
|
-
if (!res.ok)
|
|
400
|
+
if (!res.ok)
|
|
401
|
+
throw new Error(`aggregateAppCollection ${slug} failed: ${res.status} ${await res.text().catch(() => "")}`);
|
|
371
402
|
return res.json();
|
|
372
403
|
}
|
|
373
404
|
|