@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 +66 -31
- package/dist/backend.d.cts +5 -5
- package/dist/backend.d.ts +5 -5
- package/dist/backend.js +8 -6
- package/dist/context.d.cts +2 -16
- package/dist/context.d.ts +2 -16
- package/dist/context.js +1 -14
- package/dist/index.d.cts +5 -3
- package/dist/index.d.ts +5 -3
- package/dist/index.js +2 -1
- package/dist/jwks.d.cts +1 -0
- package/dist/jwks.d.ts +1 -0
- package/dist/jwks.js +70 -0
- package/dist/request-context.d.cts +37 -0
- package/dist/request-context.d.ts +37 -0
- package/dist/request-context.js +99 -0
- package/dist/staff.d.cts +5 -14
- package/dist/staff.d.ts +5 -14
- package/dist/staff.js +5 -31
- package/dist/version.d.cts +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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
|
-
###
|
|
24
|
+
### Request context
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
//
|
|
39
|
-
const backend = new SwellBackendAPI({
|
|
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
|
|
62
|
-
const config = getStorefrontConfig(
|
|
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
|
-
|
|
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 =
|
|
102
|
-
|
|
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
|
-
`
|
|
114
|
-
|
|
115
|
-
|
|
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 `
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
-
|
|
189
|
-
|
|
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
|
|
package/dist/backend.d.cts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { SwellRequestContext } from './request-context.cjs';
|
|
2
2
|
export type SwellData = Record<string, any>;
|
|
3
3
|
export type BackendOptions = {
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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 {
|
|
1
|
+
import type { SwellRequestContext } from './request-context.js';
|
|
2
2
|
export type SwellData = Record<string, any>;
|
|
3
3
|
export type BackendOptions = {
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
|
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 (
|
|
69
|
-
throw new Error('
|
|
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.
|
|
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))
|
package/dist/context.d.cts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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 {
|
|
3
|
-
export type { HeaderReader
|
|
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
|
|
11
|
+
export type { StaffIdentity } from './staff.cjs';
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import './guard.js';
|
|
2
|
-
export {
|
|
3
|
-
export type { HeaderReader
|
|
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
|
|
11
|
+
export type { StaffIdentity } from './staff.js';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import './guard.js';
|
|
2
|
-
export {
|
|
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';
|
package/dist/jwks.d.cts
ADDED
|
@@ -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 {
|
|
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
|
-
|
|
7
|
-
|
|
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 {
|
|
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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
}
|
package/dist/version.d.cts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const USER_AGENT = "swell-apps-sdk/2.0.0-alpha.
|
|
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.
|
|
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.
|
|
2
|
+
export const USER_AGENT = 'swell-apps-sdk/2.0.0-alpha.1';
|