@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 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 start = await startLink('https://app.vitrinka.ai/w/acme', { label: 'Safari on macOS · app.example.test' });
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, expires_at}` · 404 expired (`LinkExpired`).
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
+ ```
@@ -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, expires_at} · 404 expired
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
- expires_at: string;
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
- /** Ask the server for a link code. */
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
- /** Claim until approved. Resolves with the token; throws LinkExpired on 404, AbortError on abort. */
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, expires_at} · 404 expired
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
- /** Ask the server for a link code. */
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
- /** Claim until approved. Resolves with the token; throws LinkExpired on 404, AbortError on abort. */
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
- return (await res.json());
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.0",
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"],