@foir/sdk 0.1.0 → 0.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/dist/push.d.ts +109 -0
- package/dist/push.js +259 -0
- package/dist/server.d.ts +78 -1
- package/dist/server.js +63 -0
- package/package.json +5 -1
package/dist/push.d.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { TypedDocumentNode } from '@graphql-typed-document-node/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Foir GraphQL client — a single typed fetch wrapper for SSR/edge loaders.
|
|
5
|
+
*
|
|
6
|
+
* No GraphQL client (Apollo/urql) required: the Foir public API is a plain
|
|
7
|
+
* `POST` with an `x-api-key` header, and the dominant consumers (Hydrogen on
|
|
8
|
+
* Oxygen, React Router loaders, TanStack Start on CF Workers) fetch in
|
|
9
|
+
* server-only loaders and render — so Apollo's `InMemoryCache`/`useQuery`
|
|
10
|
+
* reactivity is dead weight, and a shared cache on a stateless multi-tenant
|
|
11
|
+
* edge worker is a cross-tenant hazard. This helper carries only the
|
|
12
|
+
* Foir-specific transport concerns (auth header, preview schema channel,
|
|
13
|
+
* error shape). Consumers on a GraphQL client (Apollo / urql / react-query /
|
|
14
|
+
* graphql-request) use the generated `TypedDocumentNode`s directly with their
|
|
15
|
+
* own client and replicate the two Foir-specific headers below — the
|
|
16
|
+
* `x-api-key` and, in preview, `X-Foir-Schema-Channel: draft`.
|
|
17
|
+
*
|
|
18
|
+
* Deduplicates the hand-copied `lib/foir/client.ts` that shipped identically
|
|
19
|
+
* across the Foir storefronts.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
interface FoirEnv {
|
|
23
|
+
PLATFORM_API_URL?: string;
|
|
24
|
+
PLATFORM_API_KEY?: string;
|
|
25
|
+
PLATFORM_PREVIEW_API_KEY?: string;
|
|
26
|
+
FOIR_PREVIEW?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Foir customer auth — framework-agnostic flows, token lifecycle, and guards.
|
|
31
|
+
*
|
|
32
|
+
* Session I/O is injected via {@link SessionAdapter} (no Hydrogen/React-Router
|
|
33
|
+
* coupling). Pre-login flows authenticate with the project `x-api-key`;
|
|
34
|
+
* post-login reads add the customer bearer. The project-specific profile (e.g.
|
|
35
|
+
* `isAdmin`) is resolved by a consumer-supplied `resolveUser` — the SDK never
|
|
36
|
+
* hardcodes the per-project customer profile shape.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Injected session store. Methods may be sync or async so consumers can plug
|
|
41
|
+
* in a cookie session (Hydrogen/Remix), a KV store, etc.
|
|
42
|
+
*/
|
|
43
|
+
interface SessionAdapter {
|
|
44
|
+
get(key: string): string | null | undefined | Promise<string | null | undefined>;
|
|
45
|
+
set(key: string, value: string): void | Promise<void>;
|
|
46
|
+
unset(key: string): void | Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface EnableWebPushOptions {
|
|
50
|
+
adapter: SessionAdapter;
|
|
51
|
+
env: FoirEnv;
|
|
52
|
+
/** Path the service worker is served from (must be same-origin). Default `/foir-sw.js`. */
|
|
53
|
+
swPath?: string;
|
|
54
|
+
/** Service-worker registration scope. Default `/`. */
|
|
55
|
+
scope?: string;
|
|
56
|
+
/** Optional label stored alongside the device token. */
|
|
57
|
+
deviceName?: string;
|
|
58
|
+
}
|
|
59
|
+
type EnableWebPushResult = {
|
|
60
|
+
ok: true;
|
|
61
|
+
subscription: PushSubscription;
|
|
62
|
+
} | {
|
|
63
|
+
ok: false;
|
|
64
|
+
reason: 'unsupported' | 'denied' | 'no-config';
|
|
65
|
+
};
|
|
66
|
+
interface DisableWebPushOptions {
|
|
67
|
+
adapter: SessionAdapter;
|
|
68
|
+
env: FoirEnv;
|
|
69
|
+
/** Scope the service worker was registered with. Default `/`. */
|
|
70
|
+
scope?: string;
|
|
71
|
+
}
|
|
72
|
+
/** True when the current browser supports the Web Push APIs this helper needs. */
|
|
73
|
+
declare function isWebPushSupported(): boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Subscribe the current browser to Foir Web Push and register the subscription
|
|
76
|
+
* as a `WEB` device token. Idempotent: reuses an existing subscription when one
|
|
77
|
+
* is already present. Returns `{ ok: false }` (never throws) when the browser
|
|
78
|
+
* is unsupported or the user declines permission.
|
|
79
|
+
*/
|
|
80
|
+
declare function enableWebPush(opts: EnableWebPushOptions): Promise<EnableWebPushResult>;
|
|
81
|
+
/**
|
|
82
|
+
* Unsubscribe the current browser from Web Push and deregister the device
|
|
83
|
+
* token. Returns `false` when there was no active subscription to remove.
|
|
84
|
+
*/
|
|
85
|
+
declare function disableWebPush(opts: DisableWebPushOptions): Promise<boolean>;
|
|
86
|
+
|
|
87
|
+
/** The project's non-secret VAPID public key a browser subscribes with. */
|
|
88
|
+
declare const WebPushConfigDocument: TypedDocumentNode<{
|
|
89
|
+
webPushConfig: {
|
|
90
|
+
vapidPublicKey: string;
|
|
91
|
+
} | null;
|
|
92
|
+
}, Record<string, never>>;
|
|
93
|
+
interface RegisterDeviceTokenInput {
|
|
94
|
+
token: string;
|
|
95
|
+
platform: 'IOS' | 'ANDROID' | 'WEB';
|
|
96
|
+
deviceName?: string;
|
|
97
|
+
}
|
|
98
|
+
declare const RegisterDeviceTokenDocument: TypedDocumentNode<{
|
|
99
|
+
registerDeviceToken: boolean;
|
|
100
|
+
}, {
|
|
101
|
+
input: RegisterDeviceTokenInput;
|
|
102
|
+
}>;
|
|
103
|
+
declare const UnregisterDeviceTokenDocument: TypedDocumentNode<{
|
|
104
|
+
unregisterDeviceToken: boolean;
|
|
105
|
+
}, {
|
|
106
|
+
token: string;
|
|
107
|
+
}>;
|
|
108
|
+
|
|
109
|
+
export { type DisableWebPushOptions, type EnableWebPushOptions, type EnableWebPushResult, RegisterDeviceTokenDocument, type RegisterDeviceTokenInput, UnregisterDeviceTokenDocument, WebPushConfigDocument, disableWebPush, enableWebPush, isWebPushSupported };
|
package/dist/push.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// src/graphql/foir-request.ts
|
|
2
|
+
import { print } from "graphql";
|
|
3
|
+
function isFoirPreview(env) {
|
|
4
|
+
return env.FOIR_PREVIEW === "true";
|
|
5
|
+
}
|
|
6
|
+
var FoirRequestError = class extends Error {
|
|
7
|
+
/** HTTP status code, when the request failed at the HTTP layer. */
|
|
8
|
+
status;
|
|
9
|
+
/** GraphQL errors, when the request failed with a no-data error envelope. */
|
|
10
|
+
graphQLErrors;
|
|
11
|
+
cause;
|
|
12
|
+
constructor(message, options) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "FoirRequestError";
|
|
15
|
+
this.cause = options?.cause;
|
|
16
|
+
this.status = options?.status;
|
|
17
|
+
this.graphQLErrors = options?.graphQLErrors;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
async function foirRequest(env, document, variables, options) {
|
|
21
|
+
const endpoint = env.PLATFORM_API_URL;
|
|
22
|
+
const apiKey = isFoirPreview(env) ? env.PLATFORM_PREVIEW_API_KEY ?? env.PLATFORM_API_KEY : env.PLATFORM_API_KEY;
|
|
23
|
+
if (!endpoint || !apiKey) {
|
|
24
|
+
throw new FoirRequestError(
|
|
25
|
+
"[foir] PLATFORM_API_URL or PLATFORM_API_KEY is not set \u2014 add them to .env"
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
const headers = {
|
|
29
|
+
"Content-Type": "application/json",
|
|
30
|
+
"x-api-key": apiKey
|
|
31
|
+
};
|
|
32
|
+
if (isFoirPreview(env)) {
|
|
33
|
+
headers["X-Foir-Schema-Channel"] = "draft";
|
|
34
|
+
}
|
|
35
|
+
if (options?.bearerToken) {
|
|
36
|
+
headers["Authorization"] = `Bearer ${options.bearerToken}`;
|
|
37
|
+
}
|
|
38
|
+
let res;
|
|
39
|
+
try {
|
|
40
|
+
res = await fetch(endpoint, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers,
|
|
43
|
+
body: JSON.stringify({ query: print(document), variables })
|
|
44
|
+
});
|
|
45
|
+
} catch (err) {
|
|
46
|
+
throw new FoirRequestError("[foir] network error", { cause: err });
|
|
47
|
+
}
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
throw new FoirRequestError(`[foir] HTTP ${res.status} ${res.statusText}`, {
|
|
50
|
+
status: res.status
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const json = await res.json();
|
|
54
|
+
if (json.errors?.length) {
|
|
55
|
+
const msg = json.errors.map((e) => e.message).join("; ");
|
|
56
|
+
if (json.data) {
|
|
57
|
+
console.warn(`[foir] GraphQL partial errors (data returned): ${msg}`);
|
|
58
|
+
} else {
|
|
59
|
+
throw new FoirRequestError(`[foir] GraphQL errors: ${msg}`, {
|
|
60
|
+
graphQLErrors: json.errors
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!json.data) {
|
|
65
|
+
throw new FoirRequestError("[foir] empty GraphQL response");
|
|
66
|
+
}
|
|
67
|
+
return json.data;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// src/auth/operations.ts
|
|
71
|
+
import { parse } from "graphql";
|
|
72
|
+
var AUTH_RESULT_FIELDS = "token refreshToken customerId email";
|
|
73
|
+
var CustomerLoginDocument = parse(
|
|
74
|
+
`mutation CustomerLogin($email: String!, $password: String!) {
|
|
75
|
+
customerLogin(email: $email, password: $password) { ${AUTH_RESULT_FIELDS} }
|
|
76
|
+
}`
|
|
77
|
+
);
|
|
78
|
+
var CustomerRegisterDocument = parse(
|
|
79
|
+
`mutation CustomerRegister($email: String!, $password: String!) {
|
|
80
|
+
customerRegister(email: $email, password: $password) { ${AUTH_RESULT_FIELDS} }
|
|
81
|
+
}`
|
|
82
|
+
);
|
|
83
|
+
var CustomerRefreshTokenDocument = parse(
|
|
84
|
+
`mutation CustomerRefreshToken($refreshToken: String!) {
|
|
85
|
+
customerRefreshToken(refreshToken: $refreshToken) { ${AUTH_RESULT_FIELDS} }
|
|
86
|
+
}`
|
|
87
|
+
);
|
|
88
|
+
var CustomerRequestOTPDocument = parse(
|
|
89
|
+
`mutation CustomerRequestOTP($email: String!) {
|
|
90
|
+
customerRequestOTP(email: $email)
|
|
91
|
+
}`
|
|
92
|
+
);
|
|
93
|
+
var CustomerLoginOTPDocument = parse(
|
|
94
|
+
`mutation CustomerLoginOTP($email: String!, $otp: String!) {
|
|
95
|
+
customerLoginOTP(email: $email, otp: $otp) { ${AUTH_RESULT_FIELDS} }
|
|
96
|
+
}`
|
|
97
|
+
);
|
|
98
|
+
var CustomerLoginWithProviderDocument = parse(
|
|
99
|
+
`mutation CustomerLoginWithProvider($input: ProviderLoginInput!) {
|
|
100
|
+
customerLoginWithProvider(input: $input) { redirectUrl token }
|
|
101
|
+
}`
|
|
102
|
+
);
|
|
103
|
+
var CustomerProviderCallbackDocument = parse(
|
|
104
|
+
`mutation CustomerProviderCallback($input: ProviderCallbackInput!) {
|
|
105
|
+
customerProviderCallback(input: $input) { ${AUTH_RESULT_FIELDS} }
|
|
106
|
+
}`
|
|
107
|
+
);
|
|
108
|
+
var CustomerProviderVerifyOTPDocument = parse(
|
|
109
|
+
`mutation CustomerProviderVerifyOTP($input: ProviderOTPVerifyInput!) {
|
|
110
|
+
customerProviderVerifyOTP(input: $input) { ${AUTH_RESULT_FIELDS} }
|
|
111
|
+
}`
|
|
112
|
+
);
|
|
113
|
+
var AuthProvidersDocument = parse(
|
|
114
|
+
`query AuthProviders {
|
|
115
|
+
authProviders { key name type isDefault }
|
|
116
|
+
}`
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// src/auth/auth.ts
|
|
120
|
+
var TOKEN_KEY = "foir:token";
|
|
121
|
+
var REFRESH_KEY = "foir:refreshToken";
|
|
122
|
+
var CUSTOMER_ID_KEY = "foir:customerId";
|
|
123
|
+
var EMAIL_KEY = "foir:email";
|
|
124
|
+
async function customerRefreshToken(env, refreshToken) {
|
|
125
|
+
return (await foirRequest(env, CustomerRefreshTokenDocument, { refreshToken })).customerRefreshToken;
|
|
126
|
+
}
|
|
127
|
+
async function saveSession(adapter, result) {
|
|
128
|
+
await adapter.set(TOKEN_KEY, result.token);
|
|
129
|
+
await adapter.set(REFRESH_KEY, result.refreshToken);
|
|
130
|
+
await adapter.set(CUSTOMER_ID_KEY, result.customerId);
|
|
131
|
+
await adapter.set(EMAIL_KEY, result.email);
|
|
132
|
+
return { id: result.customerId, email: result.email };
|
|
133
|
+
}
|
|
134
|
+
async function clearSession(adapter) {
|
|
135
|
+
await adapter.unset(TOKEN_KEY);
|
|
136
|
+
await adapter.unset(REFRESH_KEY);
|
|
137
|
+
await adapter.unset(CUSTOMER_ID_KEY);
|
|
138
|
+
await adapter.unset(EMAIL_KEY);
|
|
139
|
+
}
|
|
140
|
+
async function getSessionToken(adapter) {
|
|
141
|
+
return await adapter.get(TOKEN_KEY) ?? null;
|
|
142
|
+
}
|
|
143
|
+
async function refreshSession(adapter, env) {
|
|
144
|
+
const refreshToken = await adapter.get(REFRESH_KEY) ?? null;
|
|
145
|
+
if (!refreshToken) return null;
|
|
146
|
+
try {
|
|
147
|
+
const result = await customerRefreshToken(env, refreshToken);
|
|
148
|
+
await saveSession(adapter, result);
|
|
149
|
+
return { token: result.token, refreshToken: result.refreshToken };
|
|
150
|
+
} catch {
|
|
151
|
+
await clearSession(adapter);
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function isAuthExpiredError(err) {
|
|
156
|
+
if (!(err instanceof FoirRequestError)) return false;
|
|
157
|
+
if (err.status === 401) return true;
|
|
158
|
+
return (err.graphQLErrors ?? []).some(
|
|
159
|
+
(e) => /authentication required/i.test(e.message)
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
async function authedRequest(adapter, env, document, variables) {
|
|
163
|
+
const token = await getSessionToken(adapter);
|
|
164
|
+
try {
|
|
165
|
+
return await foirRequest(env, document, variables, {
|
|
166
|
+
bearerToken: token ?? void 0
|
|
167
|
+
});
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (!isAuthExpiredError(err)) throw err;
|
|
170
|
+
const refreshed = await refreshSession(adapter, env);
|
|
171
|
+
if (!refreshed) throw err;
|
|
172
|
+
return foirRequest(env, document, variables, {
|
|
173
|
+
bearerToken: refreshed.token
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// src/push/operations.ts
|
|
179
|
+
import { parse as parse2 } from "graphql";
|
|
180
|
+
var WebPushConfigDocument = parse2(
|
|
181
|
+
`query WebPushConfig {
|
|
182
|
+
webPushConfig { vapidPublicKey }
|
|
183
|
+
}`
|
|
184
|
+
);
|
|
185
|
+
var RegisterDeviceTokenDocument = parse2(
|
|
186
|
+
`mutation RegisterDeviceToken($input: RegisterDeviceTokenInput!) {
|
|
187
|
+
registerDeviceToken(input: $input)
|
|
188
|
+
}`
|
|
189
|
+
);
|
|
190
|
+
var UnregisterDeviceTokenDocument = parse2(
|
|
191
|
+
`mutation UnregisterDeviceToken($token: String!) {
|
|
192
|
+
unregisterDeviceToken(token: $token)
|
|
193
|
+
}`
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
// src/push/web-push.ts
|
|
197
|
+
function urlBase64ToUint8Array(base64) {
|
|
198
|
+
const padding = "=".repeat((4 - base64.length % 4) % 4);
|
|
199
|
+
const normalized = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
|
|
200
|
+
const raw = atob(normalized);
|
|
201
|
+
const out = new Uint8Array(new ArrayBuffer(raw.length));
|
|
202
|
+
for (let i = 0; i < raw.length; i += 1) out[i] = raw.charCodeAt(i);
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
function isWebPushSupported() {
|
|
206
|
+
return typeof window !== "undefined" && typeof navigator !== "undefined" && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
|
|
207
|
+
}
|
|
208
|
+
async function enableWebPush(opts) {
|
|
209
|
+
if (!isWebPushSupported()) return { ok: false, reason: "unsupported" };
|
|
210
|
+
const permission = await Notification.requestPermission();
|
|
211
|
+
if (permission !== "granted") return { ok: false, reason: "denied" };
|
|
212
|
+
const registration = await navigator.serviceWorker.register(
|
|
213
|
+
opts.swPath ?? "/foir-sw.js",
|
|
214
|
+
{ scope: opts.scope ?? "/" }
|
|
215
|
+
);
|
|
216
|
+
await navigator.serviceWorker.ready;
|
|
217
|
+
const config = await authedRequest(
|
|
218
|
+
opts.adapter,
|
|
219
|
+
opts.env,
|
|
220
|
+
WebPushConfigDocument
|
|
221
|
+
);
|
|
222
|
+
const vapidPublicKey = config.webPushConfig?.vapidPublicKey;
|
|
223
|
+
if (!vapidPublicKey) return { ok: false, reason: "no-config" };
|
|
224
|
+
const existing = await registration.pushManager.getSubscription();
|
|
225
|
+
const subscription = existing ?? await registration.pushManager.subscribe({
|
|
226
|
+
userVisibleOnly: true,
|
|
227
|
+
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
|
|
228
|
+
});
|
|
229
|
+
await authedRequest(opts.adapter, opts.env, RegisterDeviceTokenDocument, {
|
|
230
|
+
input: {
|
|
231
|
+
token: JSON.stringify(subscription),
|
|
232
|
+
platform: "WEB",
|
|
233
|
+
deviceName: opts.deviceName
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
return { ok: true, subscription };
|
|
237
|
+
}
|
|
238
|
+
async function disableWebPush(opts) {
|
|
239
|
+
if (!isWebPushSupported()) return false;
|
|
240
|
+
const registration = await navigator.serviceWorker.getRegistration(
|
|
241
|
+
opts.scope ?? "/"
|
|
242
|
+
);
|
|
243
|
+
const subscription = await registration?.pushManager.getSubscription();
|
|
244
|
+
if (!subscription) return false;
|
|
245
|
+
const token = JSON.stringify(subscription);
|
|
246
|
+
await subscription.unsubscribe();
|
|
247
|
+
await authedRequest(opts.adapter, opts.env, UnregisterDeviceTokenDocument, {
|
|
248
|
+
token
|
|
249
|
+
});
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
export {
|
|
253
|
+
RegisterDeviceTokenDocument,
|
|
254
|
+
UnregisterDeviceTokenDocument,
|
|
255
|
+
WebPushConfigDocument,
|
|
256
|
+
disableWebPush,
|
|
257
|
+
enableWebPush,
|
|
258
|
+
isWebPushSupported
|
|
259
|
+
};
|
package/dist/server.d.ts
CHANGED
|
@@ -226,6 +226,83 @@ declare function verifyScopedToken(token: string, jwksUrl: string, options?: Ver
|
|
|
226
226
|
*/
|
|
227
227
|
declare function parseContextHeader(raw: string | null): Record<string, string>;
|
|
228
228
|
|
|
229
|
+
/**
|
|
230
|
+
* requireScopedToken — the one-line ingress guard for BFF routes the
|
|
231
|
+
* platform dispatches operations to (EID-498).
|
|
232
|
+
*
|
|
233
|
+
* Operation endpoints must be publicly reachable (the platform's dispatcher
|
|
234
|
+
* refuses private networks), so "the BFF is never public-facing" is achieved
|
|
235
|
+
* by AUTHENTICATED ingress, not network privacy: every route the platform
|
|
236
|
+
* calls verifies the platform-minted `X-Foir-Token` before doing anything. A
|
|
237
|
+
* route that skips verification is an open endpoint anyone can call
|
|
238
|
+
* directly, bypassing the operation's scopes, quotas, and preconditions.
|
|
239
|
+
*
|
|
240
|
+
* ```ts
|
|
241
|
+
* // Hono / Workers / Hydrogen — any fetch-API request:
|
|
242
|
+
* const { claims, subject, context } = await requireScopedToken(request, {
|
|
243
|
+
* expectedProjectId: env.FOIR_PROJECT_ID,
|
|
244
|
+
* });
|
|
245
|
+
* ```
|
|
246
|
+
*
|
|
247
|
+
* Throws {@link ScopedTokenError} with `status` 401 (missing/invalid token)
|
|
248
|
+
* or 403 (valid token for the wrong tenant/project) — map it to a response
|
|
249
|
+
* in one place:
|
|
250
|
+
*
|
|
251
|
+
* ```ts
|
|
252
|
+
* catch (err) {
|
|
253
|
+
* if (err instanceof ScopedTokenError) {
|
|
254
|
+
* return new Response(err.message, { status: err.status });
|
|
255
|
+
* }
|
|
256
|
+
* throw err;
|
|
257
|
+
* }
|
|
258
|
+
* ```
|
|
259
|
+
*/
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Verification failure with the HTTP status the route should answer with:
|
|
263
|
+
* 401 for a missing or invalid token, 403 for a valid token bound to the
|
|
264
|
+
* wrong tenant or project.
|
|
265
|
+
*/
|
|
266
|
+
declare class ScopedTokenError extends Error {
|
|
267
|
+
readonly status: 401 | 403;
|
|
268
|
+
constructor(message: string, status: 401 | 403);
|
|
269
|
+
}
|
|
270
|
+
/** The minimal request shape needed — satisfied by any fetch-API Request. */
|
|
271
|
+
interface HeaderCarrier {
|
|
272
|
+
headers: {
|
|
273
|
+
get(name: string): string | null;
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
interface RequireScopedTokenOptions extends VerifyScopedTokenOptions {
|
|
277
|
+
/**
|
|
278
|
+
* JWKS endpoint to verify against. Defaults to the production platform
|
|
279
|
+
* (`https://api.foir.dev/.well-known/jwks.json`); point it at your dev
|
|
280
|
+
* host's `/.well-known/jwks.json` for non-production deployments.
|
|
281
|
+
*/
|
|
282
|
+
jwksUrl?: string;
|
|
283
|
+
/**
|
|
284
|
+
* When set, a verified token whose subject names a different project is
|
|
285
|
+
* rejected with 403. Set this from your deploy's own config — it pins the
|
|
286
|
+
* route to its project even if a token minted for another project leaks.
|
|
287
|
+
*/
|
|
288
|
+
expectedProjectId?: string;
|
|
289
|
+
/** Tenant analogue of {@link expectedProjectId}. */
|
|
290
|
+
expectedTenantId?: string;
|
|
291
|
+
}
|
|
292
|
+
interface ScopedTokenIngress {
|
|
293
|
+
claims: ScopedTokenClaims;
|
|
294
|
+
subject: ScopedTokenSubject;
|
|
295
|
+
/** Parsed `X-Foir-Context` correlation metadata (empty when absent). */
|
|
296
|
+
context: Record<string, string>;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Verify the platform-minted scoped token on an inbound dispatch request.
|
|
300
|
+
* Returns the decoded claims, parsed subject, and dispatch context; throws
|
|
301
|
+
* {@link ScopedTokenError} otherwise. See the module doc for the catch
|
|
302
|
+
* pattern.
|
|
303
|
+
*/
|
|
304
|
+
declare function requireScopedToken(request: HeaderCarrier, options?: RequireScopedTokenOptions): Promise<ScopedTokenIngress>;
|
|
305
|
+
|
|
229
306
|
/**
|
|
230
307
|
* Type definitions for platform operations.
|
|
231
308
|
*
|
|
@@ -454,4 +531,4 @@ declare function createCallbackClient<TResult = unknown>(callback: OperationCall
|
|
|
454
531
|
fetch?: typeof fetch;
|
|
455
532
|
}): CallbackClient<TResult>;
|
|
456
533
|
|
|
457
|
-
export { type CallbackClient, type CallbackResult, type EditorPlacement, type GraphQLResponse, type OperationCallback, type OperationContext, type OperationPayload, type OperationRecordContext, type OperationResult, type OperationTrigger, type ScopedTokenClaims, type ScopedTokenSubject, type ServerClient, ServerClientError, type ServerClientOptions, type VerifyScopedTokenOptions, createCallbackClient, createServerClient, parseContextHeader, parseSubject, recordsReadModels, recordsWriteModels, verifyScopedToken, verifyWebhookSignature };
|
|
534
|
+
export { type CallbackClient, type CallbackResult, type EditorPlacement, type GraphQLResponse, type OperationCallback, type OperationContext, type OperationPayload, type OperationRecordContext, type OperationResult, type OperationTrigger, type RequireScopedTokenOptions, type ScopedTokenClaims, ScopedTokenError, type ScopedTokenIngress, type ScopedTokenSubject, type ServerClient, ServerClientError, type ServerClientOptions, type VerifyScopedTokenOptions, createCallbackClient, createServerClient, parseContextHeader, parseSubject, recordsReadModels, recordsWriteModels, requireScopedToken, verifyScopedToken, verifyWebhookSignature };
|
package/dist/server.js
CHANGED
|
@@ -184,6 +184,67 @@ function parseContextHeader(raw) {
|
|
|
184
184
|
return out;
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
// src/server/require-scoped-token.ts
|
|
188
|
+
var TOKEN_HEADER = "X-Foir-Token";
|
|
189
|
+
var CONTEXT_HEADER = "X-Foir-Context";
|
|
190
|
+
var DEFAULT_JWKS_URL = "https://api.foir.dev/.well-known/jwks.json";
|
|
191
|
+
var ScopedTokenError = class extends Error {
|
|
192
|
+
status;
|
|
193
|
+
constructor(message, status) {
|
|
194
|
+
super(message);
|
|
195
|
+
this.name = "ScopedTokenError";
|
|
196
|
+
this.status = status;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
async function requireScopedToken(request, options) {
|
|
200
|
+
const token = request.headers.get(TOKEN_HEADER);
|
|
201
|
+
if (!token) {
|
|
202
|
+
throw new ScopedTokenError(
|
|
203
|
+
`missing ${TOKEN_HEADER} header \u2014 this route only accepts platform-dispatched requests`,
|
|
204
|
+
401
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
let claims;
|
|
208
|
+
try {
|
|
209
|
+
claims = await verifyScopedToken(
|
|
210
|
+
token,
|
|
211
|
+
options?.jwksUrl ?? DEFAULT_JWKS_URL,
|
|
212
|
+
options
|
|
213
|
+
);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
throw new ScopedTokenError(
|
|
216
|
+
`invalid scoped token: ${err instanceof Error ? err.message : String(err)}`,
|
|
217
|
+
401
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
let subject;
|
|
221
|
+
try {
|
|
222
|
+
subject = parseSubject(claims.sub);
|
|
223
|
+
} catch (err) {
|
|
224
|
+
throw new ScopedTokenError(
|
|
225
|
+
`invalid scoped token subject: ${err instanceof Error ? err.message : String(err)}`,
|
|
226
|
+
401
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (options?.expectedProjectId && subject.projectId !== options.expectedProjectId) {
|
|
230
|
+
throw new ScopedTokenError(
|
|
231
|
+
"scoped token is bound to a different project",
|
|
232
|
+
403
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
if (options?.expectedTenantId && subject.tenantId !== options.expectedTenantId) {
|
|
236
|
+
throw new ScopedTokenError(
|
|
237
|
+
"scoped token is bound to a different tenant",
|
|
238
|
+
403
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
claims,
|
|
243
|
+
subject,
|
|
244
|
+
context: parseContextHeader(request.headers.get(CONTEXT_HEADER))
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
187
248
|
// src/server/callback-client.ts
|
|
188
249
|
function createCallbackClient(callback, opts) {
|
|
189
250
|
const { executionId, signingSecret } = opts;
|
|
@@ -284,6 +345,7 @@ async function hmacSha256Hex(secret, body) {
|
|
|
284
345
|
return Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
285
346
|
}
|
|
286
347
|
export {
|
|
348
|
+
ScopedTokenError,
|
|
287
349
|
ServerClientError,
|
|
288
350
|
createCallbackClient,
|
|
289
351
|
createServerClient,
|
|
@@ -291,6 +353,7 @@ export {
|
|
|
291
353
|
parseSubject,
|
|
292
354
|
recordsReadModels,
|
|
293
355
|
recordsWriteModels,
|
|
356
|
+
requireScopedToken,
|
|
294
357
|
verifyScopedToken,
|
|
295
358
|
verifyWebhookSignature
|
|
296
359
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@foir/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Official Foir SDK — editor iframe client + server-side platform helpers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -33,6 +33,10 @@
|
|
|
33
33
|
"types": "./dist/auth.d.ts",
|
|
34
34
|
"import": "./dist/auth.js"
|
|
35
35
|
},
|
|
36
|
+
"./push": {
|
|
37
|
+
"types": "./dist/push.d.ts",
|
|
38
|
+
"import": "./dist/push.js"
|
|
39
|
+
},
|
|
36
40
|
"./realtime": {
|
|
37
41
|
"types": "./dist/realtime.d.ts",
|
|
38
42
|
"import": "./dist/realtime.js"
|