@sixtyninecommerce/admin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +147 -0
- package/dist/api-tokens/index.d.ts +29 -0
- package/dist/api-tokens/index.js +90 -0
- package/dist/auth/index.d.ts +61 -0
- package/dist/auth/index.js +162 -0
- package/dist/auth/queries.d.ts +11 -0
- package/dist/auth/queries.js +49 -0
- package/dist/client.d.ts +83 -0
- package/dist/client.js +236 -0
- package/dist/content/forms.d.ts +133 -0
- package/dist/content/forms.js +65 -0
- package/dist/errors.d.ts +10 -0
- package/dist/errors.js +17 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +21 -0
- package/dist/platform/guard.d.ts +11 -0
- package/dist/platform/guard.js +20 -0
- package/dist/seo/redirects.d.ts +27 -0
- package/dist/seo/redirects.js +37 -0
- package/dist/tenant/channels.d.ts +6 -0
- package/dist/tenant/channels.js +7 -0
- package/dist/types.d.ts +118 -0
- package/dist/types.js +1 -0
- package/package.json +52 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { createChannelResolver, createTransport, mergeTenant, UNSCOPED_TENANT, ValidationError, } from '@sixtyninecommerce/core';
|
|
2
|
+
import { createApiTokensModule } from './api-tokens/index.js';
|
|
3
|
+
import { createAuthModule } from './auth/index.js';
|
|
4
|
+
import { createFormsModule } from './content/forms.js';
|
|
5
|
+
import { createRedirectsModule } from './seo/redirects.js';
|
|
6
|
+
import { createChannelsModule } from './tenant/channels.js';
|
|
7
|
+
/**
|
|
8
|
+
* An admin credential in a browser is a credential the whole internet can read
|
|
9
|
+
* (spec §18 amendment 3, §15.21). A SESSION bearer is fine there — that is what
|
|
10
|
+
* the dashboard is — so the refusal is specifically about a long-lived token.
|
|
11
|
+
*/
|
|
12
|
+
function assertNotInBrowser(auth) {
|
|
13
|
+
if (!('token' in auth))
|
|
14
|
+
return;
|
|
15
|
+
// Indexed off a typed view of globalThis: this package compiles without the
|
|
16
|
+
// DOM lib on purpose, so that a stray `document.` reference cannot slip in.
|
|
17
|
+
const runtime = globalThis;
|
|
18
|
+
const inBrowser = runtime.window !== undefined && runtime.document !== undefined;
|
|
19
|
+
if (!inBrowser)
|
|
20
|
+
return;
|
|
21
|
+
throw new ValidationError({
|
|
22
|
+
code: 'CREDENTIAL_IN_BROWSER',
|
|
23
|
+
message: 'An API token must never be constructed in a browser — it would ship the credential to every visitor. Use a session bearer in the browser and keep tokens server-side.',
|
|
24
|
+
status: 400,
|
|
25
|
+
service: 'vendure',
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Where the session bearer lives: in the caller's own `session` object, always.
|
|
30
|
+
*
|
|
31
|
+
* Two earlier designs both shipped a defect. Storing it ONLY in the caller's
|
|
32
|
+
* hands meant a client built with `auth: { session: {} }` signed in and then
|
|
33
|
+
* made every following call — starting with the `me()` inside `login()` —
|
|
34
|
+
* unauthenticated. Keeping a private slot inside the client and falling back to
|
|
35
|
+
* it meant a server with one module-scope client and a per-request bearer
|
|
36
|
+
* served one administrator's session to the next visitor: both requests read
|
|
37
|
+
* `undefined` from their own store, so both fell through to the slot holding
|
|
38
|
+
* the last bearer anyone had rotated to.
|
|
39
|
+
*
|
|
40
|
+
* There is no fallback slot now. The caller's object is read live on every call
|
|
41
|
+
* and written on every rotation, so a per-request store isolates per request,
|
|
42
|
+
* `withTenant` and a second client over the same config share one session for
|
|
43
|
+
* free, and a caller who clears their copy is signed out.
|
|
44
|
+
*/
|
|
45
|
+
function sessionStore(configured) {
|
|
46
|
+
return {
|
|
47
|
+
read: () => configured.token ?? '',
|
|
48
|
+
write: (next) => {
|
|
49
|
+
configured.token = next;
|
|
50
|
+
try {
|
|
51
|
+
configured.onToken?.(next ?? '');
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
// The consumer's handler is not allowed to fail the operation that
|
|
55
|
+
// produced the token. `localStorage.setItem` throws in a private
|
|
56
|
+
// window, and a `logout()` that rejected after Vendure had already
|
|
57
|
+
// destroyed the session would leave an app on its signed-in shell while
|
|
58
|
+
// every later call 401s. The transport isolates the same handler on the
|
|
59
|
+
// rotation path for the same reason.
|
|
60
|
+
console.warn('[admin] session onToken handler threw; the bearer was still stored', error);
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function credentialMissing() {
|
|
66
|
+
return new ValidationError({
|
|
67
|
+
code: 'CREDENTIAL_MISSING',
|
|
68
|
+
message: 'auth.token is empty, so the call would go out with no credential and be refused as a permission failure. Check the environment variable or store holding the API token.',
|
|
69
|
+
status: 400,
|
|
70
|
+
service: 'vendure',
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* `auth: { token: process.env.SIXTYNINE_API_TOKEN! }` with the variable unset
|
|
75
|
+
* is the commonest way to build a client with no credential at all: TypeScript's
|
|
76
|
+
* `!` hides the `undefined`, the transport sends no Authorization header, and
|
|
77
|
+
* the first call comes back as PermissionDeniedError — "you may not do this" —
|
|
78
|
+
* from deep inside whichever module the caller invoked.
|
|
79
|
+
*
|
|
80
|
+
* Only an OWN DATA PROPERTY is checked here, at construction, because its value
|
|
81
|
+
* can only have come from the caller. Anything else — an own accessor, a getter
|
|
82
|
+
* inherited from a class prototype, a Proxy — is read live on every call, so it
|
|
83
|
+
* is legitimately empty at construction (a request context not yet populated, a
|
|
84
|
+
* secret manager not yet consulted) and checking it then would refuse the
|
|
85
|
+
* documented per-request pattern at IMPORT time. Those are checked on the call
|
|
86
|
+
* that would have sent the credential instead.
|
|
87
|
+
*/
|
|
88
|
+
function descriptorOf(target, key) {
|
|
89
|
+
for (let node = target; node !== null; node = Object.getPrototypeOf(node)) {
|
|
90
|
+
const descriptor = Object.getOwnPropertyDescriptor(node, key);
|
|
91
|
+
if (descriptor !== undefined)
|
|
92
|
+
return descriptor;
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
function assertTokenIsPresent(auth) {
|
|
97
|
+
const descriptor = descriptorOf(auth, 'token');
|
|
98
|
+
if (descriptor === undefined || descriptor.get !== undefined)
|
|
99
|
+
return;
|
|
100
|
+
if (isPresent(auth.token))
|
|
101
|
+
return;
|
|
102
|
+
throw credentialMissing();
|
|
103
|
+
}
|
|
104
|
+
const isPresent = (token) => typeof token === 'string' && token.trim() !== '';
|
|
105
|
+
/**
|
|
106
|
+
* Refuse a session object this client cannot store into. Discovering it at the
|
|
107
|
+
* first rotation instead would mean `login()` succeeding and every call after
|
|
108
|
+
* it going out unauthenticated — the exact failure this check exists to name.
|
|
109
|
+
*/
|
|
110
|
+
function assertSessionIsWritable(session) {
|
|
111
|
+
// Walked, not `getOwnPropertyDescriptor`: a request context or a secret store
|
|
112
|
+
// is usually a CLASS, so its `token` accessor lives on the prototype and an
|
|
113
|
+
// own-property check reports nothing — accepting a read-only object and
|
|
114
|
+
// producing the misdiagnosis this guard exists to prevent.
|
|
115
|
+
const descriptor = descriptorOf(session, 'token');
|
|
116
|
+
const writable = descriptor === undefined
|
|
117
|
+
? Object.isExtensible(session)
|
|
118
|
+
: descriptor.writable === true || typeof descriptor.set === 'function';
|
|
119
|
+
if (writable)
|
|
120
|
+
return;
|
|
121
|
+
throw new ValidationError({
|
|
122
|
+
code: 'SESSION_NOT_WRITABLE',
|
|
123
|
+
message: 'auth.session.token is read-only, so this client could never store the bearer Vendure returns and every call after login would be unauthenticated. Use a plain property, or a getter with a setter over your own store.',
|
|
124
|
+
status: 400,
|
|
125
|
+
service: 'vendure',
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
export function createAdminClient(config) {
|
|
129
|
+
assertNotInBrowser(config.auth);
|
|
130
|
+
const resolver = createChannelResolver({
|
|
131
|
+
cmsUrl: config.urls.cmsUrl,
|
|
132
|
+
...(config.fetch ? { fetch: config.fetch } : {}),
|
|
133
|
+
...(config.timeoutMs === undefined ? {} : { timeoutMs: config.timeoutMs }),
|
|
134
|
+
});
|
|
135
|
+
if ('session' in config.auth)
|
|
136
|
+
assertSessionIsWritable(config.auth.session);
|
|
137
|
+
else
|
|
138
|
+
assertTokenIsPresent(config.auth);
|
|
139
|
+
// Shared by every client `withTenant` derives, because the session store is.
|
|
140
|
+
// `rotations` lets `login()` tell a bearer THIS sign-in produced apart from
|
|
141
|
+
// one that merely sits in the slot already.
|
|
142
|
+
const shared = { rotations: 0 };
|
|
143
|
+
return buildClient(config, config.tenant, resolver, shared);
|
|
144
|
+
}
|
|
145
|
+
function buildClient(config, overrides, resolver, shared) {
|
|
146
|
+
// No scope configured leaves the client on UNSCOPED_TENANT, which only the
|
|
147
|
+
// auth documents may use; the transport refuses everything else with
|
|
148
|
+
// TENANT_REQUIRED (spec §15.2).
|
|
149
|
+
const tenant = mergeTenant(UNSCOPED_TENANT, overrides);
|
|
150
|
+
const configured = 'session' in config.auth ? config.auth.session : undefined;
|
|
151
|
+
const store = configured ? sessionStore(configured) : undefined;
|
|
152
|
+
const transport = createTransport({
|
|
153
|
+
urls: config.urls,
|
|
154
|
+
channels: resolver,
|
|
155
|
+
tenant,
|
|
156
|
+
principal: () => {
|
|
157
|
+
if (!store) {
|
|
158
|
+
// Read live, so a getter over a request context follows the request
|
|
159
|
+
// being served — and refuse an empty one HERE rather than sending an
|
|
160
|
+
// anonymous request that comes back as a permission failure.
|
|
161
|
+
const token = config.auth.token;
|
|
162
|
+
if (!isPresent(token))
|
|
163
|
+
throw credentialMissing();
|
|
164
|
+
return { kind: 'bearer', token };
|
|
165
|
+
}
|
|
166
|
+
const sent = store.read();
|
|
167
|
+
return {
|
|
168
|
+
kind: 'bearer',
|
|
169
|
+
token: sent,
|
|
170
|
+
onToken: (rotated) => {
|
|
171
|
+
// Only while the slot still holds what THIS call sent. A `logout()`
|
|
172
|
+
// that landed meanwhile cleared it, and a rotation for the session
|
|
173
|
+
// that ended must not re-arm the one that replaced it.
|
|
174
|
+
//
|
|
175
|
+
// Keyed on the bearer, never on a per-client counter: the session
|
|
176
|
+
// store is the CALLER's, and on the documented one-client-per-server
|
|
177
|
+
// pattern each request has its own. A counter is global to the
|
|
178
|
+
// process, so one administrator's sign-out discarded the bearer
|
|
179
|
+
// another administrator's concurrent sign-in had just been issued —
|
|
180
|
+
// reported to them as "the engine returned no vendure-auth-token".
|
|
181
|
+
if (store.read() !== sent)
|
|
182
|
+
return;
|
|
183
|
+
store.write(rotated);
|
|
184
|
+
shared.rotations += 1;
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
},
|
|
188
|
+
...(config.fetch ? { fetch: config.fetch } : {}),
|
|
189
|
+
...(config.retry ? { retry: config.retry } : {}),
|
|
190
|
+
...(config.timeoutMs === undefined ? {} : { timeoutMs: config.timeoutMs }),
|
|
191
|
+
...(config.onDeprecation ? { onDeprecation: config.onDeprecation } : {}),
|
|
192
|
+
});
|
|
193
|
+
// No guard here: the transport refuses an unscoped call itself with
|
|
194
|
+
// TENANT_REQUIRED unless it opts in, and only `auth` does. One rule, in the
|
|
195
|
+
// one place that knows what left the process.
|
|
196
|
+
return {
|
|
197
|
+
auth: createAuthModule(transport, store && {
|
|
198
|
+
present: () => store.read() !== '',
|
|
199
|
+
current: () => store.read(),
|
|
200
|
+
rotations: () => shared.rotations,
|
|
201
|
+
end: (sent) => {
|
|
202
|
+
// Vendure destroys the session and answers `vendure-auth-token: ''`,
|
|
203
|
+
// which the transport drops as "not a rotation". Without this the
|
|
204
|
+
// caller's store keeps a dead bearer — and every client derived from
|
|
205
|
+
// this one reads the same store.
|
|
206
|
+
//
|
|
207
|
+
// `sent` guards the other direction: a response that PROVES a bearer
|
|
208
|
+
// is dead arrives late, and by then a fresh sign-in may hold the
|
|
209
|
+
// slot. Clearing then would sign the new session out.
|
|
210
|
+
if (sent !== undefined && store.read() !== sent)
|
|
211
|
+
return;
|
|
212
|
+
store.write(undefined);
|
|
213
|
+
},
|
|
214
|
+
}),
|
|
215
|
+
apiTokens: createApiTokensModule(transport),
|
|
216
|
+
channels: createChannelsModule(resolver),
|
|
217
|
+
content: { forms: createFormsModule(transport) },
|
|
218
|
+
seo: { redirects: createRedirectsModule(transport) },
|
|
219
|
+
platform: {},
|
|
220
|
+
withTenant(next) {
|
|
221
|
+
// `mergeTenant`, not a spread: it merges `actor` FIELD BY FIELD, so a
|
|
222
|
+
// derived client that names only a registerRef keeps the staffRef this
|
|
223
|
+
// one was built with — exactly as the per-call `tenant` override does.
|
|
224
|
+
// A spread replaced the whole actor, silently dropping the
|
|
225
|
+
// `x-on-behalf-of` that attributes a mutation in the audit trail.
|
|
226
|
+
//
|
|
227
|
+
// Resolver and rotation count are shared, and the session store IS the
|
|
228
|
+
// caller's object — so a derived client reuses the channel cache and the
|
|
229
|
+
// bearer this one signed in with, in both directions and in either order.
|
|
230
|
+
return buildClient(config, mergeTenant(tenant, next), resolver, shared);
|
|
231
|
+
},
|
|
232
|
+
get rateLimit() {
|
|
233
|
+
return transport.rateLimit;
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { type Page, type Transport } from '@sixtyninecommerce/core';
|
|
2
|
+
import type { Call, ListOptions } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Everything a visitor reads is TRANSLATED, per language, in its own row:
|
|
5
|
+
* `forms_translations` and `form_fields_translations`. `name` and `slug` are
|
|
6
|
+
* the authoring identity, not display copy — a form with no translation for a
|
|
7
|
+
* storefront's language renders the untranslated fallback, so a multilingual
|
|
8
|
+
* tenant that cannot express this dimension is a tenant whose labels are all in
|
|
9
|
+
* the authoring language.
|
|
10
|
+
*/
|
|
11
|
+
export interface FormTranslation {
|
|
12
|
+
/** e.g. `en`, `nl`, `de`. */
|
|
13
|
+
languageCode: string;
|
|
14
|
+
title?: string | null;
|
|
15
|
+
description?: string | null;
|
|
16
|
+
submitButtonText?: string | null;
|
|
17
|
+
successMessage?: string | null;
|
|
18
|
+
[extra: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
export interface FormFieldTranslation {
|
|
21
|
+
languageCode: string;
|
|
22
|
+
label: string;
|
|
23
|
+
placeholder?: string | null;
|
|
24
|
+
helpText?: string | null;
|
|
25
|
+
errorMessage?: string | null;
|
|
26
|
+
[extra: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The WRITE shapes, mirroring `FormTranslationDto` and
|
|
30
|
+
* `FormFieldTranslationDto` exactly — separate from the read shapes for the
|
|
31
|
+
* same reason `FormFieldInput` is separate from `FormField`.
|
|
32
|
+
*
|
|
33
|
+
* A read carries the translation row's own `id`, `formId` and timestamps, and
|
|
34
|
+
* `forbidNonWhitelisted` refuses every one of them, so handing a fetched
|
|
35
|
+
* `translations` array straight back is a 400 on the edit-and-save flow this
|
|
36
|
+
* type exists to support. No index signature, so excess-property checking
|
|
37
|
+
* actually fires on a literal.
|
|
38
|
+
*
|
|
39
|
+
* `title` and `label` are the two NOT NULL columns, so they take no `| null`:
|
|
40
|
+
* `@IsOptional()` SKIPS null rather than rejecting it and the service assigns it
|
|
41
|
+
* through, which is a 500 where a 400 belongs. Every other member is nullable in
|
|
42
|
+
* the database and the service treats an explicit null as a CLEAR — so `| null`
|
|
43
|
+
* stays there, and omitting the key leaves the value alone. `''` is not the same
|
|
44
|
+
* thing: a storefront doing `submitButtonText ?? 'Send'` renders an empty button
|
|
45
|
+
* for an empty string and the default for a null.
|
|
46
|
+
*/
|
|
47
|
+
export interface FormTranslationInput {
|
|
48
|
+
languageCode: string;
|
|
49
|
+
title?: string;
|
|
50
|
+
description?: string | null;
|
|
51
|
+
submitButtonText?: string | null;
|
|
52
|
+
successMessage?: string | null;
|
|
53
|
+
}
|
|
54
|
+
export interface FormFieldTranslationInput {
|
|
55
|
+
languageCode: string;
|
|
56
|
+
label: string;
|
|
57
|
+
placeholder?: string | null;
|
|
58
|
+
helpText?: string | null;
|
|
59
|
+
errorMessage?: string | null;
|
|
60
|
+
}
|
|
61
|
+
/** A form as the CMS stores it (the admin projection, not the storefront one). */
|
|
62
|
+
export interface Form {
|
|
63
|
+
id: string;
|
|
64
|
+
channelId: string;
|
|
65
|
+
name: string;
|
|
66
|
+
slug: string;
|
|
67
|
+
notificationEmails: string | null;
|
|
68
|
+
redirectUrlOnSuccess: string | null;
|
|
69
|
+
submitAction: string;
|
|
70
|
+
/** Free-form JSONB the CMS stores verbatim — never case-converted. */
|
|
71
|
+
settings: Record<string, unknown> | null;
|
|
72
|
+
translations?: FormTranslation[];
|
|
73
|
+
fields?: FormField[];
|
|
74
|
+
createdAt: string;
|
|
75
|
+
updatedAt: string;
|
|
76
|
+
[extra: string]: unknown;
|
|
77
|
+
}
|
|
78
|
+
export interface FormField {
|
|
79
|
+
id: string;
|
|
80
|
+
name: string;
|
|
81
|
+
slug: string;
|
|
82
|
+
fieldType: string;
|
|
83
|
+
isRequired: boolean;
|
|
84
|
+
position: number;
|
|
85
|
+
config: Record<string, unknown> | null;
|
|
86
|
+
validation: Record<string, unknown> | null;
|
|
87
|
+
translations?: FormFieldTranslation[];
|
|
88
|
+
[extra: string]: unknown;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Exactly what `CreateFormFieldDto` whitelists, and nothing else. The CMS runs
|
|
92
|
+
* `ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })`, so an
|
|
93
|
+
* extra key is a 400, not an ignored extra — which is why this is not
|
|
94
|
+
* `Partial<FormField>`: handing a fetched field straight back would send `id`,
|
|
95
|
+
* `formId` and `createdAt` and fail the whole write.
|
|
96
|
+
*/
|
|
97
|
+
export interface FormFieldInput {
|
|
98
|
+
name: string;
|
|
99
|
+
slug: string;
|
|
100
|
+
fieldType: string;
|
|
101
|
+
isRequired?: boolean;
|
|
102
|
+
position?: number;
|
|
103
|
+
config?: Record<string, unknown>;
|
|
104
|
+
validation?: Record<string, unknown>;
|
|
105
|
+
translations?: FormFieldTranslationInput[];
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Exactly what `UpsertFormDto` whitelists. `slug` is NOT here on purpose: it
|
|
109
|
+
* comes from the URL, and sending it is a 400 under `forbidNonWhitelisted`.
|
|
110
|
+
*/
|
|
111
|
+
export interface FormInput {
|
|
112
|
+
name: string;
|
|
113
|
+
/**
|
|
114
|
+
* `null` CLEARS, `undefined` (or an absent key) leaves the stored value
|
|
115
|
+
* alone — the service only assigns what the DTO names, and these three
|
|
116
|
+
* columns are nullable. `''` is a different thing: it stores an empty string
|
|
117
|
+
* a storefront's `?? fallback` can never replace.
|
|
118
|
+
*/
|
|
119
|
+
notificationEmails?: string | null;
|
|
120
|
+
redirectUrlOnSuccess?: string | null;
|
|
121
|
+
submitAction?: string;
|
|
122
|
+
settings?: Record<string, unknown> | null;
|
|
123
|
+
translations?: FormTranslationInput[];
|
|
124
|
+
fields?: FormFieldInput[];
|
|
125
|
+
}
|
|
126
|
+
export interface FormsModule {
|
|
127
|
+
list(options?: ListOptions): Promise<Page<Form>>;
|
|
128
|
+
get(id: string, options?: Call): Promise<Form>;
|
|
129
|
+
getBySlug(slug: string, options?: Call): Promise<Form>;
|
|
130
|
+
upsertBySlug(slug: string, input: FormInput, options?: Call): Promise<Form>;
|
|
131
|
+
delete(id: string, options?: Call): Promise<void>;
|
|
132
|
+
}
|
|
133
|
+
export declare function createFormsModule(transport: Transport): FormsModule;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createPage, offsetCursor, PLATFORM_PASSTHROUGH_KEYS, readOffsetCursor, } from '@sixtyninecommerce/core';
|
|
2
|
+
import { AdminProjectionError } from '../errors.js';
|
|
3
|
+
const DEFAULT_PAGE_SIZE = 25;
|
|
4
|
+
/**
|
|
5
|
+
* A form carries three bags the CMS stores verbatim: `settings`, and each
|
|
6
|
+
* field's `config` and `validation` (whose constraint keys are snake_case —
|
|
7
|
+
* `min_length` — and are read back by the CMS validator). Case-converting them
|
|
8
|
+
* renames a merchant's own keys, so every call in this module names them.
|
|
9
|
+
* `Transport.rest` preserves nothing that is not named.
|
|
10
|
+
*/
|
|
11
|
+
const FORM_PASSTHROUGH = PLATFORM_PASSTHROUGH_KEYS;
|
|
12
|
+
/**
|
|
13
|
+
* `GET /forms*` is a PUBLIC CMS route with OPTIONAL auth: a token the CMS does
|
|
14
|
+
* not accept for this channel is not rejected — the route answers 200 with the
|
|
15
|
+
* STOREFRONT projection, which carries no `channelId` and no field ids.
|
|
16
|
+
* Returning that as an admin `Form` hands the caller `undefined` under a
|
|
17
|
+
* `string` type and turns the next write into an opaque 400 that names neither
|
|
18
|
+
* the token nor the channel. `channelId` is the discriminator: the entity
|
|
19
|
+
* always has it, the projection never does.
|
|
20
|
+
*/
|
|
21
|
+
function assertAdminProjection(form, what) {
|
|
22
|
+
if (form && typeof form.channelId === 'string' && form.channelId !== '')
|
|
23
|
+
return form;
|
|
24
|
+
throw new AdminProjectionError(what);
|
|
25
|
+
}
|
|
26
|
+
export function createFormsModule(transport) {
|
|
27
|
+
async function listPage(offset, limit, options) {
|
|
28
|
+
const body = await transport.rest('cms', { method: 'GET', path: '/forms', query: { limit, offset } }, { ...options, preserveKeys: FORM_PASSTHROUGH });
|
|
29
|
+
const items = body.data ?? [];
|
|
30
|
+
// `GET /forms` is `@Public()` exactly like the two single reads, so a
|
|
31
|
+
// credential the CMS will not accept for this channel is answered 200 with
|
|
32
|
+
// the storefront projection — rows with no channelId and no field ids. One
|
|
33
|
+
// row is enough to tell: the route projects all of them or none.
|
|
34
|
+
if (items[0] !== undefined)
|
|
35
|
+
assertAdminProjection(items[0], 'the form list');
|
|
36
|
+
// The CMS echoes neither the offset nor a cursor, so the page is built from
|
|
37
|
+
// what THIS call asked for — `readRestPage` cannot derive it.
|
|
38
|
+
const total = body.pagination?.total ?? items.length;
|
|
39
|
+
const advanced = offset + (items.length > 0 ? items.length : limit);
|
|
40
|
+
const hasNext = advanced < total;
|
|
41
|
+
return createPage(items, { nextCursor: hasNext ? offsetCursor(advanced) : null, hasNext, total }, (cursor) => listPage(readOffsetCursor(cursor), limit, options));
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
list(options) {
|
|
45
|
+
const limit = options?.limit ?? DEFAULT_PAGE_SIZE;
|
|
46
|
+
const offset = options?.after === undefined ? 0 : readOffsetCursor(options.after);
|
|
47
|
+
return listPage(offset, limit, options);
|
|
48
|
+
},
|
|
49
|
+
async get(id, options) {
|
|
50
|
+
const body = await transport.rest('cms', { method: 'GET', path: `/forms/${encodeURIComponent(id)}` }, { ...options, preserveKeys: FORM_PASSTHROUGH });
|
|
51
|
+
return assertAdminProjection(body.data, `form "${id}"`);
|
|
52
|
+
},
|
|
53
|
+
async getBySlug(slug, options) {
|
|
54
|
+
const body = await transport.rest('cms', { method: 'GET', path: `/forms/by-slug/${encodeURIComponent(slug)}` }, { ...options, preserveKeys: FORM_PASSTHROUGH });
|
|
55
|
+
return assertAdminProjection(body.data, `form "${slug}"`);
|
|
56
|
+
},
|
|
57
|
+
async upsertBySlug(slug, input, options) {
|
|
58
|
+
const body = await transport.rest('cms', { method: 'PUT', path: `/forms/by-slug/${encodeURIComponent(slug)}`, body: input }, { ...options, preserveKeys: FORM_PASSTHROUGH });
|
|
59
|
+
return body.data;
|
|
60
|
+
},
|
|
61
|
+
async delete(id, options) {
|
|
62
|
+
await transport.rest('cms', { method: 'DELETE', path: `/forms/${encodeURIComponent(id)}`, expect: 'void' }, options);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { PlatformError } from '@sixtyninecommerce/core';
|
|
2
|
+
/**
|
|
3
|
+
* The CMS answered a public-with-optional-auth route with its STOREFRONT
|
|
4
|
+
* projection, which means the credential was not accepted for this channel.
|
|
5
|
+
* A distinct class because the HTTP status is 200: nothing else in the
|
|
6
|
+
* hierarchy would tell a caller that their token, not their request, is wrong.
|
|
7
|
+
*/
|
|
8
|
+
export declare class AdminProjectionError extends PlatformError {
|
|
9
|
+
constructor(what: string);
|
|
10
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { PlatformError } from '@sixtyninecommerce/core';
|
|
2
|
+
/**
|
|
3
|
+
* The CMS answered a public-with-optional-auth route with its STOREFRONT
|
|
4
|
+
* projection, which means the credential was not accepted for this channel.
|
|
5
|
+
* A distinct class because the HTTP status is 200: nothing else in the
|
|
6
|
+
* hierarchy would tell a caller that their token, not their request, is wrong.
|
|
7
|
+
*/
|
|
8
|
+
export class AdminProjectionError extends PlatformError {
|
|
9
|
+
constructor(what) {
|
|
10
|
+
super({
|
|
11
|
+
code: 'ADMIN_PROJECTION_UNAVAILABLE',
|
|
12
|
+
message: `The CMS returned the public projection for ${what}, so the credential was not accepted for this channel — it is invalid or expired, or its principal has no access. Admin-only fields are unavailable.`,
|
|
13
|
+
status: 403,
|
|
14
|
+
service: 'cms',
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@sixtyninecommerce/admin` — the back-office SDK.
|
|
3
|
+
*
|
|
4
|
+
* Every capability of the platform is a method here (the parity rule, spec §5).
|
|
5
|
+
* Phase 0 ships the skeleton: auth, personal API tokens, channel resolution,
|
|
6
|
+
* CMS forms and SEO redirects, plus the `platform` guard every cross-tenant
|
|
7
|
+
* method added later runs through.
|
|
8
|
+
*
|
|
9
|
+
* Server-side by default. A session bearer is welcome in a browser — the
|
|
10
|
+
* dashboard is one — but constructing a client with an API token there throws
|
|
11
|
+
* `CREDENTIAL_IN_BROWSER` (spec §18 amendment 3).
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Every type a consumer needs to NAME something `AdminClientConfig` accepts or
|
|
15
|
+
* a method returns. core is a transitive dependency, so under pnpm's strict
|
|
16
|
+
* node_modules they cannot import these from it even though that is where they
|
|
17
|
+
* are defined — without this they can only pass inline literals.
|
|
18
|
+
*/
|
|
19
|
+
export type { DeprecationNotice, Page, PageMeta, RateLimitInfo, RetryPolicy, } from '@sixtyninecommerce/core';
|
|
20
|
+
/**
|
|
21
|
+
* The error hierarchy this package throws, re-exported so a consumer can catch
|
|
22
|
+
* it without adding a direct dependency on `@sixtyninecommerce/core` — which,
|
|
23
|
+
* being transitive, pnpm would not resolve for them anyway.
|
|
24
|
+
*/
|
|
25
|
+
export { AuthenticationError, BusinessRuleError, ConflictError, type ErrorDetail, NotFoundError, PermissionDeniedError, PlatformError, type ProblemDetails, RateLimitedError, TransportError, UpstreamError, ValidationError, } from '@sixtyninecommerce/core';
|
|
26
|
+
export type { ApiTokensModule, CreatedApiToken } from './api-tokens/index.js';
|
|
27
|
+
export type { AuthModule } from './auth/index.js';
|
|
28
|
+
export { type AdminClient, type AdminClientConfig, createAdminClient, type SessionConfig, } from './client.js';
|
|
29
|
+
export type { Form, FormField, FormFieldInput, FormFieldTranslation, FormFieldTranslationInput, FormInput, FormsModule, FormTranslation, FormTranslationInput, } from './content/forms.js';
|
|
30
|
+
export { AdminProjectionError } from './errors.js';
|
|
31
|
+
export { requireHumanSession } from './platform/guard.js';
|
|
32
|
+
export type { Redirect, RedirectCreateInput, RedirectsModule, RedirectUpdateInput, } from './seo/redirects.js';
|
|
33
|
+
export type { ChannelsModule } from './tenant/channels.js';
|
|
34
|
+
export type { ApiToken, Call, ChannelInfo, CreateApiTokenInput, ListOptions, Me, TenantContext, TenantScope, UpdateApiTokenInput, WhoAmI, } from './types.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@sixtyninecommerce/admin` — the back-office SDK.
|
|
3
|
+
*
|
|
4
|
+
* Every capability of the platform is a method here (the parity rule, spec §5).
|
|
5
|
+
* Phase 0 ships the skeleton: auth, personal API tokens, channel resolution,
|
|
6
|
+
* CMS forms and SEO redirects, plus the `platform` guard every cross-tenant
|
|
7
|
+
* method added later runs through.
|
|
8
|
+
*
|
|
9
|
+
* Server-side by default. A session bearer is welcome in a browser — the
|
|
10
|
+
* dashboard is one — but constructing a client with an API token there throws
|
|
11
|
+
* `CREDENTIAL_IN_BROWSER` (spec §18 amendment 3).
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* The error hierarchy this package throws, re-exported so a consumer can catch
|
|
15
|
+
* it without adding a direct dependency on `@sixtyninecommerce/core` — which,
|
|
16
|
+
* being transitive, pnpm would not resolve for them anyway.
|
|
17
|
+
*/
|
|
18
|
+
export { AuthenticationError, BusinessRuleError, ConflictError, NotFoundError, PermissionDeniedError, PlatformError, RateLimitedError, TransportError, UpstreamError, ValidationError, } from '@sixtyninecommerce/core';
|
|
19
|
+
export { createAdminClient, } from './client.js';
|
|
20
|
+
export { AdminProjectionError } from './errors.js';
|
|
21
|
+
export { requireHumanSession } from './platform/guard.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AdminClientConfig } from '../client.js';
|
|
2
|
+
/**
|
|
3
|
+
* `admin.platform.*` — organizations, tenant provisioning, entitlements,
|
|
4
|
+
* channel lifecycle, fleet, cross-tenant audit — needs a human SuperAdmin
|
|
5
|
+
* session (spec §6.3, §15.10). The server refuses a token there anyway; the SDK
|
|
6
|
+
* refuses to SEND one, so a machine credential is never put on the wire towards
|
|
7
|
+
* a cross-tenant surface in the first place.
|
|
8
|
+
*
|
|
9
|
+
* Phase 0 ships no platform method. Every one added later calls this first.
|
|
10
|
+
*/
|
|
11
|
+
export declare function requireHumanSession(config: AdminClientConfig): void;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { PermissionDeniedError } from '@sixtyninecommerce/core';
|
|
2
|
+
/**
|
|
3
|
+
* `admin.platform.*` — organizations, tenant provisioning, entitlements,
|
|
4
|
+
* channel lifecycle, fleet, cross-tenant audit — needs a human SuperAdmin
|
|
5
|
+
* session (spec §6.3, §15.10). The server refuses a token there anyway; the SDK
|
|
6
|
+
* refuses to SEND one, so a machine credential is never put on the wire towards
|
|
7
|
+
* a cross-tenant surface in the first place.
|
|
8
|
+
*
|
|
9
|
+
* Phase 0 ships no platform method. Every one added later calls this first.
|
|
10
|
+
*/
|
|
11
|
+
export function requireHumanSession(config) {
|
|
12
|
+
if ('session' in config.auth)
|
|
13
|
+
return;
|
|
14
|
+
throw new PermissionDeniedError({
|
|
15
|
+
code: 'PLATFORM_REQUIRES_SESSION',
|
|
16
|
+
message: 'Platform operations need a human SuperAdmin session. An API token cannot be used for cross-tenant administration.',
|
|
17
|
+
status: 403,
|
|
18
|
+
service: 'vendure',
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Page, type Transport } from '@sixtyninecommerce/core';
|
|
2
|
+
import type { Call, ListOptions } from '../types.js';
|
|
3
|
+
export interface Redirect {
|
|
4
|
+
id: string;
|
|
5
|
+
channelId: string;
|
|
6
|
+
oldPath: string;
|
|
7
|
+
newPath: string;
|
|
8
|
+
statusCode: number;
|
|
9
|
+
/** `manual`, `import`, or `auto` (created by the URL-change engine). */
|
|
10
|
+
source?: string;
|
|
11
|
+
createdAt: string;
|
|
12
|
+
updatedAt: string;
|
|
13
|
+
[extra: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface RedirectCreateInput {
|
|
16
|
+
oldPath: string;
|
|
17
|
+
newPath: string;
|
|
18
|
+
statusCode?: number;
|
|
19
|
+
}
|
|
20
|
+
export type RedirectUpdateInput = Partial<RedirectCreateInput>;
|
|
21
|
+
export interface RedirectsModule {
|
|
22
|
+
list(options?: ListOptions): Promise<Page<Redirect>>;
|
|
23
|
+
create(input: RedirectCreateInput, options?: Call): Promise<Redirect>;
|
|
24
|
+
update(id: string, input: RedirectUpdateInput, options?: Call): Promise<Redirect>;
|
|
25
|
+
delete(id: string, options?: Call): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
export declare function createRedirectsModule(transport: Transport): RedirectsModule;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createPage, readRestPage } from '@sixtyninecommerce/core';
|
|
2
|
+
export function createRedirectsModule(transport) {
|
|
3
|
+
async function list(options) {
|
|
4
|
+
// `GET /redirects` answers with a bare array unless the request OPTS IN.
|
|
5
|
+
// Spec §18 amendment 9 supersedes amendment 5 for exactly this: a bare
|
|
6
|
+
// `?limit=` must not change a deployed caller's shape, so cursor mode is
|
|
7
|
+
// triggered by `?after=` or an explicit `?cursor=1` and by nothing else
|
|
8
|
+
// (`services/plugins/src/common/pagination/cursor.ts`, `wantsCursor`).
|
|
9
|
+
// Without the opt-in the service ignores `limit` and returns every
|
|
10
|
+
// non-removed row for the channel, and no page ever carries a cursor — so
|
|
11
|
+
// `after` is unreachable and `next()` can never fire.
|
|
12
|
+
const body = await transport.rest('plugins', {
|
|
13
|
+
method: 'GET',
|
|
14
|
+
path: '/redirects',
|
|
15
|
+
query: { cursor: 1, after: options?.after, limit: options?.limit },
|
|
16
|
+
}, options);
|
|
17
|
+
// `readRestPage` labels a malformed body as a CMS failure — it takes no
|
|
18
|
+
// service. Harmless here (the shape check is the same) but worth knowing if
|
|
19
|
+
// such an error ever shows up against the plugins service.
|
|
20
|
+
const { items, meta } = readRestPage(body);
|
|
21
|
+
return createPage(items, meta, (cursor) => list({ ...options, after: cursor }));
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
list,
|
|
25
|
+
create(input, options) {
|
|
26
|
+
return transport.rest('plugins', { method: 'POST', path: '/redirects', body: input }, options);
|
|
27
|
+
},
|
|
28
|
+
update(id, input, options) {
|
|
29
|
+
return transport.rest('plugins', { method: 'PATCH', path: `/redirects/${encodeURIComponent(id)}`, body: input }, options);
|
|
30
|
+
},
|
|
31
|
+
async delete(id, options) {
|
|
32
|
+
// The endpoint answers 200 with `{ message }`, not 204 — `expect: 'void'`
|
|
33
|
+
// discards the body rather than pretending it is a Redirect.
|
|
34
|
+
await transport.rest('plugins', { method: 'DELETE', path: `/redirects/${encodeURIComponent(id)}`, expect: 'void' }, options);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ChannelInfo, ChannelResolver } from '@sixtyninecommerce/core';
|
|
2
|
+
export interface ChannelsModule {
|
|
3
|
+
/** Resolve a channel code to its id, token and defaults (cached). */
|
|
4
|
+
byCode(code: string): Promise<ChannelInfo>;
|
|
5
|
+
}
|
|
6
|
+
export declare function createChannelsModule(resolver: ChannelResolver): ChannelsModule;
|