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

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
@@ -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 staff identity with
27
+ each request. Verify this context once, then reuse it to create clients and check staff
28
+ 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,9 +94,7 @@ 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
99
  ### Staff identity
94
100
 
@@ -98,21 +104,18 @@ store before applying your application's permission checks:
98
104
  ```ts
99
105
  import { requireStaff } from '@swell/apps-sdk';
100
106
 
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
- });
107
+ const staff = requireStaff(context); // { userId, storeId }, or a 401 SwellError.
108
+ const optionalStaff = context.staff; // null for a visitor; no exception needed.
107
109
  ```
108
110
 
109
111
  ## API reference
110
112
 
111
113
  ### Backend client
112
114
 
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.
115
+ Pass `{ context }` for a frontend request, or explicit credentials for an external
116
+ server. Backend calls require an access token or secret key and an absolute HTTP(S)
117
+ `apiHost`. Do not mix input sources. Invalid constructor options throw immediately;
118
+ all backend methods return promises and reject on failure.
116
119
 
117
120
  | Method | Result |
118
121
  | --- | --- |
@@ -142,7 +145,7 @@ defaults: `limit` counts additional attempts, and delays are in milliseconds. On
142
145
  `transaction_conflict` and `transaction_throttled` retry. Ordinary requests and network
143
146
  failures are not retried.
144
147
 
145
- **Private functions:** use the app slug from `Swell-App-Id` and authorize the caller
148
+ **Private functions:** use the app slug from `context.appId` and authorize the caller
146
149
  first. `options.method` defaults to `post`; `get`, `put` and `delete` are also supported.
147
150
  GET data must contain only flat string, number or boolean values. Caller headers are
148
151
  not forwarded; response status and headers are not returned. Function errors and
@@ -166,14 +169,44 @@ rotation during GET requests. A supplied writer may throw or skip a write; after
166
169
  its reader must still report the actual state. Writer return values are ignored.
167
170
  Persist cookies in writable route handlers or actions.
168
171
 
169
- For caching, replace `storefront.request` before first use. The SDK has no built-in cache.
172
+ For API response caching, replace `storefront.request` before first use. The SDK
173
+ leaves response caching to your application.
174
+
175
+ ### Request context options
176
+
177
+ `verifySwellContext(headers, { env?, appId?, storeId?, vaultUrl? })` returns the request
178
+ context or throws if verification fails. Set `appId` and, for a single-store app,
179
+ `storeId` from trusted configuration to reject contexts intended for another app or
180
+ store. Without these options, it verifies the source but does not restrict the
181
+ destination. `vaultUrl` provides an optional vault endpoint override.
182
+
183
+ Configuration is read from `process.env`, or from `env` when supplied. Workers without
184
+ Node compatibility should pass their bindings as `env`.
185
+
186
+ | Variable | Default | Purpose |
187
+ | --- | --- | --- |
188
+ | `SWELL_VERIFY_HEADERS` | enabled | Set exactly `"false"` to skip signature verification during local development. |
189
+ | `SWELL_HEADERS_JWKS_URL` | `https://swell.store/.well-known/jwks.json` | Override the verification-key endpoint; its origin must match the token's issuer. |
190
+
191
+ For local development against a local Swell instance, put `SWELL_VERIFY_HEADERS=false`
192
+ in `.dev.vars`. Remove it or set it to `"true"` to restore verification. Token structure
193
+ and claim validation still apply, but the context is no longer authenticated: anyone
194
+ who can reach the frontend directly can supply forged context, including staff identity.
195
+ Use this bypass only for local development.
196
+
197
+ For server integrations, construct `SwellBackendAPI` with explicit credentials and
198
+ `createStorefrontClient` with explicit public configuration. These clients do not
199
+ require an HTTP request or staff identity.
200
+
201
+ ### Staff identity
170
202
 
171
- ### Staff verification
203
+ `requireStaff(context)` returns `{ userId, storeId }` or throws `SwellError` with
204
+ status 401 and code `staff_required`. For optional staff access, read `context.staff`,
205
+ which is null for visitors.
172
206
 
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.
207
+ Staff includes any signed-in dashboard user of the store, including partners and Swell
208
+ support. Swell's proxy handles staff authentication and write-origin checks; your
209
+ application decides what each staff member may do.
177
210
 
178
211
  ### Errors
179
212
 
@@ -185,8 +218,10 @@ error handling; `message` is for people and may change.
185
218
  `body`. Successful GET responses containing `errors` are returned as data.
186
219
  - Function invocation failures retain the response payload, or the invocation envelope
