@vitrinka/link 0.1.0 → 0.1.2
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 +20 -0
- package/README.md +27 -5
- package/build/dock.d.ts +61 -0
- package/build/dock.js +101 -0
- package/build/index.d.ts +37 -4
- package/build/index.js +64 -7
- package/package.json +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @vitrinka/link
|
|
2
2
|
|
|
3
|
+
## 0.1.2
|
|
4
|
+
|
|
5
|
+
- **`@vitrinka/link/dock`.** The recorder HUD's snap math, shared by the
|
|
6
|
+
web and Expo recorders: `settle` (six spots, velocity-projected flick,
|
|
7
|
+
side-edge tuck), `spotRect`, `neighbour` (arrow keys), `untuck`,
|
|
8
|
+
`parsePlace` (validated stored position).
|
|
9
|
+
|
|
10
|
+
## 0.1.1
|
|
11
|
+
|
|
12
|
+
- **Workspace hint.** `linkWorkspace(base)` reads the `<slug>` of a
|
|
13
|
+
`/w/<slug>` base; `startLink(base, { workspace })` appends
|
|
14
|
+
`workspace=<slug>` to `verifyUrl` and `qrUrl` so the approve page
|
|
15
|
+
preselects it (the start body stays `{kind, label}`), and
|
|
16
|
+
`pollLink(base, code, { workspace })` rejects a claim pinned to any other
|
|
17
|
+
workspace with `LinkWorkspaceMismatch` — its token is never returned. It
|
|
18
|
+
fails closed: a claim that names no workspace is refused too. A base
|
|
19
|
+
without `/w/<slug>` behaves as before.
|
|
20
|
+
- `Linked` matches the claim the server sends: `expires_in` (seconds), not
|
|
21
|
+
the never-sent `expires_at`.
|
|
22
|
+
|
|
3
23
|
## 0.1.0
|
|
4
24
|
|
|
5
25
|
- Initial release: `startLink`, `pollLink`, `linkOrigin`, `isUnauthorized`,
|
package/README.md
CHANGED
|
@@ -6,18 +6,40 @@ 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.
|
|
32
|
+
|
|
33
|
+
## Dock (`@vitrinka/link/dock`)
|
|
34
|
+
|
|
35
|
+
Where a recorder HUD may rest, shared by every recorder so a throw lands the
|
|
36
|
+
same way everywhere: six spots (corners plus top and bottom centre), a
|
|
37
|
+
release projected along its velocity, and a tuck into a side-edge tab when
|
|
38
|
+
more than a third of the HUD is pushed past that edge. Pure numbers.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { settle, neighbour, parsePlace, spotRect } from '@vitrinka/link/dock';
|
|
42
|
+
|
|
43
|
+
settle(releasedRect, { x: vx, y: vy }, viewport, insets); // { spot: 'tr' } | { tuck: 'right', y: 0.4 }
|
|
44
|
+
neighbour({ spot: 'br' }, 'ArrowLeft'); // { spot: 'bc' } — the keyboard alternative
|
|
45
|
+
```
|
package/build/dock.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitrinka/link/dock — where a recorder HUD may rest, shared by every
|
|
3
|
+
* recorder (web, Expo, the extension's port) so a throw lands the same way
|
|
4
|
+
* everywhere.
|
|
5
|
+
*
|
|
6
|
+
* Six spots — the four corners plus top and bottom centre. A release is
|
|
7
|
+
* projected along its velocity (iOS picture-in-picture: a flick lands where
|
|
8
|
+
* it is aimed, not merely nearest) and settles on the spot closest to the
|
|
9
|
+
* projected centre. A release with more than a third of the HUD pushed past
|
|
10
|
+
* a side edge tucks it into a tab on that edge instead, at the height it was
|
|
11
|
+
* dropped. Pure numbers: no DOM, no React Native.
|
|
12
|
+
*/
|
|
13
|
+
export type Spot = 'tl' | 'tc' | 'tr' | 'bl' | 'bc' | 'br';
|
|
14
|
+
export type Side = 'left' | 'right';
|
|
15
|
+
/** Where the HUD rests: a spot, or tucked into a side edge at `y` (0 top … 1 bottom). */
|
|
16
|
+
export type Place = {
|
|
17
|
+
spot: Spot;
|
|
18
|
+
} | {
|
|
19
|
+
tuck: Side;
|
|
20
|
+
y: number;
|
|
21
|
+
};
|
|
22
|
+
export interface Size {
|
|
23
|
+
w: number;
|
|
24
|
+
h: number;
|
|
25
|
+
}
|
|
26
|
+
export interface Rect extends Size {
|
|
27
|
+
x: number;
|
|
28
|
+
y: number;
|
|
29
|
+
}
|
|
30
|
+
/** Distance from the viewport edges, safe-area insets already added. */
|
|
31
|
+
export interface Insets {
|
|
32
|
+
top: number;
|
|
33
|
+
right: number;
|
|
34
|
+
bottom: number;
|
|
35
|
+
left: number;
|
|
36
|
+
}
|
|
37
|
+
export declare const SPOTS: readonly Spot[];
|
|
38
|
+
export declare const DEFAULT_PLACE: Place;
|
|
39
|
+
/** Seconds of travel a release is projected along its velocity. */
|
|
40
|
+
export declare const FLICK_PROJECTION_S = 0.3;
|
|
41
|
+
/** Fraction of the HUD's width past a side edge that tucks it. */
|
|
42
|
+
export declare const TUCK_FRACTION: number;
|
|
43
|
+
export declare function rowOf(spot: Spot): 't' | 'b';
|
|
44
|
+
export declare function colOf(spot: Spot): 'l' | 'c' | 'r';
|
|
45
|
+
/** The rect a HUD of `size` occupies when resting at `spot`. */
|
|
46
|
+
export declare function spotRect(spot: Spot, size: Size, view: Size, inset: Insets): Rect;
|
|
47
|
+
/**
|
|
48
|
+
* Where a released HUD settles. `rect` is where it was let go (viewport
|
|
49
|
+
* px), `velocity` the pointer's px/s at release.
|
|
50
|
+
*/
|
|
51
|
+
export declare function settle(rect: Rect, velocity: {
|
|
52
|
+
x: number;
|
|
53
|
+
y: number;
|
|
54
|
+
}, view: Size, inset: Insets): Place;
|
|
55
|
+
/** The spot a tucked HUD returns to: the nearer corner on its side. */
|
|
56
|
+
export declare function untuck(place: Place): Spot;
|
|
57
|
+
export type Arrow = 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown';
|
|
58
|
+
/** The spot an arrow key moves to — the keyboard alternative to dragging. */
|
|
59
|
+
export declare function neighbour(place: Place, arrow: Arrow): Place;
|
|
60
|
+
/** A stored place, validated; anything else is the default. */
|
|
61
|
+
export declare function parsePlace(raw: unknown): Place;
|
package/build/dock.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitrinka/link/dock — where a recorder HUD may rest, shared by every
|
|
3
|
+
* recorder (web, Expo, the extension's port) so a throw lands the same way
|
|
4
|
+
* everywhere.
|
|
5
|
+
*
|
|
6
|
+
* Six spots — the four corners plus top and bottom centre. A release is
|
|
7
|
+
* projected along its velocity (iOS picture-in-picture: a flick lands where
|
|
8
|
+
* it is aimed, not merely nearest) and settles on the spot closest to the
|
|
9
|
+
* projected centre. A release with more than a third of the HUD pushed past
|
|
10
|
+
* a side edge tucks it into a tab on that edge instead, at the height it was
|
|
11
|
+
* dropped. Pure numbers: no DOM, no React Native.
|
|
12
|
+
*/
|
|
13
|
+
export const SPOTS = ['tl', 'tc', 'tr', 'bl', 'bc', 'br'];
|
|
14
|
+
export const DEFAULT_PLACE = { spot: 'br' };
|
|
15
|
+
/** Seconds of travel a release is projected along its velocity. */
|
|
16
|
+
export const FLICK_PROJECTION_S = 0.3;
|
|
17
|
+
/** Fraction of the HUD's width past a side edge that tucks it. */
|
|
18
|
+
export const TUCK_FRACTION = 1 / 3;
|
|
19
|
+
export function rowOf(spot) {
|
|
20
|
+
return spot[0];
|
|
21
|
+
}
|
|
22
|
+
export function colOf(spot) {
|
|
23
|
+
return spot[1];
|
|
24
|
+
}
|
|
25
|
+
/** The rect a HUD of `size` occupies when resting at `spot`. */
|
|
26
|
+
export function spotRect(spot, size, view, inset) {
|
|
27
|
+
const col = colOf(spot);
|
|
28
|
+
const x = col === 'l' ? inset.left : col === 'r' ? view.w - inset.right - size.w : (view.w - size.w) / 2;
|
|
29
|
+
const y = rowOf(spot) === 't' ? inset.top : view.h - inset.bottom - size.h;
|
|
30
|
+
return { x, y, w: size.w, h: size.h };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Where a released HUD settles. `rect` is where it was let go (viewport
|
|
34
|
+
* px), `velocity` the pointer's px/s at release.
|
|
35
|
+
*/
|
|
36
|
+
export function settle(rect, velocity, view, inset) {
|
|
37
|
+
const past = Math.max(-rect.x, rect.x + rect.w - view.w);
|
|
38
|
+
if (past > rect.w * TUCK_FRACTION) {
|
|
39
|
+
const side = -rect.x > rect.x + rect.w - view.w ? 'left' : 'right';
|
|
40
|
+
return { tuck: side, y: clamp01((rect.y + rect.h / 2) / Math.max(1, view.h)) };
|
|
41
|
+
}
|
|
42
|
+
const px = rect.x + rect.w / 2 + velocity.x * FLICK_PROJECTION_S;
|
|
43
|
+
const py = rect.y + rect.h / 2 + velocity.y * FLICK_PROJECTION_S;
|
|
44
|
+
let best = 'br';
|
|
45
|
+
let bestD = Infinity;
|
|
46
|
+
for (const spot of SPOTS) {
|
|
47
|
+
const r = spotRect(spot, rect, view, inset);
|
|
48
|
+
const d = Math.hypot(r.x + r.w / 2 - px, r.y + r.h / 2 - py);
|
|
49
|
+
if (d < bestD) {
|
|
50
|
+
bestD = d;
|
|
51
|
+
best = spot;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { spot: best };
|
|
55
|
+
}
|
|
56
|
+
/** The spot a tucked HUD returns to: the nearer corner on its side. */
|
|
57
|
+
export function untuck(place) {
|
|
58
|
+
if ('spot' in place)
|
|
59
|
+
return place.spot;
|
|
60
|
+
return `${place.y < 0.5 ? 't' : 'b'}${place.tuck === 'left' ? 'l' : 'r'}`;
|
|
61
|
+
}
|
|
62
|
+
/** The spot an arrow key moves to — the keyboard alternative to dragging. */
|
|
63
|
+
export function neighbour(place, arrow) {
|
|
64
|
+
const spot = untuck(place);
|
|
65
|
+
if (!('spot' in place))
|
|
66
|
+
return { spot };
|
|
67
|
+
const cols = ['l', 'c', 'r'];
|
|
68
|
+
let row = rowOf(spot);
|
|
69
|
+
let ci = cols.indexOf(colOf(spot));
|
|
70
|
+
if (arrow === 'ArrowLeft')
|
|
71
|
+
ci = Math.max(0, ci - 1);
|
|
72
|
+
else if (arrow === 'ArrowRight')
|
|
73
|
+
ci = Math.min(2, ci + 1);
|
|
74
|
+
else if (arrow === 'ArrowUp')
|
|
75
|
+
row = 't';
|
|
76
|
+
else
|
|
77
|
+
row = 'b';
|
|
78
|
+
return { spot: `${row}${cols[ci]}` };
|
|
79
|
+
}
|
|
80
|
+
/** A stored place, validated; anything else is the default. */
|
|
81
|
+
export function parsePlace(raw) {
|
|
82
|
+
if (typeof raw === 'string') {
|
|
83
|
+
try {
|
|
84
|
+
return parsePlace(JSON.parse(raw));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return DEFAULT_PLACE;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (raw && typeof raw === 'object') {
|
|
91
|
+
const o = raw;
|
|
92
|
+
if (typeof o.spot === 'string' && SPOTS.includes(o.spot))
|
|
93
|
+
return { spot: o.spot };
|
|
94
|
+
if ((o.tuck === 'left' || o.tuck === 'right') && typeof o.y === 'number' && Number.isFinite(o.y))
|
|
95
|
+
return { tuck: o.tuck, y: clamp01(o.y) };
|
|
96
|
+
}
|
|
97
|
+
return DEFAULT_PLACE;
|
|
98
|
+
}
|
|
99
|
+
function clamp01(n) {
|
|
100
|
+
return Math.min(1, Math.max(0, n));
|
|
101
|
+
}
|
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.2",
|
|
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": {
|
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
"types": "./build/index.d.ts",
|
|
18
18
|
"default": "./build/index.js"
|
|
19
19
|
},
|
|
20
|
+
"./dock": {
|
|
21
|
+
"types": "./build/dock.d.ts",
|
|
22
|
+
"default": "./build/dock.js"
|
|
23
|
+
},
|
|
20
24
|
"./package.json": "./package.json"
|
|
21
25
|
},
|
|
22
26
|
"files": ["build", "README.md", "CHANGELOG.md"],
|