@vendure-platform/console-auth 1.0.0-major.202609091053.0.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 ADDED
@@ -0,0 +1,335 @@
1
+ # @vendure-platform/console-auth
2
+
3
+ Browser login against Vendure Console, the user-level credential store that
4
+ keeps the result, and the onboarding flow both tools run on top of it. Shared by
5
+ `@vendure-platform/create` and `@vendure-platform/cli` so there is one login and
6
+ one way to link a project, rather than one per tool.
7
+
8
+ This package is public and MIT because `@vendure-platform/create` runs under
9
+ `npx` for people who hold no registry token. It has no runtime dependencies
10
+ beyond `tslib`.
11
+
12
+ ## What it does
13
+
14
+ ```ts
15
+ import { loginAndStoreConsoleToken, resolveStoredConsoleToken } from '@vendure-platform/console-auth';
16
+
17
+ const token = (await resolveStoredConsoleToken()) ?? (await loginAndStoreConsoleToken({ client: 'create' }));
18
+ ```
19
+
20
+ `resolveStoredConsoleToken` returns a usable access token from the stored
21
+ credential, refreshing it first when it has expired and carries a refresh token.
22
+ It returns `undefined` when there is nothing usable, which is the caller's cue to
23
+ offer a login. The refresh runs under the cross-process lock described in
24
+ "Sharing the credential across processes", so two commands running at once
25
+ cannot spend the same single-use refresh token.
26
+
27
+ ## Onboarding a project
28
+
29
+ `create` has to sign a person in, find or make a Console Project, give it an
30
+ edition, and write the Project Link Manifest. It runs under `npx` and cannot
31
+ depend on `@vendure/cli`, so that flow lives here.
32
+
33
+ `vendure console link` does none of it. `@vendure/cli` owns the link: the
34
+ endpoints, the approval, the browser, the polling and the manifest.
35
+ `@vendure-platform/cli` contributes only what comes after one, through the
36
+ CLI's `afterConsoleLink` hook. The manifest reader and writer here stay in step
37
+ with the CLI's own through `project-link-conformance.spec.ts` in that package,
38
+ which runs both over the same inputs.
39
+
40
+ ```ts
41
+ import { ConsoleApi, establishProjectLink } from '@vendure-platform/console-auth';
42
+
43
+ const api = new ConsoleApi({ apiOrigin, getAccessToken: () => token });
44
+ const link = await establishProjectLink({ projectRoot, api, interaction, reuseExistingLink: true });
45
+ ```
46
+
47
+ `establishProjectLink` is the whole flow. `selectConsoleProject` and
48
+ `writeProjectLink` are its two halves, because `create` needs the edition while
49
+ its prompts are still running and cannot write a manifest until the directory
50
+ exists.
51
+
52
+ Every question is a call on `ConsoleOnboardingInteraction` rather than a prompt.
53
+ `create` draws with `@clack/prompts`, and no prompt library belongs in this
54
+ package. Any of
55
+ those calls may throw `ConsoleOnboardingCancelled`, which means the person
56
+ stopped and nothing should be written.
57
+
58
+ This package writes no credential of either kind:
59
+
60
+ - The account-level session is written by the login that produced the token
61
+ the flow runs on, which is this package's own store below.
62
+ - The project-scoped Environment Credential is written only by
63
+ `@vendure-platform/cli`. Nothing here can reach it, which is what keeps the
64
+ two stores apart in the module graph as well as on disk.
65
+
66
+ `acquireRegistryAccess` gets the Registry Access Token for a Project. Console
67
+ returns the value once, so a lost response leaves a token that exists in Console
68
+ and nowhere else. Creating a second one under the same name is refused.
69
+ The caller recovers from that refusal by finding the token of that name in the
70
+ list and rotating it, only after confirming, because rotating stops the old one
71
+ working.
72
+
73
+ ## Where the credential lives
74
+
75
+ `~/.vendure/console-credentials.json`, directory mode `0700`, file mode `0600`,
76
+ written through a temporary file and a rename so a reader never sees a
77
+ half-written token.
78
+
79
+ The repository has no `XDG_CONFIG_HOME` precedent, so this does not invent one.
80
+
81
+ This is an **account-level** credential and it is deliberately not the same thing
82
+ as the per-project development credential `@vendure-platform/cli` keeps in
83
+ `<projectRoot>/.vendure/credentials.json`. That one authenticates one
84
+ environment of one project. This one is worth as much as the person's Console
85
+ password across every project they own, so it never goes near a project
86
+ directory that someone is about to `git init`.
87
+
88
+ A missing file, an unreadable one, malformed JSON, or a credential issued by a
89
+ different Console app or API origin all read as "no credential". A corrupt cache is not worth
90
+ stopping a scaffold over.
91
+
92
+ Schema version 2 records the issuing API origin. Reads, refreshes, and login
93
+ validate and normalize both origins. A token is reused only when both origins
94
+ match. Schema version 1 has no known API issuer and requires a new login.
95
+ Logging in against another API replaces the single cache entry, but that entry
96
+ cannot be reused against production.
97
+
98
+ Only one thing removes a stored credential: a refresh that Console refused,
99
+ observed under the lock described below, against a refresh token that is still
100
+ the stored one. There is still no `vendure console logout` command, because
101
+ nothing calls for one today.
102
+
103
+ ## Sharing the credential across processes
104
+
105
+ Console rotates the refresh token on use and refuses the spent one. The second
106
+ process to present the same token is left with nothing, and if it then writes
107
+ its own stale pair back it overwrites a live session with a dead one. Two
108
+ processes are ordinary: `vendure dev` runs for hours while other commands run in
109
+ another terminal, and Vendure Cloud commands read the same file.
110
+
111
+ So every step that spends a refresh token or rewrites the credential goes
112
+ through one lock, because the double spend stays reachable through any step
113
+ left out. `resolveStoredConsoleToken`, `refreshStoredConsoleToken` and
114
+ `loginAndStoreConsoleToken` take it for you and are the whole of what this
115
+ package writes credentials from. `writeConsoleCredential` is exported so a test can
116
+ put a credential in place, and it takes no lock: any other caller has to wrap it
117
+ in `withConsoleSessionLock`.
118
+
119
+ Vendure Cloud implements this from its own repository and its own release train.
120
+ The protocol is written down here rather than shared as code, because the two
121
+ sides have only the filesystem in common. TypeScript callers import
122
+ `withConsoleSessionLock`.
123
+
124
+ ### The lock file
125
+
126
+ `~/.vendure/console-credentials.lock`, beside the credential it guards.
127
+
128
+ Take the lock by creating that file with `O_CREAT | O_EXCL` at mode `0600`
129
+ (`wx` in Node). The create either wins or fails with `EEXIST`, with no window in
130
+ between, and that is the whole mechanism. Write one line of JSON into it:
131
+
132
+ ```json
133
+ { "token": "3f0c1a6e-9c2b-4d51-9f0a-2e7c5b1d8a44", "pid": 4242, "acquiredAt": "2026-01-01T12:00:00.000Z" }
134
+ ```
135
+
136
+ `token` is new for every acquisition and is what makes releasing safe. `pid` and
137
+ `acquiredAt` are for a person looking at a lock that will not go away.
138
+
139
+ Release the lock by reading the file and removing it only while `token` still
140
+ matches the one you wrote. A lock carrying another token was broken as stale and
141
+ taken over while you were working, and removing it would let a third process in
142
+ while the second one writes.
143
+
144
+ The lock is not reentrant. `resolveStoredConsoleToken`, `refreshStoredConsoleToken`
145
+ and `loginAndStoreConsoleToken` take it themselves, so wrapping one of them in
146
+ `withConsoleSessionLock` waits out the whole wait budget and then fails: the
147
+ outer lock is held by a process that is alive and beating, which is the one
148
+ thing a waiter never breaks.
149
+
150
+ ### The budgets
151
+
152
+ | Budget | Value | Meaning |
153
+ | --------- | ----- | ------------------------------------------------------------------ |
154
+ | Stale | 30s | A lock whose mtime is older than this may be taken over by anyone. |
155
+ | Wait | 30s | How long to wait for a lock somebody else holds before giving up. |
156
+ | Heartbeat | 10s | How often a holder touches its own lock while it works. |
157
+
158
+ Never make the wait budget shorter than the stale budget. A waiter that gave up
159
+ sooner could give up while the holder was dead but not yet stale, and then "I
160
+ gave up waiting" would stop meaning "the holder is gone".
161
+
162
+ Equal budgets only make them the same condition if both are measured against
163
+ the same clock. A lock mtime carries a fraction of a millisecond that
164
+ `Date.now()` does not, so compare whole milliseconds. Look at the lock once more
165
+ when the budget runs out, too: it can cross the stale boundary while the check
166
+ before it is still reading.
167
+
168
+ Touch your own lock while you hold it, a third of the stale budget apart. That
169
+ is what makes the stale budget measure a holder that has stopped rather than one
170
+ that is slow: without it, a hold that runs long on a loaded machine is broken
171
+ while it is still working, and two processes refresh at once. Two missed beats
172
+ are allowed before anyone may act on the silence. A participant that does not
173
+ beat is not broken by this, it is only judged on how long its work takes, which
174
+ is what every participant was judged on before.
175
+
176
+ Bound that work anyway. The longest legitimate hold is the two token requests a
177
+ refresh makes when the first is refused and a rotated pair is on disk to try
178
+ instead, and each request has to carry a deadline that covers reading the answer
179
+ as well as getting it, or a server that sends headers and then stops sending
180
+ holds the lock for as long as it likes. This package gives each request 10
181
+ seconds, so the work inside a lock is 20 seconds at the outside.
182
+
183
+ Take a stale lock over by renaming it aside to a name of your own, not by
184
+ removing it in place. `rename` is one operation, so one waiter moves the file
185
+ and the rest find it gone and go back to creating. Removing it by path lets a
186
+ second waiter, still holding its reading of a file the first waiter has already
187
+ removed, delete the fresh lock the first waiter created in its place, and then
188
+ two processes hold the lock at once. Read what you moved: if it is not the lock
189
+ you judged stale, somebody took the lock in between, so `link` it back where it
190
+ was and leave it alone.
191
+
192
+ A window remains between reading the lock and moving it, so treat this as
193
+ narrowed rather than closed. A lock taken inside that window is moved aside by
194
+ somebody who judged the lock before it, and putting it back fails if a third
195
+ lock has landed by then, which leaves two holders. The recovery below is what
196
+ makes that survivable: two holders spend one grant between them and the loser
197
+ reuses what the winner wrote, so the cost is a wasted request rather than a
198
+ lost session.
199
+
200
+ While you hold the lock, read the credential again. Waiting for the lock is the
201
+ usual way to find out that another process has refreshed: by the time you get
202
+ in, the pair you read beforehand is the stale copy and the one on disk is live.
203
+ A credential that is usable when read under the lock is the answer, and no grant
204
+ needs spending.
205
+
206
+ ### When Console refuses the refresh
207
+
208
+ Re-read the credential before believing the refusal. A single-use refresh token
209
+ is usually refused because another process spent it first, and the pair that
210
+ process got back is already on disk. Use that pair.
211
+
212
+ If the stored refresh token is still the one that was just refused, and the lock
213
+ was held across both the refusal and the read, then nothing can have rotated it
214
+ and the session really is over. Only there may the credential be removed. Never
215
+ remove or overwrite it on the strength of a refusal observed without the lock.
216
+
217
+ Tell a refusal apart from a failure, and read the body to do it. Console names a
218
+ refused grant with `"code": "cli_session.invalid_grant"`. A 4xx that does not
219
+ name one is somebody other than Console answering: an API origin pointed at the
220
+ wrong host returning 404, a proxy in front of Console returning 403, a gateway
221
+ that wants a credential of its own returning 401. A timeout, an unreachable
222
+ host, a 5xx and a malformed response say nothing about the refresh token
223
+ either. All of them leave the credential untouched, and the next attempt gets a
224
+ straight answer.
225
+
226
+ When you cannot take the lock, do nothing: no refresh, no write, no removal.
227
+ Because the wait budget covers the stale budget, failing to acquire means the
228
+ filesystem refused you or other processes kept taking the lock first. Neither
229
+ says anything about the session, so report it as a temporary failure and leave
230
+ the credential where it is. Reporting it as "no credential" is not that, because
231
+ the caller answers that by sending a person to the browser to log in over a
232
+ session that is fine.
233
+
234
+ ## Trusted origins
235
+
236
+ Production pairs `https://console.vendure.io` with `https://api.vendure.io`.
237
+ Staging pairs `https://staging.console.vendure.io` with `https://staging.api.vendure.io`.
238
+ Supplying only the staging API selects the staging app for browser login and
239
+ cache lookup. Supplying only the staging app selects its API. Explicit mixed
240
+ production and staging pairs are rejected. Loopback hosts remain allowed for
241
+ local development and tests.
242
+
243
+ Any URL carrying a path, a query, a fragment, or credentials is rejected before
244
+ use, and every request sets `redirect: 'error'`. A host allow-list on its own is
245
+ not enough: those are the shapes that let a URL send the browser somewhere else
246
+ while the hostname still reads correctly.
247
+
248
+ ## Console frontend contract
249
+
250
+ > Console serves all of this as of Console #121. The route is
251
+ > `apps/console/src/routes/cli-auth.tsx` and the token endpoint is
252
+ > `apps/api/src/cli-sessions/cli-auth.controller.ts`. What is written down here
253
+ > is what both sides implement, so a change on either side changes this file.
254
+
255
+ The user logs in on the Console web app, not on the API and not on an identity
256
+ provider this CLI picked. That way the address bar shows a host they already
257
+ trust.
258
+
259
+ ### `GET https://console.vendure.io/cli-auth`
260
+
261
+ | Query param | Required | Meaning |
262
+ | ----------------------- | -------- | -------------------------------------------------------------- |
263
+ | `client` | yes | `create` or `cli`. Console names the tool on the confirm page. |
264
+ | `redirect_uri` | yes | `http://127.0.0.1:<port>/auth/callback`. Loopback only. |
265
+ | `state` | yes | Opaque, 32 random bytes base64url. Returned verbatim. |
266
+ | `code_challenge` | yes | PKCE challenge, base64url SHA-256 of the verifier. |
267
+ | `code_challenge_method` | yes | Always `S256`. |
268
+
269
+ Console behaviour:
270
+
271
+ 1. If the visitor is not signed in, show the normal Console login first.
272
+ 2. Show a confirmation page naming the tool from `client` and the account that
273
+ will be authorized.
274
+ 3. On approval, mint a short-lived single-use authorization code bound to the
275
+ account and to `code_challenge`, then `302` to
276
+ `redirect_uri?code=<code>&state=<state>`.
277
+ 4. On refusal, `302` to `redirect_uri?error=access_denied&state=<state>`.
278
+
279
+ Console must reject any `redirect_uri` that is not loopback. That check is what
280
+ stops an attacker sending a code somewhere else.
281
+
282
+ ### `POST https://api.vendure.io/v1/auth/cli/token`
283
+
284
+ Body for the initial exchange:
285
+
286
+ ```json
287
+ {
288
+ "grant_type": "authorization_code",
289
+ "code": "...",
290
+ "code_verifier": "...",
291
+ "redirect_uri": "http://127.0.0.1:54321/auth/callback"
292
+ }
293
+ ```
294
+
295
+ Body for a refresh:
296
+
297
+ ```json
298
+ { "grant_type": "refresh_token", "refresh_token": "..." }
299
+ ```
300
+
301
+ Response:
302
+
303
+ ```json
304
+ {
305
+ "access_token": "...",
306
+ "token_type": "Bearer",
307
+ "expires_in": 3600,
308
+ "refresh_token": "..."
309
+ }
310
+ ```
311
+
312
+ `refresh_token` is optional. `expires_in` is required: a session assumed to last
313
+ longer than it does fails later, at the project lookup, where the cause is no
314
+ longer visible.
315
+
316
+ A refresh token that has already been spent, expired or been revoked is refused
317
+ with `400` and the Console error convention, which names the condition under
318
+ `code` rather than under the OAuth `error`:
319
+
320
+ ```json
321
+ { "code": "cli_session.invalid_grant", "message": "This refresh token was already used." }
322
+ ```
323
+
324
+ That `code` is the one thing that ends a session. It is the only code the token
325
+ endpoint refuses with, so it covers a token request Console could not read at
326
+ all as well as a grant it will not honour again. On the refresh path that
327
+ distinction does not arise: the request carries `grant_type` and
328
+ `refresh_token`, both well formed, so the only thing left for Console to refuse
329
+ is the grant. Any other status or body reads as a request that failed rather
330
+ than a grant that was judged, and leaves the stored credential alone. `message`
331
+ is for a person; nothing branches on it.
332
+
333
+ The `access_token` is sent as `Authorization: Bearer <token>` to
334
+ `GET /v1/projects/editions`, which Console serves from
335
+ `apps/api/src/projects/projects.controller.ts`.
@@ -0,0 +1,48 @@
1
+ /** Identifies the calling tool so Console can name it on the confirmation page. */
2
+ export type ConsoleAuthClient = 'create' | 'cli';
3
+ export interface ConsoleSession {
4
+ accessToken: string;
5
+ /** Absolute expiry, ISO-8601. Derived from the `expires_in` Console returns. */
6
+ expiresAt: string;
7
+ refreshToken?: string;
8
+ }
9
+ /**
10
+ * A token grant that did not produce a session.
11
+ *
12
+ * `refused` separates "Console judged this grant and said no" from "ask again
13
+ * later", and only a refusal may end a session. A 503, a timeout, an
14
+ * unreachable host and a malformed answer all say nothing about whether the
15
+ * refresh token is still good, so acting on one would log a person out because
16
+ * their network dropped.
17
+ */
18
+ export declare class ConsoleTokenGrantError extends Error {
19
+ readonly refused: boolean;
20
+ constructor(message: string, refused: boolean);
21
+ }
22
+ export interface BrowserLoginOptions {
23
+ client: ConsoleAuthClient;
24
+ appOrigin?: string;
25
+ apiOrigin?: string;
26
+ fetch?: typeof globalThis.fetch;
27
+ openBrowser?: (url: string) => Promise<boolean>;
28
+ reportAuthorizationUrl?: (url: string) => void;
29
+ signal?: AbortSignal;
30
+ timeoutMs?: number;
31
+ now?: () => Date;
32
+ }
33
+ /**
34
+ * Log in through the Vendure Console web app and return an account-level
35
+ * session.
36
+ *
37
+ * The browser only ever visits Console. A loopback server on 127.0.0.1 receives
38
+ * the authorization code, and the code is traded for a token by this process
39
+ * over HTTPS, so the token never travels through the URL bar or the shell
40
+ * history.
41
+ */
42
+ export declare function loginWithBrowser(options: BrowserLoginOptions): Promise<ConsoleSession>;
43
+ /** Trade a refresh token for a fresh session. Same contract as the code exchange. */
44
+ export declare function refreshSession(refreshToken: string, options?: {
45
+ apiOrigin?: string;
46
+ fetch?: typeof globalThis.fetch;
47
+ now?: () => Date;
48
+ }): Promise<ConsoleSession>;
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.ConsoleTokenGrantError=void 0,exports.loginWithBrowser=d,exports.refreshSession=h;const e=require("node:child_process"),r=require("node:crypto"),t=require("node:http"),n=require("./origins"),o="127.0.0.1",i="/auth/callback",a="/cli-auth",s="/v1/auth/cli/token",c=3e5,l=1e4,u=31536e3;class ConsoleTokenGrantError extends Error{constructor(e,r){super(e),this.name="ConsoleTokenGrantError",this.refused=r}}async function d(e){var r,a,s,l,u;const{appOrigin:d,apiOrigin:h}=(0,n.consoleOrigins)(e),w=null!==(r=e.fetch)&&void 0!==r?r:globalThis.fetch,g=(0,t.createServer)();try{const r=await T(g),t=`http://${o}:${r}${i}`,n=k(),m=k(),v=p({appOrigin:d,client:e.client,redirectUri:t,state:n,codeVerifier:m}),y=_(g,n,{signal:e.signal,timeoutMs:null!==(a=e.timeoutMs)&&void 0!==a?a:c});y.catch(()=>{}),await(null!==(s=e.openBrowser)&&void 0!==s?s:E)(v).catch(()=>!1)||null===(l=e.reportAuthorizationUrl)||void 0===l||l.call(e,v);const b=await y;return await f({apiOrigin:h,fetch:w,code:b,codeVerifier:m,redirectUri:t,signal:e.signal,now:null!==(u=e.now)&&void 0!==u?u:()=>new Date})}finally{await b(g)}}async function h(e,r={}){var t,o;const i=(0,n.consoleApiOrigin)(r.apiOrigin);return v(await w(i,null!==(t=r.fetch)&&void 0!==t?t:globalThis.fetch,{grant_type:"refresh_token",refresh_token:e},void 0),null!==(o=r.now)&&void 0!==o?o:()=>new Date,e)}function p(e){const r=new URL(a,e.appOrigin);return r.searchParams.set("client",e.client),r.searchParams.set("redirect_uri",e.redirectUri),r.searchParams.set("state",e.state),r.searchParams.set("code_challenge",y(e.codeVerifier)),r.searchParams.set("code_challenge_method","S256"),r.toString()}async function f(e){return v(await w(e.apiOrigin,e.fetch,{grant_type:"authorization_code",code:e.code,code_verifier:e.codeVerifier,redirect_uri:e.redirectUri},e.signal),e.now)}async function w(e,r,t,n){const o=new AbortController,i=()=>o.abort();null==n||n.addEventListener("abort",i,{once:!0});const a=setTimeout(i,l);try{let n;try{n=await r(`${e}${s}`,{method:"POST",redirect:"error",signal:o.signal,headers:{Accept:"application/json","Content-Type":"application/json"},body:JSON.stringify(t)})}catch{throw new ConsoleTokenGrantError("Could not reach Vendure Console for a token.",!1)}if(!n.ok)throw new ConsoleTokenGrantError(`Vendure Console rejected the token request with HTTP ${n.status}.`,await m(n));try{return await n.json()}catch{throw new ConsoleTokenGrantError("Vendure Console returned malformed JSON for a token.",!1)}}finally{clearTimeout(a),null==n||n.removeEventListener("abort",i)}}exports.ConsoleTokenGrantError=ConsoleTokenGrantError;const g="cli_session.invalid_grant";async function m(e){if(e.status<400||500<=e.status)return!1;try{const{code:r}=JSON.parse(await e.text());return r===g}catch{return!1}}function v(e,r,t){const n=e;if("string"!=typeof(null==n?void 0:n.access_token)||!n.access_token)throw new Error("Vendure Console returned no access token.");if("Bearer"!==n.token_type)throw new Error(`Vendure Console returned an unsupported token type "${String(n.token_type)}".`);if("number"!=typeof n.expires_in||!Number.isInteger(n.expires_in)||n.expires_in<=0||n.expires_in>u)throw new Error("Vendure Console returned no usable token expiry.");const o="string"==typeof n.refresh_token&&n.refresh_token?n.refresh_token:t;return{accessToken:n.access_token,expiresAt:new Date(r().getTime()+1e3*n.expires_in).toISOString(),...o?{refreshToken:o}:{}}}function k(){return(0,r.randomBytes)(32).toString("base64url")}function y(e){return(0,r.createHash)("sha256").update(e).digest("base64url")}function _(e,r,t){return new Promise((n,a)=>{var s,c;let l=!1;const u=e=>{var r;l||(l=!0,clearTimeout(h),null===(r=t.signal)||void 0===r||r.removeEventListener("abort",d),"error"in e?a(e.error):n(e.code))},d=()=>u({error:new Error("Login was interrupted.")}),h=setTimeout(()=>u({error:new Error("Login timed out.")}),t.timeoutMs);null===(s=t.signal)||void 0===s||s.addEventListener("abort",d,{once:!0}),(null===(c=t.signal)||void 0===c?void 0:c.aborted)&&d(),e.on("request",(e,t)=>{var n;const a=new URL(null!==(n=e.url)&&void 0!==n?n:"/",`http://${o}`);if("GET"!==e.method||a.pathname!==i)return void t.writeHead(404).end("Not found");if(a.searchParams.get("state")!==r)return t.writeHead(400).end("Login state did not match. Return to the terminal."),void u({error:new Error("Login state did not match.")});const s=a.searchParams.get("code");if(!s||a.searchParams.has("error"))return t.writeHead(400).end("Login was not completed. Return to the terminal."),void u({error:new Error("Vendure Console did not authorize the login.")});t.writeHead(200,{"Content-Type":"text/plain; charset=utf-8"}),t.end("Login complete. You can close this window."),u({code:s})})})}function T(e){return new Promise((r,t)=>{e.once("error",t),e.listen(0,o,()=>{e.off("error",t);const n=e.address();n&&"string"!=typeof n?r(n.port):t(new Error("Could not start the local login callback."))})})}function b(e){return e.listening?new Promise(r=>e.close(()=>r())):Promise.resolve()}function E(r){const t="darwin"===process.platform?{executable:"open",args:[r]}:"win32"===process.platform?{executable:"rundll32",args:["url.dll,FileProtocolHandler",r]}:{executable:"xdg-open",args:[r]};return new Promise(r=>{const n=(0,e.spawn)(t.executable,t.args,{detached:!0,stdio:"ignore"});n.once("error",()=>r(!1)),n.once("spawn",()=>{n.unref(),r(!0)})})}
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Console answered, and the answer was a refusal. `code` carries the Console
3
+ * error convention (`project.not_found`, `auth.permission_denied`, and so on),
4
+ * which is what callers branch on: the status alone cannot tell a missing
5
+ * permission from a missing Project.
6
+ */
7
+ export declare class ConsoleApiError extends Error {
8
+ readonly status: number;
9
+ readonly code: string | undefined;
10
+ /**
11
+ * The parsed error body. Console puts the actionable part of a refusal
12
+ * beside `code`: an Evaluation denial carries `reason` and
13
+ * `nextEligibleAt`, and neither survives being flattened into a string.
14
+ */
15
+ readonly body?: Record<string, unknown>;
16
+ constructor(status: number, code: string | undefined, message: string,
17
+ /**
18
+ * The parsed error body. Console puts the actionable part of a refusal
19
+ * beside `code`: an Evaluation denial carries `reason` and
20
+ * `nextEligibleAt`, and neither survives being flattened into a string.
21
+ */
22
+ body?: Record<string, unknown>);
23
+ }
24
+ /** The request never produced an answer. Says nothing about the request itself. */
25
+ export declare class ConsoleApiTransportError extends Error {
26
+ constructor(message: string);
27
+ }
28
+ export interface ConsoleApiOptions {
29
+ /** Validated through the shared origin allow-list before anything is sent. */
30
+ apiOrigin?: string;
31
+ getAccessToken: () => string | Promise<string>;
32
+ /** Called once per client when Console reports the access token expired. */
33
+ refreshAccessToken?: () => void | Promise<void>;
34
+ fetch?: typeof globalThis.fetch;
35
+ signal?: AbortSignal;
36
+ timeoutMs?: number;
37
+ }
38
+ export interface ConsoleApiRequestOptions {
39
+ method?: 'GET' | 'POST';
40
+ body?: unknown;
41
+ /** Extra request headers. `Idempotency-Key` is the only one in use. */
42
+ headers?: Record<string, string>;
43
+ }
44
+ /**
45
+ * The authenticated Console API calls the onboarding flow makes.
46
+ *
47
+ * `@vendure-platform/license-guard` carries a client of its own for the
48
+ * entitlement runtime. This one is not that one and does not import it: that
49
+ * package declares a peer dependency on `@vendure/core`, and
50
+ * `@vendure-platform/create` runs under `npx` in a directory that has no
51
+ * Vendure installed. It also has to send `Idempotency-Key`, which the runtime
52
+ * client has no reason to.
53
+ */
54
+ export declare class ConsoleApi {
55
+ private readonly options;
56
+ readonly apiOrigin: string;
57
+ private readonly fetch;
58
+ private refreshed;
59
+ constructor(options: ConsoleApiOptions);
60
+ request<T>(path: string, options?: ConsoleApiRequestOptions): Promise<T>;
61
+ private send;
62
+ }
63
+ /** Whether Console refused because the session lacks the Permission. */
64
+ export declare function isPermissionDenied(error: unknown): boolean;
65
+ /** Whether Console refused with HTTP 409 and this error code. */
66
+ export declare function isConsoleConflict(error: unknown, code: string): boolean;
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.ConsoleApi=exports.ConsoleApiTransportError=exports.ConsoleApiError=void 0,exports.isPermissionDenied=s,exports.isConsoleConflict=t;const o=require("./origins");class ConsoleApiError extends Error{constructor(o,r,e,s){super(e),this.status=o,this.code=r,this.body=s,this.name="ConsoleApiError"}}exports.ConsoleApiError=ConsoleApiError;class ConsoleApiTransportError extends Error{constructor(o){super(o),this.name="ConsoleApiTransportError"}}exports.ConsoleApiTransportError=ConsoleApiTransportError;const r=1e4,e=65536;class ConsoleApi{constructor(r){var e;this.options=r,this.refreshed=!1,this.apiOrigin=(0,o.consoleApiOrigin)(r.apiOrigin),this.fetch=null!==(e=r.fetch)&&void 0!==e?e:globalThis.fetch}async request(o,r={}){try{return await this.send(o,r)}catch(e){if(e instanceof ConsoleApiError&&401===e.status&&"auth.token_expired"===e.code&&!this.refreshed&&this.options.refreshAccessToken)return this.refreshed=!0,await this.options.refreshAccessToken(),this.send(o,r);throw e}}async send(o,e){var s,t,a,l,p;if(!o.startsWith("/")||o.startsWith("//"))throw new Error("The Vendure Console API path must start with one slash.");const c=await this.options.getAccessToken();if(!c)throw new Error("No Vendure Console session is available.");const d=new AbortController,h=()=>d.abort();null===(s=this.options.signal)||void 0===s||s.addEventListener("abort",h,{once:!0});const u=setTimeout(h,null!==(t=this.options.timeoutMs)&&void 0!==t?t:r);try{const r=await this.fetch(`${this.apiOrigin}${o}`,{method:null!==(a=e.method)&&void 0!==a?a:void 0===e.body?"GET":"POST",redirect:"error",signal:d.signal,headers:{Authorization:`Bearer ${c}`,Accept:"application/json",...void 0===e.body?{}:{"Content-Type":"application/json"},...e.headers},...void 0===e.body?{}:{body:JSON.stringify(e.body)}}),s=await n(r);if(!r.ok){const{code:o,message:e,fields:t}=i(s);throw new ConsoleApiError(r.status,o,null!=e?e:`Vendure Console returned HTTP ${r.status}.`,t)}return s}catch(o){if(o instanceof ConsoleApiError)throw o;if(null===(l=this.options.signal)||void 0===l?void 0:l.aborted)throw new ConsoleApiTransportError("The request was cancelled.");if(o instanceof Error&&"AbortError"===o.name)throw new ConsoleApiTransportError("The Vendure Console request timed out.");throw new ConsoleApiTransportError("Could not reach Vendure Console.")}finally{clearTimeout(u),null===(p=this.options.signal)||void 0===p||p.removeEventListener("abort",h)}}}function s(o){return o instanceof ConsoleApiError&&403===o.status&&"auth.permission_denied"===o.code}function t(o,r){return o instanceof ConsoleApiError&&409===o.status&&o.code===r}async function n(o){const r=await o.text();if(Buffer.byteLength(r,"utf8")>e)throw new ConsoleApiError(o.status,void 0,"The Vendure Console response was too large.");if(r)try{return JSON.parse(r)}catch{throw new ConsoleApiError(o.status,void 0,"Vendure Console returned malformed JSON.")}}function i(o){if(!o||"object"!=typeof o||Array.isArray(o))return{};const r=o;return{code:"string"==typeof r.code?r.code:void 0,message:"string"==typeof r.message?r.message:void 0,fields:r}}exports.ConsoleApi=ConsoleApi;
@@ -0,0 +1,101 @@
1
+ import { ConsoleApi } from './console-api';
2
+ import { type ProjectLinkManifest } from './project-link-manifest';
3
+ /** Product Offer behind a Project's current access. */
4
+ export type ProjectEdition = 'starter' | 'growth' | 'scale' | 'enterprise';
5
+ /** Where that access came from. `none` means the Project has no access at all. */
6
+ export type ProjectEditionSource = 'override' | 'subscription' | 'evaluation' | 'none';
7
+ /** The Offers an Evaluation may be started on. Enterprise is sold, never evaluated. */
8
+ export type EvaluationOfferCode = 'starter' | 'growth' | 'scale';
9
+ export declare const PROJECT_EDITIONS: readonly ProjectEdition[];
10
+ export declare const EVALUATION_OFFER_CODES: readonly EvaluationOfferCode[];
11
+ /**
12
+ * One ACTIVE Project of the signed-in account with the edition it currently
13
+ * holds. `edition` is null when no Offer supplies the access, which a support
14
+ * override granting a bare Capability list produces, so a null edition is a
15
+ * Project that exists rather than one that is broken.
16
+ */
17
+ export interface ConsoleProjectEdition {
18
+ id: string;
19
+ name: string;
20
+ edition: ProjectEdition | null;
21
+ source: ProjectEditionSource;
22
+ }
23
+ export interface ConsoleProject {
24
+ id: string;
25
+ name: string;
26
+ state: 'active' | 'archived';
27
+ }
28
+ export interface ProjectEvaluationState {
29
+ startState: 'available' | 'already_active' | 'project_inactive' | 'account_evaluation_active' | 'rolling_window';
30
+ nextEligibleAt: string | null;
31
+ offerCodes: EvaluationOfferCode[];
32
+ }
33
+ export interface StartedEvaluation {
34
+ outcome: 'started' | 'existing';
35
+ offerCode: string;
36
+ expiresAt: string;
37
+ }
38
+ export interface RegistryAccessConfiguration {
39
+ registryUrl: string;
40
+ scope: string;
41
+ }
42
+ export interface RegistryAccessToken {
43
+ id: string;
44
+ name: string;
45
+ state: 'active' | 'expired' | 'revoked';
46
+ }
47
+ export interface IssuedRegistryAccessToken extends RegistryAccessToken {
48
+ /** Returned once, by the create and rotate routes only. */
49
+ token: string;
50
+ }
51
+ /** Every ACTIVE Project of the account, with its edition. */
52
+ export declare function listProjectEditions(api: ConsoleApi): Promise<ConsoleProjectEdition[]>;
53
+ /**
54
+ * Create a Project.
55
+ *
56
+ * `idempotencyKey` makes a repeat safe after a lost response: the same key and
57
+ * the same name return the Project the first request created rather than a
58
+ * second one. Console answers a repeat that carries a different name with
59
+ * `project.idempotency_conflict`, which is a caller bug rather than a race, so
60
+ * it surfaces as-is.
61
+ */
62
+ export declare function createProject(api: ConsoleApi, input: {
63
+ name: string;
64
+ idempotencyKey?: string;
65
+ }): Promise<ConsoleProject>;
66
+ export declare function getProjectEvaluation(api: ConsoleApi, projectId: string): Promise<ProjectEvaluationState>;
67
+ /**
68
+ * Start an Evaluation.
69
+ *
70
+ * Retry-safe: `outcome` reports `existing` when the Project already had the
71
+ * Evaluation this call would have created, so a repeat after a lost response
72
+ * does not need to be distinguished from the first attempt.
73
+ */
74
+ export declare function startProjectEvaluation(api: ConsoleApi, projectId: string, offerCode: EvaluationOfferCode): Promise<StartedEvaluation>;
75
+ /**
76
+ * Why Console refused to start an Evaluation, or `undefined` when the failure
77
+ * was something else. `nextEligibleAt` is the only part a person can act on:
78
+ * it names the date the rolling window reopens.
79
+ */
80
+ export declare function evaluationDenial(error: unknown): {
81
+ message: string;
82
+ reason?: string;
83
+ nextEligibleAt?: string;
84
+ } | undefined;
85
+ /**
86
+ * Create an approved Project Link for a Project the signed-in session already
87
+ * owns, and return its Manifest.
88
+ *
89
+ * This is the one-call route, not the browser approval flow: the caller holds
90
+ * the person's own Console session, so there is nobody left to ask.
91
+ */
92
+ export declare function linkProject(api: ConsoleApi, projectId: string): Promise<ProjectLinkManifest>;
93
+ export declare function getRegistryAccess(api: ConsoleApi, projectId: string): Promise<RegistryAccessConfiguration>;
94
+ export declare function listRegistryAccessTokens(api: ConsoleApi, projectId: string): Promise<RegistryAccessToken[]>;
95
+ export declare function createRegistryAccessToken(api: ConsoleApi, projectId: string, name: string): Promise<IssuedRegistryAccessToken>;
96
+ export declare function rotateRegistryAccessToken(api: ConsoleApi, projectId: string, tokenId: string): Promise<IssuedRegistryAccessToken>;
97
+ /** The Customer Account the session is scoped to, or `undefined` when it is scoped to none. */
98
+ export declare function readViewerAccount(api: ConsoleApi): Promise<{
99
+ id: string;
100
+ name: string;
101
+ } | undefined>;
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.EVALUATION_OFFER_CODES=exports.PROJECT_EDITIONS=void 0,exports.listProjectEditions=r,exports.createProject=o,exports.getProjectEvaluation=n,exports.startProjectEvaluation=s,exports.evaluationDenial=a,exports.linkProject=i,exports.getRegistryAccess=c,exports.listRegistryAccessTokens=u,exports.createRegistryAccessToken=l,exports.rotateRegistryAccessToken=d,exports.readViewerAccount=y;const e=require("./console-api"),t=require("./project-link-manifest");async function r(e){const t=await e.request("/v1/projects/editions");if(!Array.isArray(t))throw x("the Project list");return t.map(e=>{const t=g(e,"a Project");return{id:v(t.id,"a Project id"),name:v(t.name,"a Project name"),edition:E(t.edition,exports.PROJECT_EDITIONS,"a Project edition"),source:A(t.source,["override","subscription","evaluation","none"],"a Project access source")}})}async function o(e,t){const r=g(await e.request("/v1/projects",{method:"POST",body:{name:t.name},...t.idempotencyKey?{headers:{"Idempotency-Key":t.idempotencyKey}}:{}}),"the created Project");return{id:v(r.id,"a Project id"),name:v(r.name,"a Project name"),state:A(r.state,["active","archived"],"a Project state")}}async function n(e,t){const r=g(await e.request(`${f(t)}/evaluation`),"the Evaluation state");if(!Array.isArray(r.offerCodes))throw x("the evaluable Offer list");return{startState:A(r.startState,["available","already_active","project_inactive","account_evaluation_active","rolling_window"],"an Evaluation start state"),nextEligibleAt:null===r.nextEligibleAt?null:v(r.nextEligibleAt,"a next eligible time"),offerCodes:r.offerCodes.filter(e=>exports.EVALUATION_OFFER_CODES.includes(e))}}async function s(e,t,r){const o=g(await e.request(`${f(t)}/evaluation`,{method:"POST",body:{offerCode:r}}),"the Evaluation result"),n=g(o.evaluation,"the Evaluation");return{outcome:A(o.outcome,["started","existing"],"an Evaluation outcome"),offerCode:v(n.offerCode,"an Evaluation offer"),expiresAt:v(n.expiresAt,"an Evaluation expiry")}}function a(t){var r,o;if(!(t instanceof e.ConsoleApiError)||"evaluation.start_denied"!==t.code)return;const n=null===(r=t.body)||void 0===r?void 0:r.reason,s=null===(o=t.body)||void 0===o?void 0:o.nextEligibleAt;return{message:t.message,..."string"==typeof n?{reason:n}:{},..."string"==typeof s?{nextEligibleAt:s}:{}}}async function i(e,r){return(0,t.parseProjectLinkManifest)(await e.request(`${f(r)}/link`,{method:"POST"}))}async function c(e,t){const r=g(await e.request(`${f(t)}/registry-access`),"the Registry access configuration");return{registryUrl:v(r.registryUrl,"a Registry URL"),scope:v(r.scope,"a Registry scope")}}async function u(e,t){const r=await e.request(`${f(t)}/registry-access-tokens`);if(!Array.isArray(r))throw x("the Registry Access Token list");return r.map(e=>p(e))}async function l(e,t,r){return m(await e.request(`${f(t)}/registry-access-tokens`,{method:"POST",body:{name:r}}))}async function d(e,t,r){return m(await e.request(`${f(t)}/registry-access-tokens/${encodeURIComponent(r)}/rotate`,{method:"POST"}))}async function y(e){const t=g(await e.request("/v1/me"),"the Console viewer");if(!t.customerAccount)return;const r=g(t.customerAccount,"the Customer Account");return{id:v(r.id,"a Customer Account id"),name:v(r.name,"a Customer Account name")}}function f(e){return`/v1/projects/${encodeURIComponent(e)}`}function p(e){const t=g(e,"a Registry Access Token");return{id:v(t.id,"a Registry Access Token id"),name:v(t.name,"a Registry Access Token name"),state:A(t.state,["active","expired","revoked"],"a token state")}}function m(e){const t=g(e,"the issued Registry Access Token");return{...p(e),token:v(t.token,"a Registry Access Token secret")}}function g(e,t){if(!e||"object"!=typeof e||Array.isArray(e))throw x(t);return e}function v(e,t){if("string"!=typeof e||0===e.length)throw x(t);return e}function A(e,t,r){if("string"!=typeof e||!t.includes(e))throw x(r);return e}function E(e,t,r){return null==e?null:A(e,t,r)}function x(e){return new Error(`Vendure Console returned ${e} in an unexpected shape.`)}exports.PROJECT_EDITIONS=["starter","growth","scale","enterprise"],exports.EVALUATION_OFFER_CODES=["starter","growth","scale"];