shopify-static-offline-auth 0.0.0-stage → 0.1.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/LICENSE.md +11 -0
- package/README.md +192 -2
- package/dist/cjs/app.js +175 -0
- package/dist/cjs/app.js.map +1 -0
- package/dist/cjs/errors.js +40 -0
- package/dist/cjs/errors.js.map +1 -0
- package/dist/cjs/index.js +15 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/logger.js +26 -0
- package/dist/cjs/logger.js.map +1 -0
- package/dist/cjs/storage.js +57 -0
- package/dist/cjs/storage.js.map +1 -0
- package/dist/cjs/stores.js +66 -0
- package/dist/cjs/stores.js.map +1 -0
- package/dist/esm/app.mjs +173 -0
- package/dist/esm/app.mjs.map +1 -0
- package/dist/esm/errors.mjs +35 -0
- package/dist/esm/errors.mjs.map +1 -0
- package/dist/esm/index.mjs +4 -0
- package/dist/esm/index.mjs.map +1 -0
- package/dist/esm/logger.mjs +24 -0
- package/dist/esm/logger.mjs.map +1 -0
- package/dist/esm/storage.mjs +55 -0
- package/dist/esm/storage.mjs.map +1 -0
- package/dist/esm/stores.mjs +61 -0
- package/dist/esm/stores.mjs.map +1 -0
- package/dist/ts/app.d.ts +2 -0
- package/dist/ts/errors.d.ts +20 -0
- package/dist/ts/index.d.ts +5 -0
- package/dist/ts/logger.d.ts +8 -0
- package/dist/ts/storage.d.ts +14 -0
- package/dist/ts/stores.d.ts +12 -0
- package/dist/ts/types.d.ts +37 -0
- package/package.json +54 -4
package/LICENSE.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 shopify-static-offline-auth contributors
|
|
4
|
+
Portions derived from Shopify app library integration patterns:
|
|
5
|
+
Copyright (c) 2022-present, Shopify Inc.
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
8
|
+
|
|
9
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
10
|
+
|
|
11
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,193 @@
|
|
|
1
|
-
#
|
|
1
|
+
# shopify-static-offline-auth
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Pre-provisioned, non-expiring offline access tokens for **custom-distributed Shopify React Router apps**, without a session database.
|
|
4
|
+
|
|
5
|
+
This independent package wraps the official Shopify library. It preserves Shopify's embedded-app JWT validation and uses a server-side store allowlist to supply credentials. It is not an official Shopify package and does not bypass authentication.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Node.js 22 or later.
|
|
10
|
+
- `@shopify/shopify-app-react-router` `^4.0.2`, `@shopify/shopify-api` `^15.1.1`, and `@shopify/shopify-app-session-storage` `^7.0.3`.
|
|
11
|
+
- An embedded app with Dev Dashboard **custom distribution** (`SingleMerchant`).
|
|
12
|
+
- An existing **non-expiring offline token for this same app and each configured store**. This package cannot inspect an opaque token to prove its type or expiration.
|
|
13
|
+
|
|
14
|
+
It does not support public App Store apps, legacy admin-created apps, online tokens, refresh tokens, Remix, Billing, dynamic scope requests, Flow, POS, app proxies, or Storefront clients.
|
|
15
|
+
|
|
16
|
+
Custom apps can currently use non-expiring tokens. Tokens can still be revoked, including by uninstalling the app or revoking its secret. See [Shopify access tokens](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens).
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npm install shopify-static-offline-auth @shopify/shopify-app-react-router@^4.0.2 @shopify/shopify-api@^15.1.1 @shopify/shopify-app-session-storage@^7.0.3
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Keep your existing React Router/React dependencies and Shopify framework setup.
|
|
25
|
+
|
|
26
|
+
## Configure
|
|
27
|
+
|
|
28
|
+
Set the following **server-only** environment variable or secret. The values below are placeholders, not usable tokens:
|
|
29
|
+
|
|
30
|
+
```dotenv
|
|
31
|
+
SHOPIFY_STATIC_OFFLINE_STORES='{"example.myshopify.com":{"accessToken":"REPLACE_WITH_NON_EXPIRING_OFFLINE_TOKEN","scopes":["read_products"]}}'
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
In your application's `app/shopify.server.ts`:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import {ApiVersion} from '@shopify/shopify-app-react-router/server';
|
|
38
|
+
import {
|
|
39
|
+
createStaticOfflineShopifyApp,
|
|
40
|
+
parseStaticOfflineStores,
|
|
41
|
+
} from 'shopify-static-offline-auth';
|
|
42
|
+
|
|
43
|
+
const shopify = createStaticOfflineShopifyApp({
|
|
44
|
+
apiKey: process.env.SHOPIFY_API_KEY!,
|
|
45
|
+
apiSecretKey: process.env.SHOPIFY_API_SECRET!,
|
|
46
|
+
appUrl: process.env.SHOPIFY_APP_URL!,
|
|
47
|
+
apiVersion: ApiVersion.October26,
|
|
48
|
+
scopes: ['read_products'],
|
|
49
|
+
stores: parseStaticOfflineStores(process.env.SHOPIFY_STATIC_OFFLINE_STORES!),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export default shopify;
|
|
53
|
+
export const authenticate = shopify.authenticate;
|
|
54
|
+
export const unauthenticated = shopify.unauthenticated;
|
|
55
|
+
export const login = shopify.login;
|
|
56
|
+
export const addDocumentResponseHeaders = shopify.addDocumentResponseHeaders;
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The package reads no environment variables. You can instead supply a `stores` object from your secret manager **before** constructing the app. Store keys must be exact lowercase `<shop>.myshopify.com` domains, with no protocol, path, port, wildcard, or custom storefront domain. At least one store is required. Each record must contain only `accessToken` and a `scopes` array; an empty scope array is valid only when the app requires no scopes.
|
|
60
|
+
|
|
61
|
+
The map is copied and frozen when the app is constructed. It is a credential snapshot, not a live secret subscription. Every session read creates a fresh `Session`; changing a returned session does not change the snapshot or another request's credentials.
|
|
62
|
+
|
|
63
|
+
`scopes` on a store record describes permissions **already granted** to its token. Required app scopes must be included (including Shopify's implied write-to-read permissions). This is a local consistency check, not a remote grant verification. Editing scopes or omitting an expiration field cannot grant permissions or turn an expiring token into a permanent token.
|
|
64
|
+
|
|
65
|
+
`SingleMerchant`, `useOnlineTokens: false`, and `expiringOfflineAccessTokens: false` are enforced. An explicitly conflicting option fails at startup. `sessionStorage`, `hooks`, `billing`, code-based `webhooks`, custom shop domains, and global access-token overrides are rejected. Configure webhooks in TOML instead.
|
|
66
|
+
|
|
67
|
+
## Supported interfaces
|
|
68
|
+
|
|
69
|
+
### Authenticated Admin requests
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
const {admin, session, sessionToken, cors, redirect} =
|
|
73
|
+
await authenticate.admin(request);
|
|
74
|
+
const response = await admin.graphql('{ shop { name } }');
|
|
75
|
+
return cors(Response.json(await response.json()));
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Keep the template's App Bridge/AppProvider, auth catch-all route, `boundary` imports, and document-response-header integration from `@shopify/shopify-app-react-router`. Header JWTs and document `id_token` requests continue through the official authentication flow. Initial App Bridge redirects to obtain a JWT are normal; the wrapper does not exchange it for another access token.
|
|
79
|
+
|
|
80
|
+
The JWT's validated destination selects the credential. A query-string `shop` cannot select another store's token. An unconfigured destination produces a `403` response without token exchange. JWT failures retain Shopify's normal responses and bootstrap behavior.
|
|
81
|
+
|
|
82
|
+
The context exposes only `admin`, `session`, `sessionToken`, `cors`, and `redirect`. Offline requests use the app installation's permissions; they do not acquire staff-specific online tokens. Never return a `session`, token map, or server configuration to the browser.
|
|
83
|
+
|
|
84
|
+
### Webhooks
|
|
85
|
+
|
|
86
|
+
Define app-specific webhook subscriptions in your existing `shopify.app.toml`:
|
|
87
|
+
|
|
88
|
+
```toml
|
|
89
|
+
[webhooks]
|
|
90
|
+
api_version = "2026-10"
|
|
91
|
+
|
|
92
|
+
[[webhooks.subscriptions]]
|
|
93
|
+
topics = ["app/uninstalled"]
|
|
94
|
+
uri = "/webhooks/app/uninstalled"
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
const {topic, shop, session, admin, payload} =
|
|
99
|
+
await authenticate.webhook(request);
|
|
100
|
+
|
|
101
|
+
if (topic === 'APP_UNINSTALLED') {
|
|
102
|
+
// Perform your application-specific cleanup or operator notification.
|
|
103
|
+
// Remove this shop's configured credential and redeploy all instances.
|
|
104
|
+
return new Response(null, {status: 200});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Normal webhook context is available here. Check session/admin before use.
|
|
108
|
+
return new Response(null, {status: 200});
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The official library verifies the raw-body HMAC first. Valid deliveries for an unconfigured shop throw an empty `200` Response, which the route must allow React Router to handle; business logic after authentication does not run. Invalid HMACs are still rejected. `APP_UNINSTALLED` deliveries expose their metadata and payload but no `session` or `admin`.
|
|
112
|
+
|
|
113
|
+
The wrapper does **not** persist an uninstall tombstone. An installed credential remains in the snapshot until an operator removes it. Shopify's revoked token will be rejected on subsequent API calls. Do not add calls to `deleteSession` or Prisma session cleanup to this webhook.
|
|
114
|
+
|
|
115
|
+
### Background jobs
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import {StaticOfflineTokenRejectedError} from 'shopify-static-offline-auth';
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const {admin} = await unauthenticated.admin('example.myshopify.com');
|
|
122
|
+
await admin.graphql('{ shop { name } }');
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (error instanceof StaticOfflineTokenRejectedError) {
|
|
125
|
+
// Stop the job and notify an operator using your own reporting system.
|
|
126
|
+
}
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`unauthenticated.admin` is for **trusted server code**. It applies the allowlist but does not authenticate a caller. Do not expose it through a public route without your own request authorization.
|
|
132
|
+
|
|
133
|
+
### Login and document headers
|
|
134
|
+
|
|
135
|
+
`login(request)` preserves the official login form and redirects only configured shops. Short names such as `example` are accepted on the login form. `addDocumentResponseHeaders(request, headers)` delegates to Shopify. No session storage or additional authentication surfaces are exposed.
|
|
136
|
+
|
|
137
|
+
## Errors and token lifecycle
|
|
138
|
+
|
|
139
|
+
| Condition | Request-based API | Background API |
|
|
140
|
+
| ------------------------------------------------- | ---------------------------------------------------- | ------------------------------------- |
|
|
141
|
+
| Unconfigured store | Admin: `403`; valid webhook: ignored with `200` | `StaticOfflineShopNotConfiguredError` |
|
|
142
|
+
| Shopify rejects the token with HTTP `401` | `503` JSON with `STATIC_OFFLINE_TOKEN_REJECTED` | `StaticOfflineTokenRejectedError` |
|
|
143
|
+
| Invalid configuration | `StaticOfflineConfigurationError` during startup | Same |
|
|
144
|
+
| Attempted session mutation | Internal adapter throws `StaticOfflineReadOnlyError` | Same |
|
|
145
|
+
| Permission, GraphQL, throttling, or network error | Preserved from Shopify | Preserved from Shopify |
|
|
146
|
+
|
|
147
|
+
Token rejection responses use `Cache-Control: no-store`, contain no token or upstream response body, and do not request reauthentication or token refresh. The internal adapter also converts Shopify's attempt to persist an invalidated token into a token-rejection error. It never claims that a write or deletion succeeded. Debug logs passed through the wrapper redact configured secrets and JWTs.
|
|
148
|
+
|
|
149
|
+
Authentication success means the JWT is valid and its shop is configured. No remote token probe is added to each authentication call. Revocation or incorrect grants can therefore be discovered on the first API call. Repeated requests continue to fail until credentials are replaced; there is no shared invalidation cache.
|
|
150
|
+
|
|
151
|
+
To provision or rotate a token:
|
|
152
|
+
|
|
153
|
+
1. Install/authorize the same custom-distributed app on the intended store and obtain a non-expiring offline token using Shopify's supported [token exchange](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/token-exchange) or authorization-code flow. This package does not implement a provisioning flow.
|
|
154
|
+
2. Confirm the token's actual granted scopes and read-test it against the intended store. Keep the app's client ID and secret paired with that installation.
|
|
155
|
+
3. Replace the server-side credential and granted-scope metadata.
|
|
156
|
+
4. Redeploy/restart **all** instances. Old processes retain their original snapshot until replaced.
|
|
157
|
+
5. Verify an authenticated request and an Admin API call. Remove revoked or uninstalled stores from configuration.
|
|
158
|
+
|
|
159
|
+
Changes to app-required scopes may require reauthorization and new credentials before the app can start.
|
|
160
|
+
|
|
161
|
+
## Migrating from Prisma/session storage
|
|
162
|
+
|
|
163
|
+
1. Provision and verify tokens for every allowed store before changing the app.
|
|
164
|
+
2. Replace `shopifyApp` with this factory and remove `sessionStorage` and the Prisma session-storage adapter import.
|
|
165
|
+
3. Preserve route-level `authenticate.admin`, App Bridge, headers, and framework `boundary` usage. Remove unsupported `sessionStorage`/`registerWebhooks` exports.
|
|
166
|
+
4. Move `afterAuth` work into an explicit provisioning/deployment step. Use TOML subscriptions for webhooks. This wrapper does not run `afterAuth` on first use or on every request.
|
|
167
|
+
5. Remove session delete/update code from uninstall and scope-update webhooks. Handle configuration changes through your operational process instead; return successful webhook acknowledgements after any application cleanup.
|
|
168
|
+
6. Remove Prisma/database dependencies only if no other application data uses them. Do not delete unrelated database data or migrations.
|
|
169
|
+
7. Test before rollout. Keep the previous deployment and its database available for rollback until verification completes.
|
|
170
|
+
|
|
171
|
+
## Development and release
|
|
172
|
+
|
|
173
|
+
From the monorepo root:
|
|
174
|
+
|
|
175
|
+
```sh
|
|
176
|
+
pnpm install --ignore-scripts
|
|
177
|
+
pnpm exec turbo run build --filter='shopify-static-offline-auth...'
|
|
178
|
+
pnpm --filter shopify-static-offline-auth typecheck
|
|
179
|
+
pnpm --filter shopify-static-offline-auth test
|
|
180
|
+
pnpm --filter shopify-static-offline-auth test:package
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`test` runs unit tests plus native Node integration contracts. HTTP is mocked; the contracts fail on token-exchange, refresh, or unexpected network calls. `test:package` packs the built package, inspects its file allowlist, installs it in a temporary consumer with **npm registry releases** (React Router 4.0.2 / API 15.1.1), reruns the same contracts, and checks ESM, CommonJS, private-subpath blocking, and TypeScript declarations. It needs network access to install dependencies, but calls no real stores. The temporary consumer is removed afterward.
|
|
184
|
+
|
|
185
|
+
Only `dist`, `README.md`, `LICENSE.md`, and package metadata are published. Source tests, local secrets, adapters' dependencies, and workspace configuration are not bundled. Do not use the monorepo-wide release command to publish this independent package.
|
|
186
|
+
|
|
187
|
+
After checks pass, inspect `npm pack --dry-run` from this package directory, then publish only this package with an authorized npm account:
|
|
188
|
+
|
|
189
|
+
```sh
|
|
190
|
+
npm publish --access public
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Building or packing does not publish a version. Verify the published version and its integrity on the npm registry after publication.
|
package/dist/cjs/app.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var shopifyApi = require('@shopify/shopify-api');
|
|
4
|
+
var server = require('@shopify/shopify-app-react-router/server');
|
|
5
|
+
var errors = require('./errors.js');
|
|
6
|
+
var logger = require('./logger.js');
|
|
7
|
+
var storage = require('./storage.js');
|
|
8
|
+
var stores = require('./stores.js');
|
|
9
|
+
|
|
10
|
+
function forbidden() {
|
|
11
|
+
return Response.json({ error: 'STATIC_OFFLINE_SHOP_NOT_CONFIGURED' }, { status: 403, headers: { 'Cache-Control': 'no-store' } });
|
|
12
|
+
}
|
|
13
|
+
function tokenRejectedResponse() {
|
|
14
|
+
return Response.json({
|
|
15
|
+
error: 'STATIC_OFFLINE_TOKEN_REJECTED',
|
|
16
|
+
message: 'Configured Shopify credentials require operator attention.',
|
|
17
|
+
}, { status: 503, headers: { 'Cache-Control': 'no-store' } });
|
|
18
|
+
}
|
|
19
|
+
function wrapAdmin(admin, onRejected) {
|
|
20
|
+
// Shopify clears this context's session on a 401. Preserve the error category
|
|
21
|
+
// for later calls on the same client, without changing another request's state.
|
|
22
|
+
let rejected = false;
|
|
23
|
+
const graphql = async (operation, options) => {
|
|
24
|
+
if (rejected)
|
|
25
|
+
return onRejected();
|
|
26
|
+
try {
|
|
27
|
+
return await admin.graphql(operation, options);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (error instanceof errors.StaticOfflineTokenRejectedError ||
|
|
31
|
+
(error instanceof shopifyApi.HttpResponseError && error.response.code === 401) ||
|
|
32
|
+
(error instanceof Response && error.status === 401)) {
|
|
33
|
+
rejected = true;
|
|
34
|
+
return onRejected();
|
|
35
|
+
}
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
return { graphql };
|
|
40
|
+
}
|
|
41
|
+
function validateConfig(config) {
|
|
42
|
+
if (!config || typeof config !== 'object')
|
|
43
|
+
throw new errors.StaticOfflineConfigurationError('An app configuration is required.');
|
|
44
|
+
for (const key of [
|
|
45
|
+
'sessionStorage',
|
|
46
|
+
'hooks',
|
|
47
|
+
'billing',
|
|
48
|
+
'webhooks',
|
|
49
|
+
'adminApiAccessToken',
|
|
50
|
+
'privateAppStorefrontAccessToken',
|
|
51
|
+
'customShopDomains',
|
|
52
|
+
'isCustomStoreApp',
|
|
53
|
+
'isEmbeddedApp',
|
|
54
|
+
]) {
|
|
55
|
+
if (key in config)
|
|
56
|
+
throw new errors.StaticOfflineConfigurationError(`The ${key} option is unsupported in static offline mode.`);
|
|
57
|
+
}
|
|
58
|
+
if ((config.distribution !== undefined &&
|
|
59
|
+
config.distribution !== server.AppDistribution.SingleMerchant) ||
|
|
60
|
+
(config.useOnlineTokens !== undefined && config.useOnlineTokens !== false)) {
|
|
61
|
+
throw new errors.StaticOfflineConfigurationError('Static offline mode requires SingleMerchant distribution and useOnlineTokens: false.');
|
|
62
|
+
}
|
|
63
|
+
if (config.future !== undefined &&
|
|
64
|
+
(!config.future ||
|
|
65
|
+
typeof config.future !== 'object' ||
|
|
66
|
+
Array.isArray(config.future) ||
|
|
67
|
+
Object.keys(config.future).some((key) => key !== 'expiringOfflineAccessTokens') ||
|
|
68
|
+
(config.future.expiringOfflineAccessTokens !== undefined &&
|
|
69
|
+
config.future.expiringOfflineAccessTokens !== false))) {
|
|
70
|
+
throw new errors.StaticOfflineConfigurationError('Only expiringOfflineAccessTokens: false is supported.');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function createStaticOfflineShopifyApp(config) {
|
|
74
|
+
validateConfig(config);
|
|
75
|
+
const stores$1 = stores.validateStores(config.stores);
|
|
76
|
+
const scopes = stores.validateScopes(config.scopes ?? []);
|
|
77
|
+
for (const credential of Object.values(stores$1)) {
|
|
78
|
+
if (!new shopifyApi.AuthScopes([...credential.scopes]).has(scopes))
|
|
79
|
+
throw new errors.StaticOfflineConfigurationError('A configured store is missing required app scopes. Update the grant and provision its token before starting.');
|
|
80
|
+
}
|
|
81
|
+
const { stores: _stores, ...appConfig } = config;
|
|
82
|
+
const storage$1 = new storage.StaticOfflineSessionStorage(stores$1);
|
|
83
|
+
const app = server.shopifyApp({
|
|
84
|
+
...appConfig,
|
|
85
|
+
scopes,
|
|
86
|
+
logger: logger.redactedLogger({ ...config, stores: stores$1 }),
|
|
87
|
+
distribution: server.AppDistribution.SingleMerchant,
|
|
88
|
+
useOnlineTokens: false,
|
|
89
|
+
future: { expiringOfflineAccessTokens: false },
|
|
90
|
+
sessionStorage: storage$1,
|
|
91
|
+
});
|
|
92
|
+
return {
|
|
93
|
+
authenticate: {
|
|
94
|
+
async admin(request) {
|
|
95
|
+
try {
|
|
96
|
+
const context = await app.authenticate.admin(request);
|
|
97
|
+
const { session, sessionToken, cors, redirect } = context;
|
|
98
|
+
return {
|
|
99
|
+
session,
|
|
100
|
+
sessionToken,
|
|
101
|
+
cors,
|
|
102
|
+
redirect,
|
|
103
|
+
admin: wrapAdmin(context.admin, () => {
|
|
104
|
+
throw cors(tokenRejectedResponse());
|
|
105
|
+
}),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (error instanceof errors.StaticOfflineShopNotConfiguredError)
|
|
110
|
+
throw forbidden();
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
async webhook(request) {
|
|
115
|
+
try {
|
|
116
|
+
// Shopify verifies the raw body HMAC before looking up the session.
|
|
117
|
+
const context = await app.authenticate.webhook(request);
|
|
118
|
+
if (context.topic === 'APP_UNINSTALLED')
|
|
119
|
+
return { ...context, session: undefined, admin: undefined };
|
|
120
|
+
if (!context.admin)
|
|
121
|
+
return context;
|
|
122
|
+
return {
|
|
123
|
+
...context,
|
|
124
|
+
admin: wrapAdmin(context.admin, () => {
|
|
125
|
+
throw tokenRejectedResponse();
|
|
126
|
+
}),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (error instanceof errors.StaticOfflineShopNotConfiguredError)
|
|
131
|
+
throw new Response(null, { status: 200 });
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
unauthenticated: {
|
|
137
|
+
async admin(shop) {
|
|
138
|
+
if (!storage$1.hasShop(shop))
|
|
139
|
+
throw new errors.StaticOfflineShopNotConfiguredError();
|
|
140
|
+
const context = await app.unauthenticated.admin(shop);
|
|
141
|
+
return {
|
|
142
|
+
...context,
|
|
143
|
+
admin: wrapAdmin(context.admin, () => {
|
|
144
|
+
throw new errors.StaticOfflineTokenRejectedError();
|
|
145
|
+
}),
|
|
146
|
+
};
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
async login(request) {
|
|
150
|
+
const url = new URL(request.url);
|
|
151
|
+
let shop = url.searchParams.get('shop');
|
|
152
|
+
if (!shop && request.method === 'GET')
|
|
153
|
+
return {};
|
|
154
|
+
if (!shop)
|
|
155
|
+
shop = (await request.clone().formData()).get('shop');
|
|
156
|
+
if (!shop)
|
|
157
|
+
return { shop: server.LoginErrorType.MissingShop };
|
|
158
|
+
if (typeof shop !== 'string')
|
|
159
|
+
return { shop: server.LoginErrorType.InvalidShop };
|
|
160
|
+
// Match the official login form's short-name/URL convenience, then allowlist.
|
|
161
|
+
shop = shop.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
|
162
|
+
if (typeof shop === 'string' && !shop.includes('.'))
|
|
163
|
+
shop = `${shop}.myshopify.com`;
|
|
164
|
+
if (!stores.isShopDomain(shop))
|
|
165
|
+
return { shop: server.LoginErrorType.InvalidShop };
|
|
166
|
+
if (!storage$1.hasShop(shop))
|
|
167
|
+
throw forbidden();
|
|
168
|
+
return app.login(request);
|
|
169
|
+
},
|
|
170
|
+
addDocumentResponseHeaders: app.addDocumentResponseHeaders,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
exports.createStaticOfflineShopifyApp = createStaticOfflineShopifyApp;
|
|
175
|
+
//# sourceMappingURL=app.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"app.js","sources":["../../../src/app.ts"],"sourcesContent":["import {AuthScopes, HttpResponseError} from '@shopify/shopify-api';\nimport {\n AppDistribution,\n LoginErrorType,\n shopifyApp,\n} from '@shopify/shopify-app-react-router/server';\nimport type {\n AdminApiContext,\n UnauthenticatedAdminContext,\n} from '@shopify/shopify-app-react-router/server';\n\nimport {\n StaticOfflineConfigurationError,\n StaticOfflineShopNotConfiguredError,\n StaticOfflineTokenRejectedError,\n} from './errors';\nimport {redactedLogger} from './logger';\nimport {StaticOfflineSessionStorage} from './storage';\nimport {isShopDomain, validateScopes, validateStores} from './stores';\nimport type {StaticOfflineAppConfig, StaticOfflineShopifyApp} from './types';\n\nfunction forbidden(): Response {\n return Response.json(\n {error: 'STATIC_OFFLINE_SHOP_NOT_CONFIGURED'},\n {status: 403, headers: {'Cache-Control': 'no-store'}},\n );\n}\n\nfunction tokenRejectedResponse(): Response {\n return Response.json(\n {\n error: 'STATIC_OFFLINE_TOKEN_REJECTED',\n message: 'Configured Shopify credentials require operator attention.',\n },\n {status: 503, headers: {'Cache-Control': 'no-store'}},\n );\n}\n\nfunction wrapAdmin(\n admin: AdminApiContext,\n onRejected: () => never,\n): AdminApiContext {\n // Shopify clears this context's session on a 401. Preserve the error category\n // for later calls on the same client, without changing another request's state.\n let rejected = false;\n const graphql: AdminApiContext['graphql'] = async (operation, options) => {\n if (rejected) return onRejected();\n try {\n return await admin.graphql(operation, options);\n } catch (error) {\n if (\n error instanceof StaticOfflineTokenRejectedError ||\n (error instanceof HttpResponseError && error.response.code === 401) ||\n (error instanceof Response && error.status === 401)\n ) {\n rejected = true;\n return onRejected();\n }\n throw error;\n }\n };\n return {graphql};\n}\n\nfunction validateConfig(config: StaticOfflineAppConfig): void {\n if (!config || typeof config !== 'object')\n throw new StaticOfflineConfigurationError(\n 'An app configuration is required.',\n );\n for (const key of [\n 'sessionStorage',\n 'hooks',\n 'billing',\n 'webhooks',\n 'adminApiAccessToken',\n 'privateAppStorefrontAccessToken',\n 'customShopDomains',\n 'isCustomStoreApp',\n 'isEmbeddedApp',\n ]) {\n if (key in config)\n throw new StaticOfflineConfigurationError(\n `The ${key} option is unsupported in static offline mode.`,\n );\n }\n if (\n (config.distribution !== undefined &&\n config.distribution !== AppDistribution.SingleMerchant) ||\n (config.useOnlineTokens !== undefined && config.useOnlineTokens !== false)\n ) {\n throw new StaticOfflineConfigurationError(\n 'Static offline mode requires SingleMerchant distribution and useOnlineTokens: false.',\n );\n }\n if (\n config.future !== undefined &&\n (!config.future ||\n typeof config.future !== 'object' ||\n Array.isArray(config.future) ||\n Object.keys(config.future).some(\n (key) => key !== 'expiringOfflineAccessTokens',\n ) ||\n (config.future.expiringOfflineAccessTokens !== undefined &&\n config.future.expiringOfflineAccessTokens !== false))\n ) {\n throw new StaticOfflineConfigurationError(\n 'Only expiringOfflineAccessTokens: false is supported.',\n );\n }\n}\n\nexport function createStaticOfflineShopifyApp(\n config: StaticOfflineAppConfig,\n): StaticOfflineShopifyApp {\n validateConfig(config);\n const stores = validateStores(config.stores);\n const scopes = validateScopes(config.scopes ?? []);\n for (const credential of Object.values(stores)) {\n if (!new AuthScopes([...credential.scopes]).has(scopes))\n throw new StaticOfflineConfigurationError(\n 'A configured store is missing required app scopes. Update the grant and provision its token before starting.',\n );\n }\n const {stores: _stores, ...appConfig} = config;\n const storage = new StaticOfflineSessionStorage(stores);\n const app = shopifyApp({\n ...appConfig,\n scopes,\n logger: redactedLogger({...config, stores}),\n distribution: AppDistribution.SingleMerchant,\n useOnlineTokens: false,\n future: {expiringOfflineAccessTokens: false},\n sessionStorage: storage,\n });\n\n return {\n authenticate: {\n async admin(request) {\n try {\n const context = await app.authenticate.admin(request);\n const {session, sessionToken, cors, redirect} = context;\n return {\n session,\n sessionToken,\n cors,\n redirect,\n admin: wrapAdmin(context.admin, () => {\n throw cors(tokenRejectedResponse());\n }),\n };\n } catch (error) {\n if (error instanceof StaticOfflineShopNotConfiguredError)\n throw forbidden();\n throw error;\n }\n },\n async webhook(request) {\n try {\n // Shopify verifies the raw body HMAC before looking up the session.\n const context = await app.authenticate.webhook(request);\n if (context.topic === 'APP_UNINSTALLED')\n return {...context, session: undefined, admin: undefined};\n if (!context.admin) return context;\n return {\n ...context,\n admin: wrapAdmin(context.admin, () => {\n throw tokenRejectedResponse();\n }),\n };\n } catch (error) {\n if (error instanceof StaticOfflineShopNotConfiguredError)\n throw new Response(null, {status: 200});\n throw error;\n }\n },\n },\n unauthenticated: {\n async admin(shop): Promise<UnauthenticatedAdminContext> {\n if (!storage.hasShop(shop))\n throw new StaticOfflineShopNotConfiguredError();\n const context = await app.unauthenticated.admin(shop);\n return {\n ...context,\n admin: wrapAdmin(context.admin, () => {\n throw new StaticOfflineTokenRejectedError();\n }),\n };\n },\n },\n async login(request) {\n const url = new URL(request.url);\n let shop: unknown = url.searchParams.get('shop');\n if (!shop && request.method === 'GET') return {};\n if (!shop) shop = (await request.clone().formData()).get('shop');\n if (!shop) return {shop: LoginErrorType.MissingShop};\n if (typeof shop !== 'string') return {shop: LoginErrorType.InvalidShop};\n // Match the official login form's short-name/URL convenience, then allowlist.\n shop = shop.replace(/^https?:\\/\\//, '').replace(/\\/$/, '');\n if (typeof shop === 'string' && !shop.includes('.'))\n shop = `${shop}.myshopify.com`;\n if (!isShopDomain(shop)) return {shop: LoginErrorType.InvalidShop};\n if (!storage.hasShop(shop)) throw forbidden();\n return app.login(request);\n },\n addDocumentResponseHeaders: app.addDocumentResponseHeaders,\n };\n}\n"],"names":["StaticOfflineTokenRejectedError","HttpResponseError","StaticOfflineConfigurationError","AppDistribution","stores","validateStores","validateScopes","AuthScopes","storage","StaticOfflineSessionStorage","shopifyApp","redactedLogger","StaticOfflineShopNotConfiguredError","LoginErrorType","isShopDomain"],"mappings":"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"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/** Invalid or unsupported static credential configuration. Never contains secrets. */
|
|
4
|
+
class StaticOfflineConfigurationError extends Error {
|
|
5
|
+
code = 'STATIC_OFFLINE_CONFIGURATION';
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'StaticOfflineConfigurationError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** A shop is outside this app instance's configured allowlist. */
|
|
12
|
+
class StaticOfflineShopNotConfiguredError extends Error {
|
|
13
|
+
code = 'STATIC_OFFLINE_SHOP_NOT_CONFIGURED';
|
|
14
|
+
constructor() {
|
|
15
|
+
super('The shop is not configured for static offline access.');
|
|
16
|
+
this.name = 'StaticOfflineShopNotConfiguredError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Runtime session writes cannot change server-side credentials. */
|
|
20
|
+
class StaticOfflineReadOnlyError extends Error {
|
|
21
|
+
code = 'STATIC_OFFLINE_READ_ONLY';
|
|
22
|
+
constructor() {
|
|
23
|
+
super('Static offline credentials are read-only. Update the server configuration and redeploy all instances.');
|
|
24
|
+
this.name = 'StaticOfflineReadOnlyError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Shopify rejected an access token. It must be replaced outside the request flow. */
|
|
28
|
+
class StaticOfflineTokenRejectedError extends Error {
|
|
29
|
+
code = 'STATIC_OFFLINE_TOKEN_REJECTED';
|
|
30
|
+
constructor() {
|
|
31
|
+
super('Shopify rejected the configured offline token. Replace it and redeploy all instances.');
|
|
32
|
+
this.name = 'StaticOfflineTokenRejectedError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
exports.StaticOfflineConfigurationError = StaticOfflineConfigurationError;
|
|
37
|
+
exports.StaticOfflineReadOnlyError = StaticOfflineReadOnlyError;
|
|
38
|
+
exports.StaticOfflineShopNotConfiguredError = StaticOfflineShopNotConfiguredError;
|
|
39
|
+
exports.StaticOfflineTokenRejectedError = StaticOfflineTokenRejectedError;
|
|
40
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sources":["../../../src/errors.ts"],"sourcesContent":["/** Invalid or unsupported static credential configuration. Never contains secrets. */\nexport class StaticOfflineConfigurationError extends Error {\n readonly code = 'STATIC_OFFLINE_CONFIGURATION';\n constructor(message: string) {\n super(message);\n this.name = 'StaticOfflineConfigurationError';\n }\n}\n\n/** A shop is outside this app instance's configured allowlist. */\nexport class StaticOfflineShopNotConfiguredError extends Error {\n readonly code = 'STATIC_OFFLINE_SHOP_NOT_CONFIGURED';\n constructor() {\n super('The shop is not configured for static offline access.');\n this.name = 'StaticOfflineShopNotConfiguredError';\n }\n}\n\n/** Runtime session writes cannot change server-side credentials. */\nexport class StaticOfflineReadOnlyError extends Error {\n readonly code = 'STATIC_OFFLINE_READ_ONLY';\n constructor() {\n super(\n 'Static offline credentials are read-only. Update the server configuration and redeploy all instances.',\n );\n this.name = 'StaticOfflineReadOnlyError';\n }\n}\n\n/** Shopify rejected an access token. It must be replaced outside the request flow. */\nexport class StaticOfflineTokenRejectedError extends Error {\n readonly code = 'STATIC_OFFLINE_TOKEN_REJECTED';\n constructor() {\n super(\n 'Shopify rejected the configured offline token. Replace it and redeploy all instances.',\n );\n this.name = 'StaticOfflineTokenRejectedError';\n }\n}\n"],"names":[],"mappings":";;AAAA;AACM,MAAO,+BAAgC,SAAQ,KAAK,CAAA;IAC/C,IAAI,GAAG,8BAA8B;AAC9C,IAAA,WAAA,CAAY,OAAe,EAAA;QACzB,KAAK,CAAC,OAAO,CAAC;AACd,QAAA,IAAI,CAAC,IAAI,GAAG,iCAAiC;IAC/C;AACD;AAED;AACM,MAAO,mCAAoC,SAAQ,KAAK,CAAA;IACnD,IAAI,GAAG,oCAAoC;AACpD,IAAA,WAAA,GAAA;QACE,KAAK,CAAC,uDAAuD,CAAC;AAC9D,QAAA,IAAI,CAAC,IAAI,GAAG,qCAAqC;IACnD;AACD;AAED;AACM,MAAO,0BAA2B,SAAQ,KAAK,CAAA;IAC1C,IAAI,GAAG,0BAA0B;AAC1C,IAAA,WAAA,GAAA;QACE,KAAK,CACH,uGAAuG,CACxG;AACD,QAAA,IAAI,CAAC,IAAI,GAAG,4BAA4B;IAC1C;AACD;AAED;AACM,MAAO,+BAAgC,SAAQ,KAAK,CAAA;IAC/C,IAAI,GAAG,+BAA+B;AAC/C,IAAA,WAAA,GAAA;QACE,KAAK,CACH,uFAAuF,CACxF;AACD,QAAA,IAAI,CAAC,IAAI,GAAG,iCAAiC;IAC/C;AACD;;;;;;;"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var app = require('./app.js');
|
|
4
|
+
var stores = require('./stores.js');
|
|
5
|
+
var errors = require('./errors.js');
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
exports.createStaticOfflineShopifyApp = app.createStaticOfflineShopifyApp;
|
|
10
|
+
exports.parseStaticOfflineStores = stores.parseStaticOfflineStores;
|
|
11
|
+
exports.StaticOfflineConfigurationError = errors.StaticOfflineConfigurationError;
|
|
12
|
+
exports.StaticOfflineReadOnlyError = errors.StaticOfflineReadOnlyError;
|
|
13
|
+
exports.StaticOfflineShopNotConfiguredError = errors.StaticOfflineShopNotConfiguredError;
|
|
14
|
+
exports.StaticOfflineTokenRejectedError = errors.StaticOfflineTokenRejectedError;
|
|
15
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/** Shopify debug logs can include JWTs. Redact them at the final log sink. */
|
|
4
|
+
function redactedLogger(config) {
|
|
5
|
+
const secrets = [
|
|
6
|
+
config.apiSecretKey,
|
|
7
|
+
...Object.values(config.stores).map((store) => store.accessToken),
|
|
8
|
+
]
|
|
9
|
+
.filter((secret) => typeof secret === 'string' && secret.length > 0)
|
|
10
|
+
.sort((a, b) => b.length - a.length);
|
|
11
|
+
const sink = config.logger?.log ?? ((_severity, message) => console.log(message));
|
|
12
|
+
return {
|
|
13
|
+
...config.logger,
|
|
14
|
+
log(severity, message) {
|
|
15
|
+
let redacted = message.replace(/\b[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, (match) => match.startsWith('eyJ') || match.split('.')[2].length >= 20
|
|
16
|
+
? '[REDACTED JWT]'
|
|
17
|
+
: match);
|
|
18
|
+
for (const secret of secrets)
|
|
19
|
+
redacted = redacted.split(secret).join('[REDACTED]');
|
|
20
|
+
sink(severity, redacted);
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
exports.redactedLogger = redactedLogger;
|
|
26
|
+
//# sourceMappingURL=logger.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"logger.js","sources":["../../../src/logger.ts"],"sourcesContent":["import type {StaticOfflineAppConfig} from './types';\n\n/** Shopify debug logs can include JWTs. Redact them at the final log sink. */\nexport function redactedLogger(config: StaticOfflineAppConfig) {\n const secrets = [\n config.apiSecretKey,\n ...Object.values(config.stores).map((store) => store.accessToken),\n ]\n .filter(\n (secret): secret is string =>\n typeof secret === 'string' && secret.length > 0,\n )\n .sort((a, b) => b.length - a.length);\n const sink =\n config.logger?.log ?? ((_severity, message) => console.log(message));\n return {\n ...config.logger,\n log(severity: Parameters<typeof sink>[0], message: string) {\n let redacted = message.replace(\n /\\b[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\b/g,\n (match) =>\n match.startsWith('eyJ') || match.split('.')[2].length >= 20\n ? '[REDACTED JWT]'\n : match,\n );\n for (const secret of secrets)\n redacted = redacted.split(secret).join('[REDACTED]');\n sink(severity, redacted);\n },\n };\n}\n"],"names":[],"mappings":";;AAEA;AACM,SAAU,cAAc,CAAC,MAA8B,EAAA;AAC3D,IAAA,MAAM,OAAO,GAAG;AACd,QAAA,MAAM,CAAC,YAAY;AACnB,QAAA,GAAG,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,KAAK,KAAK,CAAC,WAAW,CAAC;AAClE;AACE,SAAA,MAAM,CACL,CAAC,MAAM,KACL,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC;AAElD,SAAA,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC;IACtC,MAAM,IAAI,GACR,MAAM,CAAC,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,OAAO,KAAK,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IACtE,OAAO;QACL,GAAG,MAAM,CAAC,MAAM;QAChB,GAAG,CAAC,QAAoC,EAAE,OAAe,EAAA;AACvD,YAAA,IAAI,QAAQ,GAAG,OAAO,CAAC,OAAO,CAC5B,qDAAqD,EACrD,CAAC,KAAK,KACJ,KAAK,CAAC,UAAU,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,IAAI;AACvD,kBAAE;kBACA,KAAK,CACZ;YACD,KAAK,MAAM,MAAM,IAAI,OAAO;AAC1B,gBAAA,QAAQ,GAAG,QAAQ,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC;AACtD,YAAA,IAAI,CAAC,QAAQ,EAAE,QAAQ,CAAC;QAC1B,CAAC;KACF;AACH;;;;"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var shopifyApi = require('@shopify/shopify-api');
|
|
4
|
+
var errors = require('./errors.js');
|
|
5
|
+
var stores = require('./stores.js');
|
|
6
|
+
|
|
7
|
+
/** Internal bridge only: intentionally does not implement mutable storage semantics. */
|
|
8
|
+
class StaticOfflineSessionStorage {
|
|
9
|
+
#stores;
|
|
10
|
+
constructor(stores$1) {
|
|
11
|
+
this.#stores = stores.validateStores(stores$1);
|
|
12
|
+
}
|
|
13
|
+
hasShop(shop) {
|
|
14
|
+
return stores.isShopDomain(shop) && Object.hasOwn(this.#stores, shop);
|
|
15
|
+
}
|
|
16
|
+
async loadSession(id) {
|
|
17
|
+
if (!id.startsWith('offline_') ||
|
|
18
|
+
!this.hasShop(id.slice('offline_'.length))) {
|
|
19
|
+
// Returning undefined would trigger Shopify's token exchange strategy.
|
|
20
|
+
throw new errors.StaticOfflineShopNotConfiguredError();
|
|
21
|
+
}
|
|
22
|
+
const shop = id.slice('offline_'.length);
|
|
23
|
+
const credential = this.#stores[shop];
|
|
24
|
+
return new shopifyApi.Session({
|
|
25
|
+
id,
|
|
26
|
+
shop,
|
|
27
|
+
state: '',
|
|
28
|
+
isOnline: false,
|
|
29
|
+
accessToken: credential.accessToken,
|
|
30
|
+
scope: credential.scopes.join(','),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
async findSessionsByShop(shop) {
|
|
34
|
+
return this.hasShop(shop)
|
|
35
|
+
? [await this.loadSession(`offline_${shop}`)]
|
|
36
|
+
: [];
|
|
37
|
+
}
|
|
38
|
+
async storeSession(session) {
|
|
39
|
+
// The official admin strategy clears a rejected token and tries to persist it.
|
|
40
|
+
// Throw before its reauthentication redirect; never mutate the source credentials.
|
|
41
|
+
if (session.id === `offline_${session.shop}` &&
|
|
42
|
+
this.hasShop(session.shop) &&
|
|
43
|
+
!session.accessToken) {
|
|
44
|
+
throw new errors.StaticOfflineTokenRejectedError();
|
|
45
|
+
}
|
|
46
|
+
throw new errors.StaticOfflineReadOnlyError();
|
|
47
|
+
}
|
|
48
|
+
async deleteSession(_id) {
|
|
49
|
+
throw new errors.StaticOfflineReadOnlyError();
|
|
50
|
+
}
|
|
51
|
+
async deleteSessions(_ids) {
|
|
52
|
+
throw new errors.StaticOfflineReadOnlyError();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
exports.StaticOfflineSessionStorage = StaticOfflineSessionStorage;
|
|
57
|
+
//# sourceMappingURL=storage.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"storage.js","sources":["../../../src/storage.ts"],"sourcesContent":["import {Session} from '@shopify/shopify-api';\nimport type {SessionStorage} from '@shopify/shopify-app-session-storage';\n\nimport {\n StaticOfflineReadOnlyError,\n StaticOfflineShopNotConfiguredError,\n StaticOfflineTokenRejectedError,\n} from './errors';\nimport {isShopDomain, validateStores, type StaticOfflineStores} from './stores';\n\n/** Internal bridge only: intentionally does not implement mutable storage semantics. */\nexport class StaticOfflineSessionStorage implements SessionStorage {\n readonly #stores: StaticOfflineStores;\n\n constructor(stores: StaticOfflineStores) {\n this.#stores = validateStores(stores);\n }\n\n hasShop(shop: string): boolean {\n return isShopDomain(shop) && Object.hasOwn(this.#stores, shop);\n }\n\n async loadSession(id: string): Promise<Session> {\n if (\n !id.startsWith('offline_') ||\n !this.hasShop(id.slice('offline_'.length))\n ) {\n // Returning undefined would trigger Shopify's token exchange strategy.\n throw new StaticOfflineShopNotConfiguredError();\n }\n const shop = id.slice('offline_'.length);\n const credential = this.#stores[shop];\n return new Session({\n id,\n shop,\n state: '',\n isOnline: false,\n accessToken: credential.accessToken,\n scope: credential.scopes.join(','),\n });\n }\n\n async findSessionsByShop(shop: string): Promise<Session[]> {\n return this.hasShop(shop)\n ? [await this.loadSession(`offline_${shop}`)]\n : [];\n }\n\n async storeSession(session: Session): Promise<boolean> {\n // The official admin strategy clears a rejected token and tries to persist it.\n // Throw before its reauthentication redirect; never mutate the source credentials.\n if (\n session.id === `offline_${session.shop}` &&\n this.hasShop(session.shop) &&\n !session.accessToken\n ) {\n throw new StaticOfflineTokenRejectedError();\n }\n throw new StaticOfflineReadOnlyError();\n }\n\n async deleteSession(_id: string): Promise<boolean> {\n throw new StaticOfflineReadOnlyError();\n }\n\n async deleteSessions(_ids: string[]): Promise<boolean> {\n throw new StaticOfflineReadOnlyError();\n }\n}\n"],"names":["stores","validateStores","isShopDomain","StaticOfflineShopNotConfiguredError","Session","StaticOfflineTokenRejectedError","StaticOfflineReadOnlyError"],"mappings":";;;;;;AAUA;MACa,2BAA2B,CAAA;AAC7B,IAAA,OAAO;AAEhB,IAAA,WAAA,CAAYA,QAA2B,EAAA;AACrC,QAAA,IAAI,CAAC,OAAO,GAAGC,qBAAc,CAACD,QAAM,CAAC;IACvC;AAEA,IAAA,OAAO,CAAC,IAAY,EAAA;AAClB,QAAA,OAAOE,mBAAY,CAAC,IAAI,CAAC,IAAI,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC;IAChE;IAEA,MAAM,WAAW,CAAC,EAAU,EAAA;AAC1B,QAAA,IACE,CAAC,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC;AAC1B,YAAA,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,EAC1C;;YAEA,MAAM,IAAIC,0CAAmC,EAAE;QACjD;QACA,MAAM,IAAI,GAAG,EAAE,CAAC,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC;QACxC,MAAM,UAAU,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC;QACrC,OAAO,IAAIC,kBAAO,CAAC;YACjB,EAAE;YACF,IAAI;AACJ,YAAA,KAAK,EAAE,EAAE;AACT,YAAA,QAAQ,EAAE,KAAK;YACf,WAAW,EAAE,UAAU,CAAC,WAAW;YACnC,KAAK,EAAE,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC;AACnC,SAAA,CAAC;IACJ;IAEA,MAAM,kBAAkB,CAAC,IAAY,EAAA;AACnC,QAAA,OAAO,IAAI,CAAC,OAAO,CAAC,IAAI;cACpB,CAAC,MAAM,IAAI,CAAC,WAAW,CAAC,CAAA,QAAA,EAAW,IAAI,CAAA,CAAE,CAAC;cAC1C,EAAE;IACR;IAEA,MAAM,YAAY,CAAC,OAAgB,EAAA;;;QAGjC,IACE,OAAO,CAAC,EAAE,KAAK,WAAW,OAAO,CAAC,IAAI,CAAA,CAAE;AACxC,YAAA,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC;AAC1B,YAAA,CAAC,OAAO,CAAC,WAAW,EACpB;YACA,MAAM,IAAIC,sCAA+B,EAAE;QAC7C;QACA,MAAM,IAAIC,iCAA0B,EAAE;IACxC;IAEA,MAAM,aAAa,CAAC,GAAW,EAAA;QAC7B,MAAM,IAAIA,iCAA0B,EAAE;IACxC;IAEA,MAAM,cAAc,CAAC,IAAc,EAAA;QACjC,MAAM,IAAIA,iCAA0B,EAAE;IACxC;AACD;;;;"}
|