@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/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# @sixtyninecommerce/admin
|
|
2
|
+
|
|
3
|
+
The back-office SDK for sixtynine-commerce. Every capability of the platform is a method here —
|
|
4
|
+
no raw GraphQL, no REST paths, no header names.
|
|
5
|
+
|
|
6
|
+
Built on [`@sixtyninecommerce/core`](https://www.npmjs.com/package/@sixtyninecommerce/core), which
|
|
7
|
+
gives it the tenant context, the error hierarchy, cursor pagination, idempotency keys and retries.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @sixtyninecommerce/admin
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { createAdminClient } from '@sixtyninecommerce/admin';
|
|
19
|
+
|
|
20
|
+
const admin = createAdminClient({
|
|
21
|
+
urls: {
|
|
22
|
+
vendureUrl: 'https://engine.sixtyninecommerce.com',
|
|
23
|
+
cmsUrl: 'https://cms.sixtyninecommerce.com',
|
|
24
|
+
pluginsUrl: 'https://plugins.sixtyninecommerce.com',
|
|
25
|
+
},
|
|
26
|
+
auth: { token: process.env.SIXTYNINE_API_TOKEN! },
|
|
27
|
+
tenant: { scope: { kind: 'channel', code: 'velvet-thread' } },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
for await (const form of await admin.content.forms.list()) {
|
|
31
|
+
console.log(form.slug);
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Credentials
|
|
36
|
+
|
|
37
|
+
| Principal | How | Where |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| API token | `auth: { token: 's69t_…' }` | server only — constructing one in a browser throws `CREDENTIAL_IN_BROWSER` |
|
|
40
|
+
| Admin session | `auth: { session: { token?, onToken } }` | server or browser |
|
|
41
|
+
|
|
42
|
+
A session client can start empty and sign in:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
const session: { token?: string } = {};
|
|
46
|
+
const admin = createAdminClient({ urls, auth: { session } });
|
|
47
|
+
const me = await admin.auth.login('user@example.com', 'secret');
|
|
48
|
+
const tenant = admin.withTenant({ scope: { kind: 'channel', code: me.channels[0]!.code } });
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**The session object you pass IS where the bearer is stored.** Vendure returns a fresh one on a
|
|
52
|
+
response header and the SDK writes it back into `session.token`, so it must be writable — a plain
|
|
53
|
+
property, or a getter with a setter over your own store. A read-only one is refused at construction
|
|
54
|
+
with `SESSION_NOT_WRITABLE` rather than dropping every rotation silently. `onToken` is a
|
|
55
|
+
notification for persisting it across processes, and it fires with `''` when `logout()` ends the
|
|
56
|
+
session so a stored copy can be invalidated.
|
|
57
|
+
|
|
58
|
+
**In a server, give each request its own session object** — one shared object is one shared
|
|
59
|
+
credential:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
const admin = createAdminClient({
|
|
63
|
+
urls,
|
|
64
|
+
auth: {
|
|
65
|
+
session: {
|
|
66
|
+
get token() { return requestContext.token; },
|
|
67
|
+
set token(next: string | undefined) { requestContext.token = next; },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
A bearer is not proof of a session: Vendure mints an *anonymous* one for an unauthenticated read.
|
|
74
|
+
Treat "signed in" as "`auth.me()` resolved", never as "a token is stored".
|
|
75
|
+
|
|
76
|
+
### What an API token cannot do
|
|
77
|
+
|
|
78
|
+
`apiTokens.*` needs a human session. The engine strips `ManageApiToken` from every token it
|
|
79
|
+
issues, so a token can never manage tokens — including its own — and `platform.*` refuses a token
|
|
80
|
+
in the SDK before the request leaves the process. Mint and revoke credentials from a signed-in
|
|
81
|
+
session:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
const session: { token?: string } = {};
|
|
85
|
+
const human = createAdminClient({ urls, auth: { session } });
|
|
86
|
+
await human.auth.login('ops@example.com', process.env.OPS_PASSWORD!);
|
|
87
|
+
const scoped = human.withTenant({ scope: { kind: 'channel', code: 'velvet-thread' } });
|
|
88
|
+
|
|
89
|
+
const created = await scoped.apiTokens.create({
|
|
90
|
+
name: 'ERP sync',
|
|
91
|
+
permissions: ['ReadOrder'],
|
|
92
|
+
expiresAt: '2027-01-01T00:00:00Z',
|
|
93
|
+
});
|
|
94
|
+
console.log(created.token); // shown exactly once
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Knowing what credential you hold
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
const who = await admin.auth.whoami();
|
|
101
|
+
who.kind; // 'api_token' | 'user'
|
|
102
|
+
who.token?.expiresAt; // null means it never expires
|
|
103
|
+
who.permissions; // effective on the ACTIVE channel, not the union
|
|
104
|
+
who.entitlements; // connected plugin keys for that channel
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
For a machine credential this is the only way to learn any of it without being refused. It needs a
|
|
108
|
+
tenant — permissions and entitlements have no meaning without a channel — and it is admin-plane
|
|
109
|
+
only: a storefront principal is refused with `PermissionDeniedError`.
|
|
110
|
+
|
|
111
|
+
### Tenancy
|
|
112
|
+
|
|
113
|
+
A client with no `tenant.scope` may only call `auth.*` and `channels.byCode()`; every other module
|
|
114
|
+
throws `TENANT_REQUIRED`. An administrator with several channels must say which one they mean — the
|
|
115
|
+
server never guesses. `channels.byCode()` is the exception on purpose: it names the channel in its
|
|
116
|
+
own argument, and it is how you look up the code you are about to scope to. `withTenant()` derives
|
|
117
|
+
a client on another tenant, sharing the principal and the channel cache.
|
|
118
|
+
|
|
119
|
+
### Writes
|
|
120
|
+
|
|
121
|
+
Every write takes `{ idempotencyKey?, tenant?, signal? }`. The key is sent as `Idempotency-Key` on a
|
|
122
|
+
REST write and dropped on a GraphQL one — the Admin API has no header for it, so an `apiTokens.*`
|
|
123
|
+
write is never deduplicated.
|
|
124
|
+
|
|
125
|
+
| Write | Deduplicated? |
|
|
126
|
+
|---|---|
|
|
127
|
+
| `content.forms.upsertBySlug` | yes — the CMS replays the first response for the same key |
|
|
128
|
+
| `seo.redirects.create` | yes — the plugins service does the same |
|
|
129
|
+
| everything else | no; the key is recorded on the wire and acts on nothing |
|
|
130
|
+
|
|
131
|
+
Where it is honoured, the same key with a **different** body is refused with 409. This SDK never
|
|
132
|
+
retries a write on its own, so a repeat is always one you sent: use a fresh key per intended write
|
|
133
|
+
and reuse it only when retrying that exact write.
|
|
134
|
+
|
|
135
|
+
### Errors
|
|
136
|
+
|
|
137
|
+
One hierarchy, all re-exported from this package so you need no direct dependency on
|
|
138
|
+
`@sixtyninecommerce/core`: `ValidationError`, `AuthenticationError`, `PermissionDeniedError`,
|
|
139
|
+
`NotFoundError`, `ConflictError`, `BusinessRuleError`, `RateLimitedError`, `UpstreamError`,
|
|
140
|
+
`TransportError`, and `AdminProjectionError` — this package's own, thrown by `content.forms` when
|
|
141
|
+
the CMS answers a public route with its storefront projection because the credential was not
|
|
142
|
+
accepted for the channel (`ADMIN_PROJECTION_UNAVAILABLE`). All extend `PlatformError`. Branch on
|
|
143
|
+
`error.code`.
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
MIT
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type Page, type Transport } from '@sixtyninecommerce/core';
|
|
2
|
+
import type { ApiToken, Call, CreateApiTokenInput, ListOptions, UpdateApiTokenInput } from '../types.js';
|
|
3
|
+
export declare const API_TOKEN_FIELDS = "\n id\n name\n tokenPrefix\n permissions\n channelId\n expiresAt\n lastUsedAt\n revokedAt\n createdAt\n";
|
|
4
|
+
export declare const LIST_QUERY = "\n query ApiTokens($options: ApiTokenListOptions) {\n apiTokens(options: $options) {\n totalItems\n items { \n id\n name\n tokenPrefix\n permissions\n channelId\n expiresAt\n lastUsedAt\n revokedAt\n createdAt\n }\n }\n }\n";
|
|
5
|
+
export declare const CREATE_MUTATION = "\n mutation CreateApiToken($input: CreateApiTokenInput!) {\n createApiToken(input: $input) {\n token\n apiToken { \n id\n name\n tokenPrefix\n permissions\n channelId\n expiresAt\n lastUsedAt\n revokedAt\n createdAt\n }\n }\n }\n";
|
|
6
|
+
export declare const UPDATE_MUTATION = "\n mutation UpdateApiToken($input: UpdateApiTokenInput!) {\n updateApiToken(input: $input) { \n id\n name\n tokenPrefix\n permissions\n channelId\n expiresAt\n lastUsedAt\n revokedAt\n createdAt\n }\n }\n";
|
|
7
|
+
export declare const REVOKE_MUTATION = "\n mutation RevokeApiToken($id: ID!) {\n revokeApiToken(id: $id) { \n id\n name\n tokenPrefix\n permissions\n channelId\n expiresAt\n lastUsedAt\n revokedAt\n createdAt\n }\n }\n";
|
|
8
|
+
export declare const DELETE_MUTATION = "\n mutation DeleteApiTokens($ids: [ID!]!) {\n deleteApiTokens(ids: $ids) {\n result\n message\n }\n }\n";
|
|
9
|
+
/** The raw token is shown exactly once, at creation. */
|
|
10
|
+
export interface CreatedApiToken {
|
|
11
|
+
token: string;
|
|
12
|
+
apiToken: ApiToken;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Managing API tokens needs a HUMAN SESSION. The engine strips
|
|
16
|
+
* `ManageApiToken` from every grant set it issues (`NEVER_GRANTABLE`), so a
|
|
17
|
+
* token can never manage tokens — including its own — and every method here
|
|
18
|
+
* answers `PermissionDeniedError FORBIDDEN` for a machine credential. That is
|
|
19
|
+
* the engine's rule, not the SDK's, so it is documented rather than guarded:
|
|
20
|
+
* the refusal arrives from the API that owns it.
|
|
21
|
+
*/
|
|
22
|
+
export interface ApiTokensModule {
|
|
23
|
+
list(options?: ListOptions): Promise<Page<ApiToken>>;
|
|
24
|
+
create(input: CreateApiTokenInput, options?: Call): Promise<CreatedApiToken>;
|
|
25
|
+
update(id: string, input: UpdateApiTokenInput, options?: Call): Promise<ApiToken>;
|
|
26
|
+
revoke(id: string, options?: Call): Promise<ApiToken>;
|
|
27
|
+
delete(ids: string[], options?: Call): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
export declare function createApiTokensModule(transport: Transport): ApiTokensModule;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { ConflictError, pageFromVendureList, readOffsetCursor, } from '@sixtyninecommerce/core';
|
|
2
|
+
export const API_TOKEN_FIELDS = /* GraphQL */ `
|
|
3
|
+
id
|
|
4
|
+
name
|
|
5
|
+
tokenPrefix
|
|
6
|
+
permissions
|
|
7
|
+
channelId
|
|
8
|
+
expiresAt
|
|
9
|
+
lastUsedAt
|
|
10
|
+
revokedAt
|
|
11
|
+
createdAt
|
|
12
|
+
`;
|
|
13
|
+
export const LIST_QUERY = /* GraphQL */ `
|
|
14
|
+
query ApiTokens($options: ApiTokenListOptions) {
|
|
15
|
+
apiTokens(options: $options) {
|
|
16
|
+
totalItems
|
|
17
|
+
items { ${API_TOKEN_FIELDS} }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
`;
|
|
21
|
+
export const CREATE_MUTATION = /* GraphQL */ `
|
|
22
|
+
mutation CreateApiToken($input: CreateApiTokenInput!) {
|
|
23
|
+
createApiToken(input: $input) {
|
|
24
|
+
token
|
|
25
|
+
apiToken { ${API_TOKEN_FIELDS} }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
`;
|
|
29
|
+
export const UPDATE_MUTATION = /* GraphQL */ `
|
|
30
|
+
mutation UpdateApiToken($input: UpdateApiTokenInput!) {
|
|
31
|
+
updateApiToken(input: $input) { ${API_TOKEN_FIELDS} }
|
|
32
|
+
}
|
|
33
|
+
`;
|
|
34
|
+
export const REVOKE_MUTATION = /* GraphQL */ `
|
|
35
|
+
mutation RevokeApiToken($id: ID!) {
|
|
36
|
+
revokeApiToken(id: $id) { ${API_TOKEN_FIELDS} }
|
|
37
|
+
}
|
|
38
|
+
`;
|
|
39
|
+
export const DELETE_MUTATION = /* GraphQL */ `
|
|
40
|
+
mutation DeleteApiTokens($ids: [ID!]!) {
|
|
41
|
+
deleteApiTokens(ids: $ids) {
|
|
42
|
+
result
|
|
43
|
+
message
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
`;
|
|
47
|
+
const DEFAULT_PAGE_SIZE = 25;
|
|
48
|
+
export function createApiTokensModule(transport) {
|
|
49
|
+
async function listPage(skip, take, options) {
|
|
50
|
+
const data = await transport.graphql({ api: 'admin', query: LIST_QUERY, variables: { options: { skip, take } } }, options);
|
|
51
|
+
return pageFromVendureList(data.apiTokens, skip, take, (cursor) => listPage(readOffsetCursor(cursor), take, options));
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
list(options) {
|
|
55
|
+
// The Admin API is offset-paged; the offset is hidden inside the cursor so
|
|
56
|
+
// a consumer only ever sees `Page<T>` (spec §9.3).
|
|
57
|
+
const take = options?.limit ?? DEFAULT_PAGE_SIZE;
|
|
58
|
+
const skip = options?.after === undefined ? 0 : readOffsetCursor(options.after);
|
|
59
|
+
return listPage(skip, take, options);
|
|
60
|
+
},
|
|
61
|
+
async create(input, options) {
|
|
62
|
+
const data = await transport.graphql({ api: 'admin', query: CREATE_MUTATION, variables: { input } }, options);
|
|
63
|
+
return data.createApiToken;
|
|
64
|
+
},
|
|
65
|
+
async update(id, input, options) {
|
|
66
|
+
const data = await transport.graphql({ api: 'admin', query: UPDATE_MUTATION, variables: { input: { id, ...input } } }, options);
|
|
67
|
+
return data.updateApiToken;
|
|
68
|
+
},
|
|
69
|
+
async revoke(id, options) {
|
|
70
|
+
const data = await transport.graphql({ api: 'admin', query: REVOKE_MUTATION, variables: { id } }, options);
|
|
71
|
+
return data.revokeApiToken;
|
|
72
|
+
},
|
|
73
|
+
async delete(ids, options) {
|
|
74
|
+
const data = await transport.graphql({ api: 'admin', query: DELETE_MUTATION, variables: { ids } }, options);
|
|
75
|
+
// `deleteApiTokens` reports per-id outcomes at HTTP 200 with no error, so
|
|
76
|
+
// a caller awaiting a void promise would read a refusal as success.
|
|
77
|
+
const refused = data.deleteApiTokens.filter((entry) => entry.result !== 'DELETED');
|
|
78
|
+
if (refused.length > 0) {
|
|
79
|
+
throw new ConflictError({
|
|
80
|
+
code: 'API_TOKEN_NOT_DELETED',
|
|
81
|
+
message: `${String(refused.length)} of ${String(ids.length)} tokens were not deleted: ${refused
|
|
82
|
+
.map((entry) => entry.message ?? 'no reason given')
|
|
83
|
+
.join('; ')}`,
|
|
84
|
+
status: 409,
|
|
85
|
+
service: 'vendure',
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { type Transport } from '@sixtyninecommerce/core';
|
|
2
|
+
import type { Call, Me, WhoAmI } from '../types.js';
|
|
3
|
+
export interface AuthModule {
|
|
4
|
+
/** Establishes a session. The bearer arrives on a response header, not here. */
|
|
5
|
+
login(username: string, password: string, options?: Call): Promise<Me>;
|
|
6
|
+
/**
|
|
7
|
+
* Ends the session server-side AND clears the bearer, in the caller's own
|
|
8
|
+
* session object. `onToken` fires with `''` so a persisted copy — a cookie, a
|
|
9
|
+
* keychain entry, `localStorage` — can be invalidated in the same step.
|
|
10
|
+
*
|
|
11
|
+
* Idempotent: with no bearer in hand there is nothing to end, and asking
|
|
12
|
+
* anyway would make Vendure mint an anonymous session.
|
|
13
|
+
*/
|
|
14
|
+
logout(options?: Call): Promise<void>;
|
|
15
|
+
me(options?: Call): Promise<Me>;
|
|
16
|
+
/**
|
|
17
|
+
* Describe the credential in hand: its kind, its channels, and — for the
|
|
18
|
+
* ACTIVE channel — its effective permissions and the plugin entitlements its
|
|
19
|
+
* tenant has. For a machine credential this is the only way to learn any of
|
|
20
|
+
* that without being refused; `token.expiresAt` is what an integration checks
|
|
21
|
+
* against now.
|
|
22
|
+
*
|
|
23
|
+
* Unlike the rest of `auth`, it needs a tenant: permissions and entitlements
|
|
24
|
+
* have no meaning without a channel, and the platform refuses to guess one.
|
|
25
|
+
* It is also an admin-plane endpoint — a storefront principal is refused with
|
|
26
|
+
* `PermissionDeniedError`.
|
|
27
|
+
*/
|
|
28
|
+
whoami(options?: Call): Promise<WhoAmI>;
|
|
29
|
+
}
|
|
30
|
+
/** The session bearer's store, when the client has one (a token client does not). */
|
|
31
|
+
export interface SessionControl {
|
|
32
|
+
/** Is there a bearer to send at all? */
|
|
33
|
+
present(): boolean;
|
|
34
|
+
/** The bearer in force right now — what the next call will send. */
|
|
35
|
+
current(): string;
|
|
36
|
+
/** How many rotations this client has STORED, to tell one call's apart. */
|
|
37
|
+
rotations(): number;
|
|
38
|
+
/**
|
|
39
|
+
* Forget it. `sent` makes it conditional: clear only while the slot still
|
|
40
|
+
* holds that bearer, so a late response proving one credential dead cannot
|
|
41
|
+
* sign out the session that replaced it.
|
|
42
|
+
*/
|
|
43
|
+
end(sent?: string): void;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* `session` is present only for a session principal. It exists for two reasons.
|
|
47
|
+
*
|
|
48
|
+
* `logout()` has to clear the bearer — Vendure signals sign-out with an EMPTY
|
|
49
|
+
* `vendure-auth-token` header, which the transport correctly refuses to treat
|
|
50
|
+
* as a rotation, so nothing else ever would.
|
|
51
|
+
*
|
|
52
|
+
* And `me()`/`logout()` must not go out at all without one. `me` is
|
|
53
|
+
* `@Allow(Authenticated, Owner)`, and for an Owner-allowed operation Vendure
|
|
54
|
+
* MINTS AN ANONYMOUS SESSION for an unauthenticated request and returns its
|
|
55
|
+
* token on `vendure-auth-token`. The SDK would store that as if it were a
|
|
56
|
+
* credential, so a dashboard that signed out and re-checked `me()` would have a
|
|
57
|
+
* bearer again — and an app gating its shell on "is a token stored?" would show
|
|
58
|
+
* itself as signed in. Refusing before the wire is also the honest answer: with
|
|
59
|
+
* no bearer there is no session to report on.
|
|
60
|
+
*/
|
|
61
|
+
export declare function createAuthModule(transport: Transport, session?: SessionControl): AuthModule;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { AuthenticationError, errorFromErrorResult, isErrorResult, PermissionDeniedError, UNSCOPED_TENANT, } from '@sixtyninecommerce/core';
|
|
2
|
+
import { LOGIN_MUTATION, LOGOUT_MUTATION, ME_QUERY } from './queries.js';
|
|
3
|
+
/**
|
|
4
|
+
* `me` is nullable on the Admin API: an unauthenticated request is answered
|
|
5
|
+
* with `null`, not with an error. Returning that as a `Me` would hand the
|
|
6
|
+
* caller `undefined` under a `string` type, so it is turned into the
|
|
7
|
+
* authentication failure it actually is.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* A bearer the Admin API does not accept makes `me` answer `FORBIDDEN`, which
|
|
11
|
+
* reads as PermissionDeniedError — "you may not do this" — when the truth is
|
|
12
|
+
* "your session is over". That sends a dashboard to an error page instead of to
|
|
13
|
+
* its sign-in screen, so it is narrowed for `me` ONLY; everywhere else
|
|
14
|
+
* FORBIDDEN keeps its meaning.
|
|
15
|
+
*
|
|
16
|
+
* Verified against the installed engine rather than assumed: `me` is
|
|
17
|
+
* `@Allow(Permission.Authenticated, Permission.Owner)`, and because `Owner` is
|
|
18
|
+
* in that list the guard never refuses it — every FORBIDDEN comes from
|
|
19
|
+
* `BaseAuthResolver.me`, which throws when there is no active user AND when an
|
|
20
|
+
* authenticated user has no Administrator row. The second is a real
|
|
21
|
+
* authorization refusal, but it is unreachable through this SDK: `login()`
|
|
22
|
+
* refuses a non-administrator on the Admin API in the first place. So the
|
|
23
|
+
* narrowing is safe for every session this client can hold, and `cause` keeps
|
|
24
|
+
* the original for the case that is not.
|
|
25
|
+
*/
|
|
26
|
+
function asAuthFailure(error) {
|
|
27
|
+
if (!(error instanceof PermissionDeniedError))
|
|
28
|
+
return error;
|
|
29
|
+
return new AuthenticationError({
|
|
30
|
+
code: 'NOT_AUTHENTICATED',
|
|
31
|
+
message: 'No active session. Call auth.login() first, or supply an API token.',
|
|
32
|
+
status: 401,
|
|
33
|
+
service: 'vendure',
|
|
34
|
+
...(error.traceId ? { traceId: error.traceId } : {}),
|
|
35
|
+
cause: error,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function readMe(data) {
|
|
39
|
+
const me = data.me;
|
|
40
|
+
if (!me) {
|
|
41
|
+
// Built directly rather than run through `errorFromErrorResult`: Vendure
|
|
42
|
+
// sent no ErrorResult, it sent `null`. Synthesising one would land on the
|
|
43
|
+
// default branch and classify a missing session as a business rule.
|
|
44
|
+
throw new AuthenticationError({
|
|
45
|
+
code: 'NOT_AUTHENTICATED',
|
|
46
|
+
message: 'No active session. Call auth.login() first, or supply an API token.',
|
|
47
|
+
status: 401,
|
|
48
|
+
service: 'vendure',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
id: me.id,
|
|
53
|
+
identifier: me.identifier,
|
|
54
|
+
channels: me.channels,
|
|
55
|
+
administrator: data.activeAdministrator,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* `session` is present only for a session principal. It exists for two reasons.
|
|
60
|
+
*
|
|
61
|
+
* `logout()` has to clear the bearer — Vendure signals sign-out with an EMPTY
|
|
62
|
+
* `vendure-auth-token` header, which the transport correctly refuses to treat
|
|
63
|
+
* as a rotation, so nothing else ever would.
|
|
64
|
+
*
|
|
65
|
+
* And `me()`/`logout()` must not go out at all without one. `me` is
|
|
66
|
+
* `@Allow(Authenticated, Owner)`, and for an Owner-allowed operation Vendure
|
|
67
|
+
* MINTS AN ANONYMOUS SESSION for an unauthenticated request and returns its
|
|
68
|
+
* token on `vendure-auth-token`. The SDK would store that as if it were a
|
|
69
|
+
* credential, so a dashboard that signed out and re-checked `me()` would have a
|
|
70
|
+
* bearer again — and an app gating its shell on "is a token stored?" would show
|
|
71
|
+
* itself as signed in. Refusing before the wire is also the honest answer: with
|
|
72
|
+
* no bearer there is no session to report on.
|
|
73
|
+
*/
|
|
74
|
+
export function createAuthModule(transport, session) {
|
|
75
|
+
const notAuthenticated = () => new AuthenticationError({
|
|
76
|
+
code: 'NOT_AUTHENTICATED',
|
|
77
|
+
message: 'No active session. Call auth.login() first, or supply an API token.',
|
|
78
|
+
status: 401,
|
|
79
|
+
service: 'vendure',
|
|
80
|
+
});
|
|
81
|
+
/**
|
|
82
|
+
* Auth documents go out UNSCOPED, even on a tenant-scoped client: a session
|
|
83
|
+
* belongs to the user, not to a tenant, and `login` happens before the caller
|
|
84
|
+
* knows which channels it may address. Sending a channel token there would
|
|
85
|
+
* also make a client that has no tenant yet unable to sign in at all.
|
|
86
|
+
*/
|
|
87
|
+
const unscoped = (options) => ({
|
|
88
|
+
...options,
|
|
89
|
+
tenant: UNSCOPED_TENANT,
|
|
90
|
+
allowUnscoped: true,
|
|
91
|
+
});
|
|
92
|
+
async function me(options) {
|
|
93
|
+
if (session && !session.present())
|
|
94
|
+
throw notAuthenticated();
|
|
95
|
+
const sent = session?.current();
|
|
96
|
+
try {
|
|
97
|
+
const data = await transport.graphql({ api: 'admin', query: ME_QUERY }, unscoped(options));
|
|
98
|
+
return readMe(data);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
const failure = asAuthFailure(error);
|
|
102
|
+
// A bearer the engine refused is dead, and `me` is Owner-allowed — so
|
|
103
|
+
// Vendure answered this very request by MINTING an anonymous session and
|
|
104
|
+
// returning its token, which the client has just stored as if it were a
|
|
105
|
+
// credential. Drop it, or `present()` reports a session the user never
|
|
106
|
+
// obtained and an app gating on "is a token stored?" stays signed in.
|
|
107
|
+
if (failure instanceof AuthenticationError)
|
|
108
|
+
session?.end(sent);
|
|
109
|
+
throw failure;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
async login(username, password, options) {
|
|
114
|
+
const rotationsBefore = session?.rotations();
|
|
115
|
+
const data = await transport.graphql({ api: 'admin', query: LOGIN_MUTATION, variables: { username, password } }, unscoped(options));
|
|
116
|
+
// Vendure answers a bad password with HTTP 200 and an ErrorResult member.
|
|
117
|
+
if (isErrorResult(data.login))
|
|
118
|
+
throw errorFromErrorResult(data.login);
|
|
119
|
+
// The session bearer arrives on the `vendure-auth-token` response header.
|
|
120
|
+
// If none did, the credentials were right and the client still has no way
|
|
121
|
+
// to authenticate anything — say that, rather than let the `me()` below
|
|
122
|
+
// report it as "no active session" after a successful sign-in.
|
|
123
|
+
//
|
|
124
|
+
// Asked as "did THIS sign-in store a bearer", never as "is some bearer
|
|
125
|
+
// present": a client that already held one — an account switch, or a
|
|
126
|
+
// token restored from a keychain — would otherwise skip the check, keep
|
|
127
|
+
// the OLD bearer, and resolve `login()` as the administrator who was
|
|
128
|
+
// already signed in. Vendure does not invalidate their session, so every
|
|
129
|
+
// later call would act as them, audit trail included.
|
|
130
|
+
if (session && session.rotations() === rotationsBefore) {
|
|
131
|
+
throw new AuthenticationError({
|
|
132
|
+
code: 'NO_SESSION_BEARER',
|
|
133
|
+
message: 'The engine accepted the credentials but returned no vendure-auth-token header, so this client has no bearer to send. The Admin API must be configured with the `bearer` token method.',
|
|
134
|
+
status: 401,
|
|
135
|
+
service: 'vendure',
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return me(options);
|
|
139
|
+
},
|
|
140
|
+
async logout(options) {
|
|
141
|
+
// Idempotent, and it must not go out unauthenticated: Vendure would mint
|
|
142
|
+
// an anonymous session and hand back its token, arming the client again.
|
|
143
|
+
if (session && !session.present())
|
|
144
|
+
return;
|
|
145
|
+
await transport.graphql({ api: 'admin', query: LOGOUT_MUTATION }, unscoped(options));
|
|
146
|
+
// Only after the server confirms: clearing on a failed logout would tell
|
|
147
|
+
// the caller they are signed out while the session is still live.
|
|
148
|
+
session?.end();
|
|
149
|
+
},
|
|
150
|
+
// `async` so the guard REJECTS rather than throwing synchronously: every
|
|
151
|
+
// other method on this client returns a rejected promise, and a caller who
|
|
152
|
+
// only attached `.catch()` would otherwise get an uncatchable throw.
|
|
153
|
+
async whoami(options) {
|
|
154
|
+
if (session && !session.present())
|
|
155
|
+
throw notAuthenticated();
|
|
156
|
+
// Scoped on purpose: `x-channel-id` is what selects the active channel,
|
|
157
|
+
// and the service refuses a multi-channel principal that names none.
|
|
158
|
+
return transport.rest('plugins', { method: 'GET', path: '/me' }, options);
|
|
159
|
+
},
|
|
160
|
+
me,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Admin API documents this SDK's auth uses.
|
|
3
|
+
*
|
|
4
|
+
* `LOGIN` is sent WITHOUT a channel token: a session belongs to the user, not
|
|
5
|
+
* to a tenant, and the channels the user may address come back from `ME`.
|
|
6
|
+
* Vendure returns the session bearer on the `vendure-auth-token` RESPONSE
|
|
7
|
+
* header, which the transport hands to `Principal.onToken`.
|
|
8
|
+
*/
|
|
9
|
+
export declare const LOGIN_MUTATION = "\n mutation Login($username: String!, $password: String!) {\n login(username: $username, password: $password) {\n __typename\n ... on CurrentUser {\n id\n }\n ... on ErrorResult {\n errorCode\n message\n }\n }\n }\n";
|
|
10
|
+
export declare const ME_QUERY = "\n query Me {\n me {\n id\n identifier\n channels {\n id\n code\n token\n permissions\n }\n }\n activeAdministrator {\n id\n firstName\n lastName\n emailAddress\n }\n }\n";
|
|
11
|
+
export declare const LOGOUT_MUTATION = "\n mutation Logout {\n logout {\n success\n }\n }\n";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Admin API documents this SDK's auth uses.
|
|
3
|
+
*
|
|
4
|
+
* `LOGIN` is sent WITHOUT a channel token: a session belongs to the user, not
|
|
5
|
+
* to a tenant, and the channels the user may address come back from `ME`.
|
|
6
|
+
* Vendure returns the session bearer on the `vendure-auth-token` RESPONSE
|
|
7
|
+
* header, which the transport hands to `Principal.onToken`.
|
|
8
|
+
*/
|
|
9
|
+
export const LOGIN_MUTATION = /* GraphQL */ `
|
|
10
|
+
mutation Login($username: String!, $password: String!) {
|
|
11
|
+
login(username: $username, password: $password) {
|
|
12
|
+
__typename
|
|
13
|
+
... on CurrentUser {
|
|
14
|
+
id
|
|
15
|
+
}
|
|
16
|
+
... on ErrorResult {
|
|
17
|
+
errorCode
|
|
18
|
+
message
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
`;
|
|
23
|
+
export const ME_QUERY = /* GraphQL */ `
|
|
24
|
+
query Me {
|
|
25
|
+
me {
|
|
26
|
+
id
|
|
27
|
+
identifier
|
|
28
|
+
channels {
|
|
29
|
+
id
|
|
30
|
+
code
|
|
31
|
+
token
|
|
32
|
+
permissions
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
activeAdministrator {
|
|
36
|
+
id
|
|
37
|
+
firstName
|
|
38
|
+
lastName
|
|
39
|
+
emailAddress
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
`;
|
|
43
|
+
export const LOGOUT_MUTATION = /* GraphQL */ `
|
|
44
|
+
mutation Logout {
|
|
45
|
+
logout {
|
|
46
|
+
success
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
`;
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { type DeprecationNotice, type RateLimitInfo, type RetryPolicy, type TenantContext, type TenantScope } from '@sixtyninecommerce/core';
|
|
2
|
+
import { type ApiTokensModule } from './api-tokens/index.js';
|
|
3
|
+
import { type AuthModule } from './auth/index.js';
|
|
4
|
+
import { type FormsModule } from './content/forms.js';
|
|
5
|
+
import { type RedirectsModule } from './seo/redirects.js';
|
|
6
|
+
import { type ChannelsModule } from './tenant/channels.js';
|
|
7
|
+
export interface AdminClientConfig {
|
|
8
|
+
urls: {
|
|
9
|
+
vendureUrl: string;
|
|
10
|
+
cmsUrl: string;
|
|
11
|
+
pluginsUrl: string;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* A machine credential (`token`) or a human session.
|
|
15
|
+
*
|
|
16
|
+
* A session may start empty: the client holds the bearer `login()` obtains,
|
|
17
|
+
* so nothing has to be stored to use it. It never writes back into this
|
|
18
|
+
* object — `onToken` is the only way a caller learns the bearer, and it is
|
|
19
|
+
* what to persist across processes.
|
|
20
|
+
*
|
|
21
|
+
* `token` is read LIVE on every call, so a getter over the caller's own
|
|
22
|
+
* store works: whenever that value changes it becomes the bearer, which is
|
|
23
|
+
* how a per-request credential, a token restored after construction, and a
|
|
24
|
+
* sign-out that clears it all take effect.
|
|
25
|
+
*/
|
|
26
|
+
auth: {
|
|
27
|
+
token: string;
|
|
28
|
+
} | {
|
|
29
|
+
session: SessionConfig;
|
|
30
|
+
};
|
|
31
|
+
tenant?: Partial<TenantContext> & {
|
|
32
|
+
scope?: TenantScope;
|
|
33
|
+
};
|
|
34
|
+
fetch?: typeof globalThis.fetch;
|
|
35
|
+
retry?: Partial<RetryPolicy>;
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
onDeprecation?: (notice: DeprecationNotice) => void;
|
|
38
|
+
}
|
|
39
|
+
export interface AdminClient {
|
|
40
|
+
auth: AuthModule;
|
|
41
|
+
apiTokens: ApiTokensModule;
|
|
42
|
+
channels: ChannelsModule;
|
|
43
|
+
content: {
|
|
44
|
+
forms: FormsModule;
|
|
45
|
+
};
|
|
46
|
+
seo: {
|
|
47
|
+
redirects: RedirectsModule;
|
|
48
|
+
};
|
|
49
|
+
/** Human SuperAdmin surface. Empty in phase 0. */
|
|
50
|
+
platform: Record<string, never>;
|
|
51
|
+
/** A derived client on another tenant, sharing this one's principal. */
|
|
52
|
+
withTenant(tenant: Partial<TenantContext> & {
|
|
53
|
+
scope?: TenantScope;
|
|
54
|
+
}): AdminClient;
|
|
55
|
+
readonly rateLimit: RateLimitInfo | null;
|
|
56
|
+
}
|
|
57
|
+
export interface SessionConfig {
|
|
58
|
+
/**
|
|
59
|
+
* The session bearer, and where this client STORES it. Vendure returns a
|
|
60
|
+
* fresh one on a response header, and the SDK writes it back here — so this
|
|
61
|
+
* object, not a hidden slot inside the client, is the single source of truth.
|
|
62
|
+
* It must be writable (a plain property, or a getter WITH a setter over your
|
|
63
|
+
* own store); `createAdminClient` refuses a read-only one rather than
|
|
64
|
+
* silently dropping every rotation.
|
|
65
|
+
*
|
|
66
|
+
* **In a server, give each request its own session object** (or a
|
|
67
|
+
* getter/setter pair over your request context). One shared object is one
|
|
68
|
+
* shared credential: the bearer the last sign-in produced is the bearer the
|
|
69
|
+
* next request sends.
|
|
70
|
+
*/
|
|
71
|
+
token?: string;
|
|
72
|
+
/**
|
|
73
|
+
* Called with every bearer this client stores, for persisting it across
|
|
74
|
+
* processes — and with `''` when `logout()` ends the session, so a stored
|
|
75
|
+
* copy can be invalidated.
|
|
76
|
+
*
|
|
77
|
+
* A bearer is not proof of a session: Vendure mints an ANONYMOUS session for
|
|
78
|
+
* an unauthenticated read. Treat "signed in" as "`auth.me()` resolved", never
|
|
79
|
+
* as "a token is stored".
|
|
80
|
+
*/
|
|
81
|
+
onToken?: (token: string) => void;
|
|
82
|
+
}
|
|
83
|
+
export declare function createAdminClient(config: AdminClientConfig): AdminClient;
|