@swell/apps-sdk 2.0.0-alpha.0 → 2.0.0-alpha.2

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  The Swell Apps SDK is a TypeScript library for building server-side Swell apps. It
4
4
  provides access to the Backend and Storefront APIs, along with helpers for app
5
- configuration, customer sessions and staff identity.
5
+ configuration, customer sessions and store user identity.
6
6
 
7
7
  Use it in Swell-hosted apps, Cloudflare Workers or Node.js servers. For browser
8
8
  applications, use [`swell-js`](https://github.com/swellstores/swell-js).
@@ -21,22 +21,30 @@ Version 2 replaces the 1.x theme API. Existing theme applications should remain
21
21
 
22
22
  ## Getting started
23
23
 
24
- ### Headers and app proxying
24
+ ### Request context
25
25
 
26
- When your app runs on Swell, the platform supplies request headers with API
27
- credentials, store configuration and storefront context. Pass the request's headers
28
- to the SDK to work with the current store. Create clients for each incoming request.
26
+ Swell supplies the current store's configuration, credentials and store user identity
27
+ with each request. Verify this context once, then reuse it to create clients and check
28
+ store user access. Use the same pattern for Swell-hosted and self-hosted frontends.
29
29
 
30
- Only use these headers when they come through Swell's trusted proxy. On other servers,
31
- use explicit credentials as shown below.
30
+ ```ts
31
+ import { verifySwellContext } from '@swell/apps-sdk';
32
+
33
+ const context = await verifySwellContext(request.headers, {
34
+ appId: 'my-app', // Your app's configured slug.
35
+ });
36
+ ```
37
+
38
+ In a server component, pass `await headers()`. Keep the context and clients on the
39
+ server, scoped to the incoming request.
32
40
 
33
41
  ### Backend API calls
34
42
 
35
43
  ```ts
36
44
  import { SwellBackendAPI } from '@swell/apps-sdk';
37
45
 
38
- // Use the credentials supplied to your Swell-hosted app.
39
- const backend = new SwellBackendAPI({ headers: request.headers });
46
+ // Reuse the context resolved for this request.
47
+ const backend = new SwellBackendAPI({ context });
40
48
 
41
49
  // Fetch products from the Backend API.
42
50
  const products = await backend.get('/products', { limit: 10 });
@@ -58,8 +66,8 @@ pending writes and deletions.
58
66
  import { getStorefrontConfig } from '@swell/apps-sdk';
59
67
  import { createStorefrontClient } from '@swell/apps-sdk/storefront';
60
68
 
61
- // Build the storefront config from the platform headers.
62
- const config = getStorefrontConfig(request.headers);
69
+ // Build public storefront configuration from the request context.
70
+ const config = getStorefrontConfig(context);
63
71
  const storefront = createStorefrontClient(config, {
64
72
  cookies: {
65
73
  get: name => cookies.get(name)?.value,
@@ -86,33 +94,29 @@ import swell from 'swell-js';
86
94
  swell.init(config.storeId, config.publicKey, config);
87
95
  ```
88
96
 
89
- This config excludes backend credentials. For server-side metadata, use
90
- `parseSwellHeaders(headers)`. It reads headers without verifying their signature;
91
- keep its result on the server because it includes the backend token.
97
+ Send only this public config to the browser; the request context contains server credentials.
92
98
 
93
- ### Staff identity
99
+ ### Store users
94
100
 
95
- Use `requireStaff` to check that a request belongs to a staff member of the current
96
- store before applying your application's permission checks:
101
+ A store user is someone signed in to the store's Swell dashboard. Use
102
+ `requireStoreUser` to check that a request belongs to one before applying your
103
+ application's permission checks:
97
104
 
98
105
  ```ts
99
- import { requireStaff } from '@swell/apps-sdk';
106
+ import { requireStoreUser } from '@swell/apps-sdk';
100
107
 
101
- const staff = await requireStaff({
102
- headers: request.headers,
103
- method: request.method,
104
- origin: appOrigin, // Your configured app origin.
105
- cookies: { get: name => cookies.get(name)?.value },
106
- });
108
+ const storeUser = requireStoreUser(context); // { userId, storeId }, or a 401 SwellError.
109
+ const optional = context.storeUser; // null for a visitor; no exception needed.
107
110
  ```
108
111
 
109
112
  ## API reference
110
113
 
111
114
  ### Backend client
112
115
 
113
- `apiHost` is a required absolute HTTP(S) URL. Use either `secretKey` or `accessToken`;
114
- do not mix explicit credentials with `headers`. Invalid constructor options throw
115
- immediately. All backend methods return promises and reject on failure.
116
+ Pass `{ context }` for a frontend request, or explicit credentials for an external
117
+ server. Backend calls require an access token or secret key and an absolute HTTP(S)
118
+ `apiHost`. Do not mix input sources. Invalid constructor options throw immediately;
119
+ all backend methods return promises and reject on failure.
116
120
 
117
121
  | Method | Result |
118
122
  | --- | --- |
@@ -142,7 +146,7 @@ defaults: `limit` counts additional attempts, and delays are in milliseconds. On
142
146
  `transaction_conflict` and `transaction_throttled` retry. Ordinary requests and network
143
147
  failures are not retried.
144
148
 
145
- **Private functions:** use the app slug from `Swell-App-Id` and authorize the caller
149
+ **Private functions:** use the app slug from `context.appId` and authorize the caller
146
150
  first. `options.method` defaults to `post`; `get`, `put` and `delete` are also supported.
147
151
  GET data must contain only flat string, number or boolean values. Caller headers are
148
152
  not forwarded; response status and headers are not returned. Function errors and
@@ -166,14 +170,45 @@ rotation during GET requests. A supplied writer may throw or skip a write; after
166
170
  its reader must still report the actual state. Writer return values are ignored.
167
171
  Persist cookies in writable route handlers or actions.
168
172
 
169
- For caching, replace `storefront.request` before first use. The SDK has no built-in cache.
173
+ For API response caching, replace `storefront.request` before first use. The SDK
174
+ leaves response caching to your application.
175
+
176
+ ### Request context options
177
+
178
+ `verifySwellContext(headers, { env?, appId?, storeId?, vaultUrl? })` returns the request
179
+ context or throws if verification fails. Set `appId` and, for a single-store app,
180
+ `storeId` from trusted configuration to reject contexts intended for another app or
181
+ store. Without these options, it verifies the source but does not restrict the
182
+ destination. `vaultUrl` provides an optional vault endpoint override.
183
+
184
+ Configuration is read from `process.env`, or from `env` when supplied. Workers without
185
+ Node compatibility should pass their bindings as `env`.
186
+
187
+ | Variable | Default | Purpose |
188
+ | --- | --- | --- |
189
+ | `SWELL_VERIFY_HEADERS` | enabled | Set exactly `"false"` to skip signature verification during local development. |
190
+ | `SWELL_HEADERS_JWKS_URL` | `https://swell.store/.well-known/jwks.json` | Override the verification-key endpoint. |
191
+
192
+ For local development against a local Swell instance, put `SWELL_VERIFY_HEADERS=false`
193
+ in `.dev.vars`. Remove it or set it to `"true"` to restore verification. Token structure
194
+ and claim validation still apply, but the context is no longer authenticated: anyone
195
+ who can reach the frontend directly can supply forged context, including a store user.
196
+ Use this bypass only for local development.
197
+
198
+ For server integrations, construct `SwellBackendAPI` with explicit credentials and
199
+ `createStorefrontClient` with explicit public configuration. These clients do not
200
+ require an HTTP request or a store user.
201
+
202
+ ### Store users
170
203
 
171
- ### Staff verification
204
+ `requireStoreUser(context)` returns `{ userId, storeId }` or throws `SwellError` with
205
+ status 401 and code `store_user_required`. For optional access, read
206
+ `context.storeUser`, which is null for visitors.
172
207
 
173
- `requireStaff` verifies `_swell_admin_session` against the current store and returns
174
- `{ userId, storeId }`. Use a trusted `appOrigin`: every non-GET request requires a
175
- matching `Origin` and, when present, `Sec-Fetch-Site: same-origin`. Failures reject.
176
- The helper checks identity and request origin; your application enforces permissions.
208
+ A store user is anyone signed in to the store's dashboard, including partners and Swell
209
+ support, who may not appear in the store's own user list. Swell's proxy handles their
210
+ authentication and write-origin checks; your application decides what each store user
211
+ may do.
177
212
 
178
213
  ### Errors
179
214
 
@@ -185,8 +220,10 @@ error handling; `message` is for people and may change.
185
220
  `body`. Successful GET responses containing `errors` are returned as data.
186
221
  - Function invocation failures retain the response payload, or the invocation envelope
187
222
  when the payload is null or absent, in `body`.
188
- - Network errors remain native. Local configuration errors may be ordinary `Error`
189
- instances; not every failure is a `SwellError`.
223
+ - Header verification uses 401 / `invalid_swell_context` for absent, malformed, expired
224
+ or rejected tokens, and 503 / `swell_jwks_unavailable` for key-service failures.
225
+ - Backend/storefront network errors remain native. Local configuration errors may be
226
+ ordinary `Error` instances; not every failure is a `SwellError`.
190
227
 
191
228
  ## Development
192
229
 
@@ -1,7 +1,7 @@
1
- import type { HeaderReader } from './context.cjs';
1
+ import type { SwellRequestContext } from './request-context.cjs';
2
2
  export type SwellData = Record<string, any>;
3
3
  export type BackendOptions = {
4
- headers: HeaderReader;
4
+ context: SwellRequestContext;
5
5
  storeId?: never;
6
6
  apiHost?: never;
7
7
  accessToken?: never;
@@ -9,7 +9,7 @@ export type BackendOptions = {
9
9
  appId?: never;
10
10
  requestId?: never;
11
11
  } | ({
12
- headers?: never;
12
+ context?: never;
13
13
  storeId: string;
14
14
  apiHost: string;
15
15
  appId?: string;
@@ -47,7 +47,7 @@ export interface TransactionOptions {
47
47
  jitter?: boolean;
48
48
  };
49
49
  }
50
- /** Per-request backend client. Use explicit credentials outside trusted platform ingress. */
50
+ /** Backend client from a frontend request context or explicit server credentials. */
51
51
  export declare class SwellBackendAPI {
52
52
  #private;
53
53
  protected get userAgent(): string;
@@ -59,7 +59,7 @@ export declare class SwellBackendAPI {
59
59
  /**
60
60
  * Invokes an app's private function from server code and resolves to its response payload,
61
61
  * without the envelope's status or headers.
62
- * `appId` is the app identifier from `Swell-App-Id` (`parseSwellHeaders(headers).appId`).
62
+ * `appId` is the app identifier from the request context (`context.appId`).
63
63
  * `method` (default `post`) selects the function's handler. GET data reaches the function
64
64
  * as query parameters, so its values must be flat strings, numbers or booleans.
65
65
  * Throws `SwellError` when the function reports a non-2xx status or an error.
package/dist/backend.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import type { HeaderReader } from './context.js';
1
+ import type { SwellRequestContext } from './request-context.js';
2
2
  export type SwellData = Record<string, any>;
3
3
  export type BackendOptions = {
4
- headers: HeaderReader;
4
+ context: SwellRequestContext;
5
5
  storeId?: never;
6
6
  apiHost?: never;
7
7
  accessToken?: never;
@@ -9,7 +9,7 @@ export type BackendOptions = {
9
9
  appId?: never;
10
10
  requestId?: never;
11
11
  } | ({
12
- headers?: never;
12
+ context?: never;
13
13
  storeId: string;
14
14
  apiHost: string;
15
15
  appId?: string;
@@ -47,7 +47,7 @@ export interface TransactionOptions {
47
47
  jitter?: boolean;
48
48
  };
49
49
  }
50
- /** Per-request backend client. Use explicit credentials outside trusted platform ingress. */
50
+ /** Backend client from a frontend request context or explicit server credentials. */
51
51
  export declare class SwellBackendAPI {
52
52
  #private;
53
53
  protected get userAgent(): string;
@@ -59,7 +59,7 @@ export declare class SwellBackendAPI {
59
59
  /**
60
60
  * Invokes an app's private function from server code and resolves to its response payload,
61
61
  * without the envelope's status or headers.
62
- * `appId` is the app identifier from `Swell-App-Id` (`parseSwellHeaders(headers).appId`).
62
+ * `appId` is the app identifier from the request context (`context.appId`).
63
63
  * `method` (default `post`) selects the function's handler. GET data reaches the function
64
64
  * as query parameters, so its values must be flat strings, numbers or booleans.
65
65
  * Throws `SwellError` when the function reports a non-2xx status or an error.
package/dist/backend.js CHANGED
@@ -1,4 +1,4 @@
1
- import { parseSwellHeaders, requireString, validateUrl } from './context.js';
1
+ import { requireString, validateUrl } from './context.js';
2
2
  import { SwellError } from './error.js';
3
3
  import { validateWorkflowParams } from './workflow.js';
4
4
  import { USER_AGENT } from './version.js';
@@ -17,7 +17,7 @@ function queryParts(query, prefix = '') {
17
17
  : [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`];
18
18
  });
19
19
  }
20
- /** Per-request backend client. Use explicit credentials outside trusted platform ingress. */
20
+ /** Backend client from a frontend request context or explicit server credentials. */
21
21
  export class SwellBackendAPI {
22
22
  #baseUrl;
23
23
  #authorization;
@@ -37,7 +37,7 @@ export class SwellBackendAPI {
37
37
  /**
38
38
  * Invokes an app's private function from server code and resolves to its response payload,
39
39
  * without the envelope's status or headers.
40
- * `appId` is the app identifier from `Swell-App-Id` (`parseSwellHeaders(headers).appId`).
40
+ * `appId` is the app identifier from the request context (`context.appId`).
41
41
  * `method` (default `post`) selects the function's handler. GET data reaches the function
42
42
  * as query parameters, so its values must be flat strings, numbers or booleans.
43
43
  * Throws `SwellError` when the function reports a non-2xx status or an error.
@@ -65,10 +65,12 @@ export class SwellBackendAPI {
65
65
  },
66
66
  };
67
67
  constructor(options) {
68
- if (options.headers && ['storeId', 'apiHost', 'accessToken', 'secretKey', 'appId', 'requestId'].some(key => key in options)) {
69
- throw new Error('headers and explicit backend credentials are mutually exclusive');
68
+ if ('headers' in options)
69
+ throw new Error('Pass a request context or explicit credentials, not raw headers');
70
+ if (options.context && ['storeId', 'apiHost', 'accessToken', 'secretKey', 'appId', 'requestId'].some(key => key in options)) {
71
+ throw new Error('context and explicit backend credentials are mutually exclusive');
70
72
  }
71
- const config = options.headers ? parseSwellHeaders(options.headers) : options;
73
+ const config = options.context ?? options;
72
74
  const secretKey = 'secretKey' in config ? config.secretKey : undefined;
73
75
  requireString(config.storeId, 'storeId');
74
76
  if ((config.accessToken !== undefined) === (secretKey !== undefined))
@@ -1,21 +1,7 @@
1
1
  import type { PublicConfig } from 'swell-js';
2
+ import type { SwellRequestContext } from './request-context.cjs';
2
3
  export type HeaderReader = Pick<Headers, 'get'>;
3
- export interface SwellContext {
4
- storeId?: string;
5
- appId?: string;
6
- environmentId?: string;
7
- storefrontId?: string;
8
- accessToken?: string;
9
- publicKey?: string;
10
- apiHost?: string;
11
- adminUrl?: string;
12
- vaultUrl?: string;
13
- requestId?: string;
14
- isLocalDev: boolean;
15
- }
16
- /** Parses trusted ingress headers; does not verify their signature. */
17
- export declare function parseSwellHeaders(headers: HeaderReader): SwellContext;
18
4
  export declare function requireString(value: unknown, field: string): asserts value is string;
19
5
  export declare function validateUrl(value: unknown, field: string): string;
20
6
  /** Projects only public configuration. Deliver it with Cache-Control: private, no-store. */
21
- export declare function getStorefrontConfig(headers: HeaderReader): PublicConfig;
7
+ export declare function getStorefrontConfig(context: SwellRequestContext): PublicConfig;
package/dist/context.d.ts CHANGED
@@ -1,21 +1,7 @@
1
1
  import type { PublicConfig } from 'swell-js';
2
+ import type { SwellRequestContext } from './request-context.js';
2
3
  export type HeaderReader = Pick<Headers, 'get'>;
3
- export interface SwellContext {
4
- storeId?: string;
5
- appId?: string;
6
- environmentId?: string;
7
- storefrontId?: string;
8
- accessToken?: string;
9
- publicKey?: string;
10
- apiHost?: string;
11
- adminUrl?: string;
12
- vaultUrl?: string;
13
- requestId?: string;
14
- isLocalDev: boolean;
15
- }
16
- /** Parses trusted ingress headers; does not verify their signature. */
17
- export declare function parseSwellHeaders(headers: HeaderReader): SwellContext;
18
4
  export declare function requireString(value: unknown, field: string): asserts value is string;
19
5
  export declare function validateUrl(value: unknown, field: string): string;
20
6
  /** Projects only public configuration. Deliver it with Cache-Control: private, no-store. */
21
- export declare function getStorefrontConfig(headers: HeaderReader): PublicConfig;
7
+ export declare function getStorefrontConfig(context: SwellRequestContext): PublicConfig;
package/dist/context.js CHANGED
@@ -1,15 +1,3 @@
1
- /** Parses trusted ingress headers; does not verify their signature. */
2
- export function parseSwellHeaders(headers) {
3
- const read = (name) => headers.get(`Swell-${name}`) ?? undefined;
4
- return {
5
- storeId: read('Store-Id'), appId: read('App-Id'),
6
- environmentId: read('Environment-Id'), storefrontId: read('Storefront-Id'),
7
- accessToken: read('Access-Token'), publicKey: read('Public-Key'),
8
- apiHost: read('API-Host'), adminUrl: read('Admin-Url'),
9
- vaultUrl: read('Vault-Url'), requestId: read('Request-ID'),
10
- isLocalDev: read('Local-Dev') === 'true',
11
- };
12
- }
13
1
  export function requireString(value, field) {
14
2
  if (typeof value !== 'string' || !value.trim())
15
3
  throw new Error(`Missing or invalid ${field}`);
@@ -29,8 +17,7 @@ export function validateUrl(value, field) {
29
17
  return url.href.replace(/\/$/, '');
30
18
  }
31
19
  /** Projects only public configuration. Deliver it with Cache-Control: private, no-store. */
32
- export function getStorefrontConfig(headers) {
33
- const context = parseSwellHeaders(headers);
20
+ export function getStorefrontConfig(context) {
34
21
  requireString(context.storeId, 'storeId');
35
22
  requireString(context.publicKey, 'publicKey');
36
23
  return {
package/dist/index.d.cts CHANGED
@@ -1,9 +1,11 @@
1
1
  import './guard.cjs';
2
- export { parseSwellHeaders, getStorefrontConfig } from './context.cjs';
3
- export type { HeaderReader, SwellContext } from './context.cjs';
2
+ export { getStorefrontConfig } from './context.cjs';
3
+ export type { HeaderReader } from './context.cjs';
4
+ export { verifySwellContext } from './request-context.cjs';
5
+ export type { SwellRequestContext, SwellHeadersEnv, VerifySwellContextOptions } from './request-context.cjs';
4
6
  export { SwellBackendAPI } from './backend.cjs';
5
7
  export type { BackendOptions, SwellCollection, SwellData, TransactionOperation, TransactionOptions } from './backend.cjs';
6
8
  export { SwellError } from './error.cjs';
7
9
  export type { SwellErrorOptions } from './error.cjs';
8
- export { requireStaff } from './staff.cjs';
9
- export type { StaffIdentity, StaffOptions } from './staff.cjs';
10
+ export { requireStoreUser } from './store-user.cjs';
11
+ export type { StoreUser } from './store-user.cjs';
package/dist/index.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import './guard.js';
2
- export { parseSwellHeaders, getStorefrontConfig } from './context.js';
3
- export type { HeaderReader, SwellContext } from './context.js';
2
+ export { getStorefrontConfig } from './context.js';
3
+ export type { HeaderReader } from './context.js';
4
+ export { verifySwellContext } from './request-context.js';
5
+ export type { SwellRequestContext, SwellHeadersEnv, VerifySwellContextOptions } from './request-context.js';
4
6
  export { SwellBackendAPI } from './backend.js';
5
7
  export type { BackendOptions, SwellCollection, SwellData, TransactionOperation, TransactionOptions } from './backend.js';
6
8
  export { SwellError } from './error.js';
7
9
  export type { SwellErrorOptions } from './error.js';
8
- export { requireStaff } from './staff.js';
9
- export type { StaffIdentity, StaffOptions } from './staff.js';
10
+ export { requireStoreUser } from './store-user.js';
11
+ export type { StoreUser } from './store-user.js';
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import './guard.js';
2
- export { parseSwellHeaders, getStorefrontConfig } from './context.js';
2
+ export { getStorefrontConfig } from './context.js';
3
+ export { verifySwellContext } from './request-context.js';
3
4
  export { SwellBackendAPI } from './backend.js';
4
5
  export { SwellError } from './error.js';
5
- export { requireStaff } from './staff.js';
6
+ export { requireStoreUser } from './store-user.js';
@@ -0,0 +1 @@
1
+ export declare function getVerificationKey(url: string, kid: string): Promise<CryptoKey>;
package/dist/jwks.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function getVerificationKey(url: string, kid: string): Promise<CryptoKey>;
package/dist/jwks.js ADDED
@@ -0,0 +1,70 @@
1
+ import { SwellError } from './error.js';
2
+ const CACHE_TTL = 300_000;
3
+ const REFRESH_INTERVAL = 30_000;
4
+ const FAILURE_RETRY_INTERVAL = 1000;
5
+ const MAX_URLS = 8;
6
+ // Public keys only: no tokens, credentials, identities or request results.
7
+ const keySets = new Map();
8
+ async function refresh(url, entry) {
9
+ entry.refreshAfter = Date.now() + REFRESH_INTERVAL;
10
+ try {
11
+ const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(5000) });
12
+ if (!response.ok)
13
+ throw new Error('JWKS request failed');
14
+ const body = await response.json();
15
+ if (!Array.isArray(body?.keys))
16
+ throw new Error('Invalid JWKS');
17
+ const keys = new Map();
18
+ for (const jwk of body.keys) {
19
+ if (!jwk || jwk.kty !== 'EC' || jwk.crv !== 'P-256' || (jwk.alg && jwk.alg !== 'ES256') || (jwk.use && jwk.use !== 'sig'))
20
+ continue;
21
+ if (typeof jwk.kid !== 'string' || !jwk.kid || keys.has(jwk.kid) || jwk.d)
22
+ throw new Error('Invalid JWKS key');
23
+ keys.set(jwk.kid, await crypto.subtle.importKey('jwk', jwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']));
24
+ }
25
+ if (!keys.size)
26
+ throw new Error('Empty JWKS');
27
+ entry.keys = keys;
28
+ entry.expiresAt = Date.now() + CACHE_TTL;
29
+ entry.error = undefined;
30
+ }
31
+ catch {
32
+ entry.refreshAfter = Date.now() + FAILURE_RETRY_INTERVAL;
33
+ entry.error = new SwellError('Swell verification keys unavailable', { status: 503, code: 'swell_jwks_unavailable' });
34
+ throw entry.error;
35
+ }
36
+ }
37
+ export async function getVerificationKey(url, kid) {
38
+ let entry = keySets.get(url);
39
+ if (!entry) {
40
+ if (keySets.size >= MAX_URLS)
41
+ keySets.delete(keySets.keys().next().value);
42
+ entry = { keys: new Map(), expiresAt: 0, refreshAfter: 0 };
43
+ keySets.set(url, entry);
44
+ }
45
+ const now = Date.now();
46
+ const cached = entry.keys.get(kid);
47
+ if (cached && now < entry.expiresAt)
48
+ return cached;
49
+ if (entry.pending) {
50
+ await entry.pending;
51
+ }
52
+ else if (now >= entry.expiresAt || !entry.keys.has(kid)) {
53
+ if (now >= entry.refreshAfter) {
54
+ entry.pending = refresh(url, entry);
55
+ try {
56
+ await entry.pending;
57
+ }
58
+ finally {
59
+ entry.pending = undefined;
60
+ }
61
+ }
62
+ else if (now >= entry.expiresAt) {
63
+ throw entry.error ?? new SwellError('Swell verification keys unavailable', { status: 503, code: 'swell_jwks_unavailable' });
64
+ }
65
+ }
66
+ const key = entry.keys.get(kid);
67
+ if (!key)
68
+ throw new SwellError('Unknown Swell signing key', { status: 401, code: 'invalid_swell_context' });
69
+ return key;
70
+ }
@@ -0,0 +1,37 @@
1
+ import type { HeaderReader } from './context.cjs';
2
+ import type { StoreUser } from './store-user.cjs';
3
+ export interface SwellHeadersEnv {
4
+ SWELL_VERIFY_HEADERS?: string;
5
+ SWELL_HEADERS_JWKS_URL?: string;
6
+ }
7
+ export interface VerifySwellContextOptions {
8
+ /** Explicit Worker bindings or server configuration. Omit to read process.env when available. */
9
+ env?: SwellHeadersEnv;
10
+ /** Expected destination IDs from trusted app configuration, never incoming headers. */
11
+ appId?: string;
12
+ storeId?: string;
13
+ /** Trusted vault override; the unsigned Swell-Vault-Url header is not used. */
14
+ vaultUrl?: string;
15
+ }
16
+ /** Request-local server data. Only getStorefrontConfig's projection may be sent to the browser. */
17
+ export interface SwellRequestContext {
18
+ readonly storeId: string;
19
+ readonly appId: string;
20
+ readonly installationId: string;
21
+ readonly environmentId?: string;
22
+ readonly storefrontId?: string;
23
+ readonly apiHost: string;
24
+ readonly adminUrl: string;
25
+ readonly accessToken?: string;
26
+ readonly publicKey?: string;
27
+ readonly requestId?: string;
28
+ readonly vaultUrl?: string;
29
+ readonly storeUser: Readonly<StoreUser> | null;
30
+ /** False only when trusted runtime configuration explicitly disables verification. */
31
+ readonly signatureVerified: boolean;
32
+ }
33
+ /**
34
+ * Resolves Swell-Context once per request. Uses ES256 and a pinned JWKS endpoint;
35
+ * never discovers keys from the request. No fallback to plain headers or dashboard cookies.
36
+ */
37
+ export declare function verifySwellContext(headers: HeaderReader, options?: VerifySwellContextOptions): Promise<SwellRequestContext>;
@@ -0,0 +1,37 @@
1
+ import type { HeaderReader } from './context.js';
2
+ import type { StoreUser } from './store-user.js';
3
+ export interface SwellHeadersEnv {
4
+ SWELL_VERIFY_HEADERS?: string;
5
+ SWELL_HEADERS_JWKS_URL?: string;
6
+ }
7
+ export interface VerifySwellContextOptions {
8
+ /** Explicit Worker bindings or server configuration. Omit to read process.env when available. */
9
+ env?: SwellHeadersEnv;
10
+ /** Expected destination IDs from trusted app configuration, never incoming headers. */
11
+ appId?: string;
12
+ storeId?: string;
13
+ /** Trusted vault override; the unsigned Swell-Vault-Url header is not used. */
14
+ vaultUrl?: string;
15
+ }
16
+ /** Request-local server data. Only getStorefrontConfig's projection may be sent to the browser. */
17
+ export interface SwellRequestContext {
18
+ readonly storeId: string;
19
+ readonly appId: string;
20
+ readonly installationId: string;
21
+ readonly environmentId?: string;
22
+ readonly storefrontId?: string;
23
+ readonly apiHost: string;
24
+ readonly adminUrl: string;
25
+ readonly accessToken?: string;
26
+ readonly publicKey?: string;
27
+ readonly requestId?: string;
28
+ readonly vaultUrl?: string;
29
+ readonly storeUser: Readonly<StoreUser> | null;
30
+ /** False only when trusted runtime configuration explicitly disables verification. */
31
+ readonly signatureVerified: boolean;
32
+ }
33
+ /**
34
+ * Resolves Swell-Context once per request. Uses ES256 and a pinned JWKS endpoint;
35
+ * never discovers keys from the request. No fallback to plain headers or dashboard cookies.
36
+ */
37
+ export declare function verifySwellContext(headers: HeaderReader, options?: VerifySwellContextOptions): Promise<SwellRequestContext>;
@@ -0,0 +1,95 @@
1
+ import { requireString, validateUrl } from './context.js';
2
+ import { SwellError } from './error.js';
3
+ import { getVerificationKey } from './jwks.js';
4
+ function invalidContext() {
5
+ return new SwellError('Missing or invalid Swell context', { status: 401, code: 'invalid_swell_context' });
6
+ }
7
+ function decode(value) {
8
+ if (!/^[A-Za-z0-9_-]+$/.test(value))
9
+ throw invalidContext();
10
+ return Uint8Array.from(atob(value.replace(/-/g, '+').replace(/_/g, '/')), char => char.charCodeAt(0));
11
+ }
12
+ function object(value) {
13
+ if (!value || typeof value !== 'object' || Array.isArray(value))
14
+ throw invalidContext();
15
+ }
16
+ /**
17
+ * Resolves Swell-Context once per request. Uses ES256 and a pinned JWKS endpoint;
18
+ * never discovers keys from the request. No fallback to plain headers or dashboard cookies.
19
+ */
20
+ export async function verifySwellContext(headers, options = {}) {
21
+ const runtime = globalThis;
22
+ const env = options.env ?? runtime.process?.env ?? {};
23
+ const verify = env.SWELL_VERIFY_HEADERS !== 'false';
24
+ const jwksUrl = validateUrl(env.SWELL_HEADERS_JWKS_URL ?? 'https://swell.store/.well-known/jwks.json', 'SWELL_HEADERS_JWKS_URL');
25
+ for (const field of ['appId', 'storeId']) {
26
+ if (options[field] !== undefined)
27
+ requireString(options[field], field);
28
+ }
29
+ const vaultUrl = options.vaultUrl === undefined ? undefined : validateUrl(options.vaultUrl, 'vaultUrl');
30
+ const token = headers.get('Swell-Context');
31
+ if (!token || token.length > 16_384)
32
+ throw invalidContext();
33
+ let payload;
34
+ let header;
35
+ let signature;
36
+ const parts = token.split('.');
37
+ try {
38
+ if (parts.length !== 3)
39
+ throw invalidContext();
40
+ header = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(decode(parts[0])));
41
+ payload = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(decode(parts[1])));
42
+ object(header);
43
+ object(payload);
44
+ signature = decode(parts[2]);
45
+ if (header.alg !== 'ES256' || typeof header.kid !== 'string' || !header.kid || header.crit !== undefined || header.b64 !== undefined || signature.length !== 64)
46
+ throw invalidContext();
47
+ }
48
+ catch {
49
+ throw invalidContext();
50
+ }
51
+ if (verify) {
52
+ const key = await getVerificationKey(jwksUrl, header.kid);
53
+ if (!await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, signature, new TextEncoder().encode(`${parts[0]}.${parts[1]}`)))
54
+ throw invalidContext();
55
+ }
56
+ try {
57
+ const now = Date.now() / 1000;
58
+ // The issuer controls token lifetime; allow five seconds for clock skew.
59
+ if (typeof payload.iat !== 'number' || !Number.isFinite(payload.iat) || payload.iat > now + 5 ||
60
+ typeof payload.exp !== 'number' || !Number.isFinite(payload.exp) || payload.exp <= now - 5 ||
61
+ payload.exp <= payload.iat)
62
+ throw invalidContext();
63
+ if (payload.nbf !== undefined && (typeof payload.nbf !== 'number' || !Number.isFinite(payload.nbf) || payload.nbf > now + 5))
64
+ throw invalidContext();
65
+ for (const field of ['store_id', 'app_id', 'installation_id'])
66
+ requireString(payload[field], field);
67
+ if (payload.aud !== payload.app_id || (options.appId !== undefined && payload.aud !== options.appId) ||
68
+ (options.storeId !== undefined && payload.store_id !== options.storeId))
69
+ throw invalidContext();
70
+ for (const field of ['environment_id', 'storefront_id']) {
71
+ if (payload[field] != null)
72
+ requireString(payload[field], field);
73
+ }
74
+ let storeUser = null;
75
+ if (payload.admin !== null) {
76
+ object(payload.admin);
77
+ requireString(payload.admin.user_id, 'admin.user_id');
78
+ storeUser = Object.freeze({ userId: payload.admin.user_id, storeId: payload.store_id });
79
+ }
80
+ return Object.freeze({
81
+ storeId: payload.store_id, appId: payload.app_id,
82
+ installationId: payload.installation_id,
83
+ environmentId: payload.environment_id ?? undefined,
84
+ storefrontId: payload.storefront_id ?? undefined,
85
+ apiHost: validateUrl(payload.api_host, 'api_host'), adminUrl: validateUrl(payload.admin_url, 'admin_url'),
86
+ accessToken: headers.get('Swell-Access-Token') ?? undefined,
87
+ publicKey: headers.get('Swell-Public-Key') ?? undefined,
88
+ requestId: headers.get('Swell-Request-ID') ?? undefined,
89
+ vaultUrl, storeUser, signatureVerified: verify,
90
+ });
91
+ }
92
+ catch {
93
+ throw invalidContext();
94
+ }
95
+ }
@@ -0,0 +1,7 @@
1
+ import type { SwellRequestContext } from './request-context.cjs';
2
+ export interface StoreUser {
3
+ readonly userId: string;
4
+ readonly storeId: string;
5
+ }
6
+ /** Identity only; any dashboard role counts. The proxy owns write-origin checks; the app owns permissions. */
7
+ export declare function requireStoreUser(context: SwellRequestContext): StoreUser;
@@ -0,0 +1,7 @@
1
+ import type { SwellRequestContext } from './request-context.js';
2
+ export interface StoreUser {
3
+ readonly userId: string;
4
+ readonly storeId: string;
5
+ }
6
+ /** Identity only; any dashboard role counts. The proxy owns write-origin checks; the app owns permissions. */
7
+ export declare function requireStoreUser(context: SwellRequestContext): StoreUser;
@@ -0,0 +1,7 @@
1
+ import { SwellError } from './error.js';
2
+ /** Identity only; any dashboard role counts. The proxy owns write-origin checks; the app owns permissions. */
3
+ export function requireStoreUser(context) {
4
+ if (!context.storeUser)
5
+ throw new SwellError('Store user required', { status: 401, code: 'store_user_required' });
6
+ return context.storeUser;
7
+ }
@@ -1 +1 @@
1
- export declare const USER_AGENT = "swell-apps-sdk/2.0.0-alpha.0";
1
+ export declare const USER_AGENT = "swell-apps-sdk/2.0.0-alpha.2";
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const USER_AGENT = "swell-apps-sdk/2.0.0-alpha.0";
1
+ export declare const USER_AGENT = "swell-apps-sdk/2.0.0-alpha.2";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Replaced with the package version by the build.
2
- export const USER_AGENT = 'swell-apps-sdk/2.0.0-alpha.0';
2
+ export const USER_AGENT = 'swell-apps-sdk/2.0.0-alpha.2';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swell/apps-sdk",
3
- "version": "2.0.0-alpha.0",
3
+ "version": "2.0.0-alpha.2",
4
4
  "description": "Server SDK for Swell apps.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/dist/staff.d.cts DELETED
@@ -1,16 +0,0 @@
1
- import type { HeaderReader } from './context.cjs';
2
- export interface StaffIdentity {
3
- userId: string;
4
- storeId: string;
5
- }
6
- export interface StaffOptions {
7
- headers: HeaderReader;
8
- method: string;
9
- /** Trusted app/iframe origin, never an unchecked forwarded-host value. */
10
- origin: string;
11
- cookies: {
12
- get(name: string): string | undefined;
13
- };
14
- }
15
- /** Verifies store staff identity only. The application still owns authorization policy. */
16
- export declare function requireStaff({ headers, method, origin, cookies }: StaffOptions): Promise<StaffIdentity>;
package/dist/staff.d.ts DELETED
@@ -1,16 +0,0 @@
1
- import type { HeaderReader } from './context.js';
2
- export interface StaffIdentity {
3
- userId: string;
4
- storeId: string;
5
- }
6
- export interface StaffOptions {
7
- headers: HeaderReader;
8
- method: string;
9
- /** Trusted app/iframe origin, never an unchecked forwarded-host value. */
10
- origin: string;
11
- cookies: {
12
- get(name: string): string | undefined;
13
- };
14
- }
15
- /** Verifies store staff identity only. The application still owns authorization policy. */
16
- export declare function requireStaff({ headers, method, origin, cookies }: StaffOptions): Promise<StaffIdentity>;
package/dist/staff.js DELETED
@@ -1,33 +0,0 @@
1
- import { parseSwellHeaders, requireString, validateUrl } from './context.js';
2
- import { SwellError } from './error.js';
3
- import { USER_AGENT } from './version.js';
4
- /** Verifies store staff identity only. The application still owns authorization policy. */
5
- export async function requireStaff({ headers, method, origin, cookies }) {
6
- const { storeId, adminUrl } = parseSwellHeaders(headers);
7
- requireString(storeId, 'storeId');
8
- const url = validateUrl(adminUrl, 'adminUrl');
9
- requireString(method, 'method');
10
- if (method.toUpperCase() !== 'GET') {
11
- const expected = validateUrl(origin, 'origin');
12
- const supplied = headers.get('Origin');
13
- if (new URL(expected).origin !== origin)
14
- throw new Error('origin must be an absolute app origin');
15
- if (supplied !== origin || (headers.get('Sec-Fetch-Site') !== null && headers.get('Sec-Fetch-Site') !== 'same-origin')) {
16
- throw new SwellError('Staff request origin rejected', { status: 403 });
17
- }
18
- }
19
- const sessionId = cookies.get('_swell_admin_session');
20
- if (!sessionId)
21
- throw new SwellError('Staff session required', { status: 401 });
22
- const response = await fetch(new URL('/admin/api/session', url), {
23
- headers: { 'X-Session': sessionId, 'User-Agent': USER_AGENT }, redirect: 'manual',
24
- });
25
- if (response.status === 401 || response.status === 403)
26
- throw new SwellError('Invalid staff session', { status: 401 });
27
- if (!response.ok)
28
- throw new SwellError('Staff session verification failed', { status: response.status });
29
- const session = await response.json();
30
- if (typeof session?.user_id !== 'string' || !session.user_id || session.client_id !== storeId)
31
- throw new SwellError('Invalid staff session', { status: 401 });
32
- return { userId: session.user_id, storeId };
33
- }