@vitrinka/link 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +13 -5
- package/build/index.d.ts +37 -4
- package/build/index.js +64 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @vitrinka/link
|
|
2
2
|
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
- **Workspace hint.** `linkWorkspace(base)` reads the `<slug>` of a
|
|
6
|
+
`/w/<slug>` base; `startLink(base, { workspace })` appends
|
|
7
|
+
`workspace=<slug>` to `verifyUrl` and `qrUrl` so the approve page
|
|
8
|
+
preselects it (the start body stays `{kind, label}`), and
|
|
9
|
+
`pollLink(base, code, { workspace })` rejects a claim pinned to any other
|
|
10
|
+
workspace with `LinkWorkspaceMismatch` — its token is never returned. It
|
|
11
|
+
fails closed: a claim that names no workspace is refused too. A base
|
|
12
|
+
without `/w/<slug>` behaves as before.
|
|
13
|
+
- `Linked` matches the claim the server sends: `expires_in` (seconds), not
|
|
14
|
+
the never-sent `expires_at`.
|
|
15
|
+
|
|
3
16
|
## 0.1.0
|
|
4
17
|
|
|
5
18
|
- Initial release: `startLink`, `pollLink`, `linkOrigin`, `isUnauthorized`,
|
package/README.md
CHANGED
|
@@ -6,18 +6,26 @@ via a link, or another device via the server-rendered QR), and the recorder
|
|
|
6
6
|
receives an ingest-only `vkr_` token. No baked secrets, no QR library.
|
|
7
7
|
|
|
8
8
|
```ts
|
|
9
|
-
import { startLink, pollLink, LinkExpired } from '@vitrinka/link';
|
|
9
|
+
import { startLink, pollLink, linkWorkspace, LinkExpired, LinkWorkspaceMismatch } from '@vitrinka/link';
|
|
10
10
|
|
|
11
|
-
const
|
|
11
|
+
const base = 'https://app.vitrinka.ai/w/acme';
|
|
12
|
+
const workspace = linkWorkspace(base); // 'acme' — undefined for a bare origin
|
|
13
|
+
const start = await startLink(base, { label: 'Safari on macOS · app.example.test', workspace });
|
|
12
14
|
start.user_code; // 'ABCD-EFGH' — show it
|
|
13
|
-
start.verifyUrl; // open on the same device
|
|
15
|
+
start.verifyUrl; // open on the same device (…&workspace=acme preselects it)
|
|
14
16
|
start.qrUrl; // <img src> for the desktop→phone path (SVG from the server)
|
|
15
|
-
const linked = await pollLink(start.base, start.device_code, { interval: start.interval });
|
|
17
|
+
const linked = await pollLink(start.base, start.device_code, { interval: start.interval, workspace });
|
|
16
18
|
linked.token; // 'vkr_…' — store it, send it as the bearer
|
|
17
19
|
```
|
|
18
20
|
|
|
19
21
|
Doors (at the base URL's origin): `POST /api/v1/cli/auth {kind:"recorder",label}` →
|
|
20
22
|
201 `{device_code, user_code, verify_path, verify_url?, qr_path, interval,
|
|
21
23
|
expires_in}`; `POST /api/v1/cli/auth/claim {device_code}` → 202 pending ·
|
|
22
|
-
200 `{token, workspace, label,
|
|
24
|
+
200 `{token, kind, workspace, label, expires_in}` · 404 expired (`LinkExpired`).
|
|
23
25
|
A 401 from any session door means the token is dead: forget it and link again.
|
|
26
|
+
|
|
27
|
+
A token only authenticates in the workspace it was approved into, so a
|
|
28
|
+
`/w/<slug>` base passes that slug as `workspace`: it rides the approve and QR
|
|
29
|
+
URLs as a `workspace=<slug>` preselect (never the start body), and a claim
|
|
30
|
+
approved into another workspace rejects with `LinkWorkspaceMismatch`
|
|
31
|
+
(`linked`, `expected`, a ready-to-show message) — the token is discarded.
|
package/build/index.d.ts
CHANGED
|
@@ -7,7 +7,12 @@
|
|
|
7
7
|
* POST {origin}/api/v1/cli/auth {kind:"recorder", label}
|
|
8
8
|
* → 201 {device_code, user_code, verify_path, verify_url?, qr_path, interval, expires_in}
|
|
9
9
|
* POST {origin}/api/v1/cli/auth/claim {device_code}
|
|
10
|
-
* → 202 pending · 200 {token, workspace, label,
|
|
10
|
+
* → 202 pending · 200 {token, kind, workspace, label, expires_in} · 404 expired
|
|
11
|
+
*
|
|
12
|
+
* A base that addresses one workspace (`…/w/<slug>`) carries that slug as a
|
|
13
|
+
* `workspace=<slug>` hint on the approve and QR URLs (the approve page
|
|
14
|
+
* preselects it) and refuses a claim pinned to any other workspace — its
|
|
15
|
+
* token could never authenticate against `/w/<slug>`.
|
|
11
16
|
*
|
|
12
17
|
* Pure TypeScript: no DOM or React Native globals — `fetch` is taken from
|
|
13
18
|
* the options or from globalThis.
|
|
@@ -29,14 +34,26 @@ export interface LinkStart {
|
|
|
29
34
|
}
|
|
30
35
|
export interface Linked {
|
|
31
36
|
token: string;
|
|
37
|
+
/** Slug of the workspace the approver pinned the token to. */
|
|
32
38
|
workspace: string;
|
|
33
39
|
label: string;
|
|
34
|
-
|
|
40
|
+
/** Seconds until the (sliding) token expiry, as of the claim. */
|
|
41
|
+
expires_in: number;
|
|
35
42
|
}
|
|
36
43
|
/** The server no longer knows the code (expired or already consumed). */
|
|
37
44
|
export declare class LinkExpired extends Error {
|
|
38
45
|
constructor(message?: string);
|
|
39
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* The approver pinned the token to another workspace than the one the
|
|
49
|
+
* recorder records into. The token is discarded (never returned): every
|
|
50
|
+
* session door under `/w/<expected>` would answer it with a 401.
|
|
51
|
+
*/
|
|
52
|
+
export declare class LinkWorkspaceMismatch extends Error {
|
|
53
|
+
readonly linked: string;
|
|
54
|
+
readonly expected: string;
|
|
55
|
+
constructor(linked: string, expected: string);
|
|
56
|
+
}
|
|
40
57
|
export declare class LinkError extends Error {
|
|
41
58
|
readonly status: number;
|
|
42
59
|
constructor(message: string, status: number);
|
|
@@ -58,19 +75,35 @@ export interface LinkOptions {
|
|
|
58
75
|
}
|
|
59
76
|
/** The control-plane origin of a base URL. */
|
|
60
77
|
export declare function linkOrigin(base: string): string;
|
|
78
|
+
/**
|
|
79
|
+
* The workspace a base URL addresses — `<slug>` of a `/w/<slug>` path, split
|
|
80
|
+
* exactly like the server's tenant router — or undefined for a bare origin.
|
|
81
|
+
*/
|
|
82
|
+
export declare function linkWorkspace(base: string): string | undefined;
|
|
61
83
|
/** A 401 from a session door: the stored token is dead. */
|
|
62
84
|
export declare function isUnauthorized(status: number): boolean;
|
|
63
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* Ask the server for a link code. `workspace` (normally `linkWorkspace(base)`)
|
|
87
|
+
* rides the approve and QR URLs as a preselect hint only — the start body
|
|
88
|
+
* stays `{kind, label}`, because the server refuses unknown fields there.
|
|
89
|
+
*/
|
|
64
90
|
export declare function startLink(base: string, opts: {
|
|
65
91
|
label: string;
|
|
92
|
+
workspace?: string;
|
|
66
93
|
} & LinkOptions): Promise<LinkStart>;
|
|
67
94
|
export interface PollOptions extends LinkOptions {
|
|
68
95
|
/** Seconds between claims (min 2). */
|
|
69
96
|
interval?: number;
|
|
97
|
+
/** The workspace the recorder records into; a claim pinned elsewhere rejects with LinkWorkspaceMismatch. */
|
|
98
|
+
workspace?: string;
|
|
70
99
|
signal?: AbortSignal;
|
|
71
100
|
/** Test seam: the sleep. */
|
|
72
101
|
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
73
102
|
}
|
|
74
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* Claim until approved. Resolves with the token; throws LinkExpired on 404,
|
|
105
|
+
* LinkWorkspaceMismatch when approved into another workspace than
|
|
106
|
+
* `opts.workspace`, AbortError on abort.
|
|
107
|
+
*/
|
|
75
108
|
export declare function pollLink(base: string, deviceCode: string, opts?: PollOptions): Promise<Linked>;
|
|
76
109
|
export {};
|
package/build/index.js
CHANGED
|
@@ -7,7 +7,12 @@
|
|
|
7
7
|
* POST {origin}/api/v1/cli/auth {kind:"recorder", label}
|
|
8
8
|
* → 201 {device_code, user_code, verify_path, verify_url?, qr_path, interval, expires_in}
|
|
9
9
|
* POST {origin}/api/v1/cli/auth/claim {device_code}
|
|
10
|
-
* → 202 pending · 200 {token, workspace, label,
|
|
10
|
+
* → 202 pending · 200 {token, kind, workspace, label, expires_in} · 404 expired
|
|
11
|
+
*
|
|
12
|
+
* A base that addresses one workspace (`…/w/<slug>`) carries that slug as a
|
|
13
|
+
* `workspace=<slug>` hint on the approve and QR URLs (the approve page
|
|
14
|
+
* preselects it) and refuses a claim pinned to any other workspace — its
|
|
15
|
+
* token could never authenticate against `/w/<slug>`.
|
|
11
16
|
*
|
|
12
17
|
* Pure TypeScript: no DOM or React Native globals — `fetch` is taken from
|
|
13
18
|
* the options or from globalThis.
|
|
@@ -19,6 +24,19 @@ export class LinkExpired extends Error {
|
|
|
19
24
|
this.name = 'LinkExpired';
|
|
20
25
|
}
|
|
21
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* The approver pinned the token to another workspace than the one the
|
|
29
|
+
* recorder records into. The token is discarded (never returned): every
|
|
30
|
+
* session door under `/w/<expected>` would answer it with a 401.
|
|
31
|
+
*/
|
|
32
|
+
export class LinkWorkspaceMismatch extends Error {
|
|
33
|
+
constructor(linked, expected) {
|
|
34
|
+
super(`linked into ${linked} — this app records into ${expected}; link again and pick ${expected}`);
|
|
35
|
+
this.linked = linked;
|
|
36
|
+
this.expected = expected;
|
|
37
|
+
this.name = 'LinkWorkspaceMismatch';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
22
40
|
export class LinkError extends Error {
|
|
23
41
|
constructor(message, status) {
|
|
24
42
|
super(message);
|
|
@@ -46,6 +64,28 @@ export function permanentStatus(status) {
|
|
|
46
64
|
export function linkOrigin(base) {
|
|
47
65
|
return new URL(base).origin;
|
|
48
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* The workspace a base URL addresses — `<slug>` of a `/w/<slug>` path, split
|
|
69
|
+
* exactly like the server's tenant router — or undefined for a bare origin.
|
|
70
|
+
*/
|
|
71
|
+
export function linkWorkspace(base) {
|
|
72
|
+
const m = /^\/w\/([^/]+)/.exec(new URL(base).pathname);
|
|
73
|
+
if (!m?.[1])
|
|
74
|
+
return undefined;
|
|
75
|
+
try {
|
|
76
|
+
return decodeURIComponent(m[1]);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return m[1];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function withWorkspace(url, workspace) {
|
|
83
|
+
if (!workspace)
|
|
84
|
+
return url;
|
|
85
|
+
const u = new URL(url);
|
|
86
|
+
u.searchParams.set('workspace', workspace);
|
|
87
|
+
return u.href;
|
|
88
|
+
}
|
|
49
89
|
/** A 401 from a session door: the stored token is dead. */
|
|
50
90
|
export function isUnauthorized(status) {
|
|
51
91
|
return status === 401;
|
|
@@ -90,7 +130,11 @@ function requireString(w, key, status) {
|
|
|
90
130
|
throw new LinkError(`link start → malformed payload (${key})`, status);
|
|
91
131
|
return v;
|
|
92
132
|
}
|
|
93
|
-
/**
|
|
133
|
+
/**
|
|
134
|
+
* Ask the server for a link code. `workspace` (normally `linkWorkspace(base)`)
|
|
135
|
+
* rides the approve and QR URLs as a preselect hint only — the start body
|
|
136
|
+
* stays `{kind, label}`, because the server refuses unknown fields there.
|
|
137
|
+
*/
|
|
94
138
|
export async function startLink(base, opts) {
|
|
95
139
|
const origin = linkOrigin(base);
|
|
96
140
|
const res = await fetcher(opts)(`${origin}/api/v1/cli/auth`, {
|
|
@@ -122,8 +166,8 @@ export async function startLink(base, opts) {
|
|
|
122
166
|
device_code: deviceCode,
|
|
123
167
|
user_code: userCode,
|
|
124
168
|
verify_path: verifyPath,
|
|
125
|
-
verifyUrl: sameOriginUrl(origin, typeof w.verify_url === 'string' ? w.verify_url : undefined, verifyPath, `/cli-auth?code=${encodeURIComponent(userCode)}`),
|
|
126
|
-
qrUrl: sameOriginUrl(origin, qrPath, qrPath, `/cli-auth/qr?code=${encodeURIComponent(userCode)}`),
|
|
169
|
+
verifyUrl: withWorkspace(sameOriginUrl(origin, typeof w.verify_url === 'string' ? w.verify_url : undefined, verifyPath, `/cli-auth?code=${encodeURIComponent(userCode)}`), opts.workspace),
|
|
170
|
+
qrUrl: withWorkspace(sameOriginUrl(origin, qrPath, qrPath, `/cli-auth/qr?code=${encodeURIComponent(userCode)}`), opts.workspace),
|
|
127
171
|
interval: Math.max(2, Number(w.interval) || 2),
|
|
128
172
|
expires_in: Number(w.expires_in) || 0,
|
|
129
173
|
};
|
|
@@ -148,7 +192,11 @@ function abortError() {
|
|
|
148
192
|
e.name = 'AbortError';
|
|
149
193
|
return e;
|
|
150
194
|
}
|
|
151
|
-
/**
|
|
195
|
+
/**
|
|
196
|
+
* Claim until approved. Resolves with the token; throws LinkExpired on 404,
|
|
197
|
+
* LinkWorkspaceMismatch when approved into another workspace than
|
|
198
|
+
* `opts.workspace`, AbortError on abort.
|
|
199
|
+
*/
|
|
152
200
|
export async function pollLink(base, deviceCode, opts = {}) {
|
|
153
201
|
const origin = linkOrigin(base);
|
|
154
202
|
const f = fetcher(opts);
|
|
@@ -165,8 +213,17 @@ export async function pollLink(base, deviceCode, opts = {}) {
|
|
|
165
213
|
body: JSON.stringify({ device_code: deviceCode }),
|
|
166
214
|
signal: opts.signal,
|
|
167
215
|
});
|
|
168
|
-
if (res.status === 200)
|
|
169
|
-
|
|
216
|
+
if (res.status === 200) {
|
|
217
|
+
const linked = (await res.json());
|
|
218
|
+
// Fail closed: a recorder claim always names its workspace (the server
|
|
219
|
+
// sets it for kind recorder), so a missing or non-string one is refused
|
|
220
|
+
// like a foreign one — never stored against a base it cannot serve.
|
|
221
|
+
if (opts.workspace && linked.workspace !== opts.workspace) {
|
|
222
|
+
const got = typeof linked.workspace === 'string' && linked.workspace ? linked.workspace : '(no workspace)';
|
|
223
|
+
throw new LinkWorkspaceMismatch(got, opts.workspace);
|
|
224
|
+
}
|
|
225
|
+
return linked;
|
|
226
|
+
}
|
|
170
227
|
if (res.status === 404 || res.status === 410)
|
|
171
228
|
throw new LinkExpired();
|
|
172
229
|
if (res.status !== 202)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vitrinka/link",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "vitrinka device link — the Netflix-style code flow that mints an ingest-only recorder token for the web and Expo recorders. Zero dependencies.",
|
|
5
5
|
"license": "Elastic-2.0",
|
|
6
6
|
"repository": {
|