@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/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
+ }
@@ -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
+ }
@@ -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;
@@ -0,0 +1,7 @@
1
+ export function createChannelsModule(resolver) {
2
+ return {
3
+ byCode(code) {
4
+ return resolver.resolve(code);
5
+ },
6
+ };
7
+ }