187
220
  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`.
221
+ - Header verification uses 401 / `invalid_swell_context` for absent, malformed, expired
222
+ or rejected tokens, and 503 / `swell_jwks_unavailable` for key-service failures.
223
+ - Backend/storefront network errors remain native. Local configuration errors may be
224
+ ordinary `Error` instances; not every failure is a `SwellError`.
190
225
 
191
226
  ## Development
192
227
 
@@ -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
10
  export { requireStaff } from './staff.cjs';
9
- export type { StaffIdentity, StaffOptions } from './staff.cjs';
11
+ export type { StaffIdentity } from './staff.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
10
  export { requireStaff } from './staff.js';
9
- export type { StaffIdentity, StaffOptions } from './staff.js';
11
+ export type { StaffIdentity } from './staff.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
6
  export { requireStaff } from './staff.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 { StaffIdentity } from './staff.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 staff: Readonly<StaffIdentity> | 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 staff 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 { StaffIdentity } from './staff.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 staff: Readonly<StaffIdentity> | 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 staff cookies.
36
+ */
37
+ export declare function verifySwellContext(headers: HeaderReader, options?: VerifySwellContextOptions): Promise<SwellRequestContext>;
@@ -0,0 +1,99 @@
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 staff 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
+ const issuer = new URL(jwksUrl).origin;
26
+ for (const field of ['appId', 'storeId']) {
27
+ if (options[field] !== undefined)
28
+ requireString(options[field], field);
29
+ }
30
+ const vaultUrl = options.vaultUrl === undefined ? undefined : validateUrl(options.vaultUrl, 'vaultUrl');
31
+ const token = headers.get('Swell-Context');
32
+ if (!token || token.length > 16_384)
33
+ throw invalidContext();
34
+ let payload;
35
+ let header;
36
+ let signature;
37
+ const parts = token.split('.');
38
+ try {
39
+ if (parts.length !== 3)
40
+ throw invalidContext();
41
+ header = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(decode(parts[0])));
42
+ payload = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(decode(parts[1])));
43
+ object(header);
44
+ object(payload);
45
+ signature = decode(parts[2]);
46
+ if (header.alg !== 'ES256' || typeof header.kid !== 'string' || !header.kid || header.crit !== undefined || header.b64 !== undefined || signature.length !== 64)
47
+ throw invalidContext();
48
+ }
49
+ catch {
50
+ throw invalidContext();
51
+ }
52
+ if (verify) {
53
+ const key = await getVerificationKey(jwksUrl, header.kid);
54
+ if (!await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, signature, new TextEncoder().encode(`${parts[0]}.${parts[1]}`)))
55
+ throw invalidContext();
56
+ }
57
+ try {
58
+ const now = Date.now() / 1000;
59
+ // The issuer controls token lifetime; allow five seconds for clock skew.
60
+ if (typeof payload.iat !== 'number' || !Number.isFinite(payload.iat) || payload.iat > now + 5 ||
61
+ typeof payload.exp !== 'number' || !Number.isFinite(payload.exp) || payload.exp <= now - 5 ||
62
+ payload.exp <= payload.iat)
63
+ throw invalidContext();
64
+ if (payload.nbf !== undefined && (typeof payload.nbf !== 'number' || !Number.isFinite(payload.nbf) || payload.nbf > now + 5))
65
+ throw invalidContext();
66
+ requireString(payload.iss, 'iss');
67
+ if (verify && payload.iss !== issuer)
68
+ throw invalidContext();
69
+ for (const field of ['store_id', 'app_id', 'installation_id'])
70
+ requireString(payload[field], field);
71
+ if (payload.aud !== payload.app_id || (options.appId !== undefined && payload.aud !== options.appId) ||
72
+ (options.storeId !== undefined && payload.store_id !== options.storeId))
73
+ throw invalidContext();
74
+ for (const field of ['environment_id', 'storefront_id']) {
75
+ if (payload[field] != null)
76
+ requireString(payload[field], field);
77
+ }
78
+ let staff = null;
79
+ if (payload.admin !== null) {
80
+ object(payload.admin);
81
+ requireString(payload.admin.user_id, 'admin.user_id');
82
+ staff = Object.freeze({ userId: payload.admin.user_id, storeId: payload.store_id });
83
+ }
84
+ return Object.freeze({
85
+ storeId: payload.store_id, appId: payload.app_id,
86
+ installationId: payload.installation_id,
87
+ environmentId: payload.environment_id ?? undefined,
88
+ storefrontId: payload.storefront_id ?? undefined,
89
+ apiHost: validateUrl(payload.api_host, 'api_host'), adminUrl: validateUrl(payload.admin_url, 'admin_url'),
90
+ accessToken: headers.get('Swell-Access-Token') ?? undefined,
91
+ publicKey: headers.get('Swell-Public-Key') ?? undefined,
92
+ requestId: headers.get('Swell-Request-ID') ?? undefined,
93
+ vaultUrl, staff, signatureVerified: verify,
94
+ });
95
+ }
96
+ catch {
97
+ throw invalidContext();
98
+ }
99
+ }
package/dist/staff.d.cts CHANGED
@@ -1,16 +1,7 @@
1
- import type { HeaderReader } from './context.cjs';
1
+ import type { SwellRequestContext } from './request-context.cjs';
2
2
  export interface StaffIdentity {
3
- userId: string;
4
- storeId: string;
3
+ readonly userId: string;
4
+ readonly storeId: string;
5
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>;
6
+ /** Identity only; any dashboard role counts. The proxy owns write-origin checks; the app owns permissions. */
7
+ export declare function requireStaff(context: SwellRequestContext): StaffIdentity;
package/dist/staff.d.ts CHANGED
@@ -1,16 +1,7 @@
1
- import type { HeaderReader } from './context.js';
1
+ import type { SwellRequestContext } from './request-context.js';
2
2
  export interface StaffIdentity {
3
- userId: string;
4
- storeId: string;
3
+ readonly userId: string;
4
+ readonly storeId: string;
5
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>;
6
+ /** Identity only; any dashboard role counts. The proxy owns write-origin checks; the app owns permissions. */
7
+ export declare function requireStaff(context: SwellRequestContext): StaffIdentity;
package/dist/staff.js CHANGED
@@ -1,33 +1,7 @@
1
- import { parseSwellHeaders, requireString, validateUrl } from './context.js';
2
1
  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 };
2
+ /** Identity only; any dashboard role counts. The proxy owns write-origin checks; the app owns permissions. */
3
+ export function requireStaff(context) {
4
+ if (!context.staff)
5
+ throw new SwellError('Staff identity required', { status: 401, code: 'staff_required' });
6
+ return context.staff;
33
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.1";
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.1";
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.1';
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.1",
4
4
  "description": "Server SDK for Swell apps.",
5
5
  "type": "module",
6
6
  "license": "MIT",