@freqhole/api-client 0.1.31 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@freqhole/api-client",
3
- "version": "0.1.31",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -11,36 +11,26 @@
11
11
  "access": "public"
12
12
  },
13
13
  "exports": {
14
- ".": "./src/index.ts",
15
- "./storage": "./src/storage/index.ts",
16
- "./playlistz": "./src/playlistz/index.ts",
17
- "./automerge": "./src/automerge/index.ts"
14
+ ".": "./src/index.ts"
18
15
  },
19
16
  "scripts": {
20
17
  "typecheck": "tsc --noEmit",
21
18
  "test": "tsx src/test.ts",
22
19
  "test:coverage": "SKIP_INTEGRATION=true tsx src/test.ts",
23
20
  "test:integration": "tsx src/test.ts",
21
+ "test:unit": "vitest run",
24
22
  "auth-test": "vite --config vite.config.js"
25
23
  },
26
24
  "dependencies": {
27
- "idb": "^8.0.3",
28
- "zod": "^4.3.5"
29
- },
30
- "peerDependencies": {
31
- "@automerge/automerge-repo": "^2.5.5"
32
- },
33
- "peerDependenciesMeta": {
34
- "@automerge/automerge-repo": {
35
- "optional": true
36
- }
25
+ "zod": "^4.3.5",
26
+ "@freqhole/haruspex": "file:../../lib/haruspex/ts"
37
27
  },
38
28
  "devDependencies": {
39
- "@automerge/automerge-repo": "^2.5.6",
40
29
  "@tauri-apps/api": "^2.10.1",
41
30
  "@types/node": "^25.0.8",
42
31
  "tsx": "^4.7.0",
32
+ "vitest": "^2.1.0",
43
33
  "typescript": "^5.3.0",
44
34
  "vite": "^5.4.21"
45
35
  }
46
- }
36
+ }
@@ -128,12 +128,12 @@ export class CharnelTransport implements Transport {
128
128
  const inv = await ensureInvoke();
129
129
 
130
130
  // intentionally no per-error debug log here: the underlying
131
- // p2p_proxy_request command already traces failures on the rust
131
+ // p2p_api_call command already traces failures on the rust
132
132
  // side, and callers (auth-status, health-check, etc.) decide
133
133
  // whether to surface the error. logging at this layer fires once
134
134
  // per request per failed peer and drowns the console for any
135
135
  // peer that's temporarily unreachable.
136
- const result = (await inv("p2p_proxy_request", {
136
+ const result = (await inv("p2p_api_call", {
137
137
  peerAddr: this.peerAddr,
138
138
  method,
139
139
  path,
@@ -218,7 +218,7 @@ export class CharnelTransport implements Transport {
218
218
  );
219
219
  }
220
220
 
221
- // for non-music uploads, send path + metadata via proxy_request
221
+ // for non-music uploads, send path + metadata via api_request
222
222
  const body: Record<string, unknown> = {
223
223
  file_path: filePath,
224
224
  ...metadata,
@@ -253,7 +253,7 @@ export class CharnelTransport implements Transport {
253
253
  }
254
254
  }
255
255
 
256
- // send via proxy_request — routes through offal dispatch on the remote peer
256
+ // send via api_request — routes through offal dispatch on the remote peer
257
257
  return this.request("POST", path, JSON.stringify(body));
258
258
  }
259
259
 
@@ -311,7 +311,7 @@ export class CharnelTransport implements Transport {
311
311
  /**
312
312
  * fetch a blob via P2P
313
313
  * if blake3 is provided, uses iroh-blobs verified streaming.
314
- * for blobs without blake3 (images, waveforms, thumbnails), uses proxy_request
314
+ * for blobs without blake3 (images, waveforms, thumbnails), uses api_request
315
315
  * to fetch base64-encoded data from the peer's /api/blobs/{id}/data endpoint.
316
316
  */
317
317
  async fetchBlob(blobId: string, blake3?: string): Promise<BlobData> {
@@ -334,7 +334,7 @@ export class CharnelTransport implements Transport {
334
334
  };
335
335
  }
336
336
 
337
- // no blake3 — try proxy_request to get blob data from database
337
+ // no blake3 — try api_request to get blob data from database
338
338
  // this is the primary path for images (waveforms, thumbnails) stored in the database
339
339
  try {
340
340
  const result = await this.request("GET", `/api/blobs/${blobId}/data`);
@@ -349,7 +349,7 @@ export class CharnelTransport implements Transport {
349
349
  } catch (e) {
350
350
  const errorMessage = e instanceof Error ? e.message : String(e);
351
351
  console.warn(
352
- `[CharnelTransport] proxy blob data request failed, falling back to verified download: ${errorMessage}`,
352
+ `[CharnelTransport] api blob data request failed, falling back to verified download: ${errorMessage}`,
353
353
  );
354
354
  }
355
355
 
@@ -45,7 +45,7 @@ export interface BiStreamLike {
45
45
  export interface MiddenNodeLike {
46
46
  node_id(): string;
47
47
  secret_key(): Uint8Array;
48
- proxy_request(
48
+ api_request(
49
49
  peer_addr: string,
50
50
  method: string,
51
51
  path: string,
@@ -218,7 +218,7 @@ export class WasmTransport implements Transport {
218
218
  body?: string,
219
219
  ): Promise<TransportResponse> {
220
220
  try {
221
- const result = await this.node.proxy_request(
221
+ const result = await this.node.api_request(
222
222
  this.peerAddr,
223
223
  method,
224
224
  path,
@@ -260,7 +260,7 @@ export class WasmTransport implements Transport {
260
260
  return this.uploadViaIrohBlobs(file, formData);
261
261
  }
262
262
 
263
- // fallback: base64 encode and send via proxy_request (works for image uploads)
263
+ // fallback: base64 encode and send via api_request (works for image uploads)
264
264
  return this.uploadViaBase64(path, file, formData);
265
265
  }
266
266
 
@@ -363,7 +363,7 @@ export class WasmTransport implements Transport {
363
363
  }
364
364
  }
365
365
 
366
- // send via proxy_request — routes through offal dispatch on the remote peer
366
+ // send via api_request — routes through offal dispatch on the remote peer
367
367
  return this.request("POST", path, JSON.stringify(body));
368
368
  }
369
369
 
@@ -407,15 +407,15 @@ export class WasmTransport implements Transport {
407
407
  console.warn(
408
408
  `[WasmTransport] verified download failed, falling back: ${errorMessage}`,
409
409
  );
410
- // fall through to proxy fetch
410
+ // fall through to api fetch
411
411
  }
412
412
  }
413
413
  }
414
414
 
415
- // no blake3 (or verified download failed) — try proxy_request to get blob data
415
+ // no blake3 (or verified download failed) — try api_request to get blob data
416
416
  // this is the primary path for images (waveforms, thumbnails) stored in the database
417
417
  try {
418
- const result = await this.node.proxy_request(
418
+ const result = await this.node.api_request(
419
419
  this.peerAddr,
420
420
  "GET",
421
421
  `/api/blobs/${blobId}/data`,
@@ -443,7 +443,7 @@ export class WasmTransport implements Transport {
443
443
  } catch (e) {
444
444
  const errorMessage = e instanceof Error ? e.message : String(e);
445
445
  console.warn(
446
- `[WasmTransport] proxy blob data request failed, falling back: ${errorMessage}`,
446
+ `[WasmTransport] api blob data request failed, falling back: ${errorMessage}`,
447
447
  );
448
448
  }
449
449
 
@@ -653,9 +653,10 @@ export class WasmTransport implements Transport {
653
653
  }
654
654
 
655
655
  // no blake3 (or verified download failed).
656
- // for audio blobs, prefer verified-by-id before proxy `/data` because
657
- // `/api/blobs/{id}/data` only works for DB-backed blobs and file-backed
658
- // media blobs would otherwise fail first, then fall back anyway.
656
+ // for audio blobs, prefer verified-by-id before the api_request `/data`
657
+ // fallback because `/api/blobs/{id}/data` only works for DB-backed blobs
658
+ // and file-backed media blobs would otherwise fail first, then fall back
659
+ // anyway.
659
660
  if (
660
661
  !blake3 &&
661
662
  this.node.download_verified_by_id &&
@@ -684,15 +685,15 @@ export class WasmTransport implements Transport {
684
685
  } catch (e) {
685
686
  const errorMessage = e instanceof Error ? e.message : String(e);
686
687
  console.warn(
687
- `[WasmTransport] audio verified-by-id failed, falling back to proxy: ${errorMessage}`,
688
+ `[WasmTransport] audio verified-by-id failed, falling back to api_request: ${errorMessage}`,
688
689
  );
689
690
  }
690
691
  }
691
692
 
692
- // try proxy_request to get blob data
693
- let proxyFailureReason: string | null = null;
693
+ // try api_request to get blob data
694
+ let apiFailureReason: string | null = null;
694
695
  try {
695
- const result = await this.node.proxy_request(
696
+ const result = await this.node.api_request(
696
697
  this.peerAddr,
697
698
  "GET",
698
699
  `/api/blobs/${blobId}/data`,
@@ -719,17 +720,17 @@ export class WasmTransport implements Transport {
719
720
 
720
721
  return { data, contentType };
721
722
  }
722
- proxyFailureReason = `success=false in proxy response body for blob ${blobId}`;
723
- console.warn(`[WasmTransport] ${proxyFailureReason}`);
723
+ apiFailureReason = `success=false in api response body for blob ${blobId}`;
724
+ console.warn(`[WasmTransport] ${apiFailureReason}`);
724
725
  } else {
725
- proxyFailureReason = `proxy returned status ${result.status} for blob ${blobId}`;
726
- console.warn(`[WasmTransport] ${proxyFailureReason}`);
726
+ apiFailureReason = `api request returned status ${result.status} for blob ${blobId}`;
727
+ console.warn(`[WasmTransport] ${apiFailureReason}`);
727
728
  }
728
729
  } catch (e) {
729
730
  const errorMessage = e instanceof Error ? e.message : String(e);
730
- proxyFailureReason = `proxy request threw: ${errorMessage}`;
731
+ apiFailureReason = `api request threw: ${errorMessage}`;
731
732
  console.warn(
732
- `[WasmTransport] proxy blob data request failed, falling back: ${errorMessage}`,
733
+ `[WasmTransport] api blob data request failed, falling back: ${errorMessage}`,
733
734
  );
734
735
  }
735
736
 
@@ -804,7 +805,7 @@ export class WasmTransport implements Transport {
804
805
  throw new Error(
805
806
  `no download method available for blob ${blobId} ` +
806
807
  `(blake3=${blake3 ? "yes" : "no"}, ` +
807
- `proxy=${proxyFailureReason ?? "not attempted"}, ` +
808
+ `api=${apiFailureReason ?? "not attempted"}, ` +
808
809
  `legacy_fetch_blob=${this.node.fetch_blob ? "available" : "missing"})`,
809
810
  );
810
811
  }
@@ -93,5 +93,28 @@ export function createAdminMethods(call: CallFn) {
93
93
  routes.admin.get_knock_status_public.path,
94
94
  );
95
95
  },
96
+
97
+ // internal service callbacks
98
+ deviceLinkedCallback: (params: s.DeviceLinkedCallbackRequest) => {
99
+ return call(
100
+ "admin", "device_linked_callback",
101
+ routes.admin.device_linked_callback.resp,
102
+ routes.admin.device_linked_callback.req,
103
+ routes.admin.device_linked_callback.method,
104
+ routes.admin.device_linked_callback.path,
105
+ params,
106
+ );
107
+ },
108
+
109
+ knockAcceptedCallback: (params: s.KnockAcceptedCallbackRequest) => {
110
+ return call(
111
+ "admin", "knock_accepted_callback",
112
+ routes.admin.knock_accepted_callback.resp,
113
+ routes.admin.knock_accepted_callback.req,
114
+ routes.admin.knock_accepted_callback.method,
115
+ routes.admin.knock_accepted_callback.path,
116
+ params,
117
+ );
118
+ },
96
119
  };
97
120
  }
@@ -1,8 +1,12 @@
1
1
  // permission helpers for route authorization
2
2
  //
3
3
  // these are hand-written helpers that use the generated route auth metadata
4
- // to check if a user has permission to perform actions.
4
+ // to check if a user has permission to perform actions. the core role-
5
+ // hierarchy/route-auth evaluation logic is bound once over this package's
6
+ // own codegen'd role hierarchy from @freqhole/haruspex/permissions - only
7
+ // the entity-specific route lookups below are tomb-specific.
5
8
 
9
+ import { bindRoleHierarchy, type AccessRule } from "@freqhole/haruspex/permissions";
6
10
  import {
7
11
  roleHierarchy,
8
12
  routes,
@@ -10,6 +14,8 @@ import {
10
14
  type UserRoleName,
11
15
  } from "./codegen/routes.js";
12
16
 
17
+ const roleChecks = bindRoleHierarchy(roleHierarchy);
18
+
13
19
  // ============================================================================
14
20
  // core permission functions
15
21
  // ============================================================================
@@ -26,7 +32,7 @@ export function canAccessRole(
26
32
  userRole: UserRoleName,
27
33
  requiredRole: UserRoleName,
28
34
  ): boolean {
29
- return roleHierarchy[userRole] <= roleHierarchy[requiredRole];
35
+ return roleChecks.hasAtLeastRole(userRole, requiredRole);
30
36
  }
31
37
 
32
38
  /**
@@ -45,12 +51,7 @@ export function canAccessOwnerOr(
45
51
  userRole: UserRoleName,
46
52
  requiredRole: UserRoleName,
47
53
  ): boolean {
48
- // owner always has access
49
- if (ownerId !== null && userId === ownerId) {
50
- return true;
51
- }
52
- // otherwise check role
53
- return canAccessRole(userRole, requiredRole);
54
+ return roleChecks.canAccessOwnerOr(userId, ownerId, userRole, requiredRole);
54
55
  }
55
56
 
56
57
  /**
@@ -65,7 +66,7 @@ export function canAccessOwner(
65
66
  userId: string,
66
67
  ownerId: string | null,
67
68
  ): boolean {
68
- return ownerId !== null && userId === ownerId;
69
+ return roleChecks.isOwner(userId, ownerId);
69
70
  }
70
71
 
71
72
  /**
@@ -84,28 +85,14 @@ export function canAccessRoute(
84
85
  userId: string | null,
85
86
  ownerId: string | null,
86
87
  ): boolean {
87
- switch (auth.type) {
88
- case "public":
89
- return true;
90
-
91
- case "authenticated":
92
- return userRole !== null;
93
-
94
- case "role":
95
- return userRole !== null && canAccessRole(userRole, auth.role);
96
-
97
- case "owner":
98
- return userId !== null && canAccessOwner(userId, ownerId);
99
-
100
- case "owner_or":
101
- return (
102
- userId !== null &&
103
- userRole !== null &&
104
- canAccessOwnerOr(userId, ownerId, userRole, auth.role)
105
- );
106
- }
88
+ return roleChecks.canAccess(auth as AccessRule<UserRoleName>, {
89
+ userRole,
90
+ userId,
91
+ ownerId,
92
+ });
107
93
  }
108
94
 
95
+
109
96
  // ============================================================================
110
97
  // entity-specific permission helpers
111
98
  // ============================================================================
@@ -0,0 +1,192 @@
1
+ // tests for the http polling fallback's snapshot-diffing state machine.
2
+ //
3
+ // pollingJobEvents synthesizes status_changed/stage events by diffing
4
+ // consecutive snapshots against a retained baseline (`prev`). these
5
+ // tests exist because that diffing logic previously had a real bug: a
6
+ // transient snapshot fetch failure wiped `prev` entirely, causing every
7
+ // still-active job to spuriously re-announce as "new" (from: null) on
8
+ // the next successful poll even though nothing had actually changed.
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
+ import {
11
+ pollingJobEvents,
12
+ POLL_INTERVAL_FALLBACK_MS,
13
+ type Transport,
14
+ type TransportResponse,
15
+ } from "./transport.js";
16
+ import type { JobStateSnapshot } from "./codegen/schema.js";
17
+
18
+ function snapshot(
19
+ overrides: Partial<JobStateSnapshot> & Pick<JobStateSnapshot, "job_id">,
20
+ ): JobStateSnapshot {
21
+ return {
22
+ session_id: null,
23
+ job_type: "ScanDirectory",
24
+ status: "pending",
25
+ entity_ref: null,
26
+ created_by: null,
27
+ last_stage: null,
28
+ last_message: null,
29
+ updated_at: 0,
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ /** a fake Transport whose `request` returns one canned snapshot list (or
35
+ * throws) per call, in order. the last entry repeats for any extra calls. */
36
+ function fakeTransport(responses: Array<JobStateSnapshot[] | Error>): Transport {
37
+ let call = 0;
38
+ const request = vi.fn(async (): Promise<TransportResponse> => {
39
+ const entry = responses[Math.min(call, responses.length - 1)];
40
+ call++;
41
+ if (entry instanceof Error) throw entry;
42
+ return { status: 200, body: JSON.stringify({ success: true, data: entry }) };
43
+ });
44
+ return {
45
+ request,
46
+ upload: vi.fn(),
47
+ fetchBlob: vi.fn(),
48
+ getBlobUrl: vi.fn(),
49
+ } as unknown as Transport;
50
+ }
51
+
52
+ /** advances the fake poll-interval timer `count` times in sequence,
53
+ * fully draining the microtask queue between each advance. */
54
+ async function advanceTicks(count: number): Promise<void> {
55
+ for (let i = 0; i < count; i++) {
56
+ await vi.advanceTimersByTimeAsync(POLL_INTERVAL_FALLBACK_MS);
57
+ }
58
+ }
59
+
60
+ describe("pollingJobEvents", () => {
61
+ beforeEach(() => {
62
+ vi.useFakeTimers();
63
+ });
64
+
65
+ afterEach(() => {
66
+ vi.useRealTimers();
67
+ });
68
+
69
+ it("the first tick only seeds the baseline - no event for an already-known job", async () => {
70
+ const transport = fakeTransport([
71
+ [snapshot({ job_id: "job-a", status: "running" })],
72
+ [snapshot({ job_id: "job-a", status: "completed" })],
73
+ ]);
74
+ const iter = pollingJobEvents(transport);
75
+ const nextPromise = iter.next();
76
+
77
+ await advanceTicks(1);
78
+ const result = await nextPromise;
79
+
80
+ expect(result.done).toBe(false);
81
+ expect(result.value).toMatchObject({
82
+ kind: "status_changed",
83
+ job_id: "job-a",
84
+ from: "running",
85
+ to: "completed",
86
+ });
87
+ });
88
+
89
+ it("a job appearing after the baseline yields status_changed with from: null", async () => {
90
+ const transport = fakeTransport([
91
+ [],
92
+ [snapshot({ job_id: "job-a", status: "running" })],
93
+ ]);
94
+ const iter = pollingJobEvents(transport);
95
+ const nextPromise = iter.next();
96
+
97
+ await advanceTicks(1);
98
+ const result = await nextPromise;
99
+
100
+ expect(result.value).toMatchObject({
101
+ kind: "status_changed",
102
+ job_id: "job-a",
103
+ from: null,
104
+ to: "running",
105
+ });
106
+ });
107
+
108
+ it("a stage/message change yields a stage event", async () => {
109
+ const transport = fakeTransport([
110
+ [snapshot({ job_id: "job-a", status: "running", last_stage: "scanning" })],
111
+ [
112
+ snapshot({
113
+ job_id: "job-a",
114
+ status: "running",
115
+ last_stage: "importing",
116
+ last_message: "42 files",
117
+ }),
118
+ ],
119
+ ]);
120
+ const iter = pollingJobEvents(transport);
121
+ const nextPromise = iter.next();
122
+
123
+ await advanceTicks(1);
124
+ const result = await nextPromise;
125
+
126
+ expect(result.value).toMatchObject({
127
+ kind: "stage",
128
+ job_id: "job-a",
129
+ stage: "importing",
130
+ message: "42 files",
131
+ });
132
+ });
133
+
134
+ it("a transient fetch failure between two ticks does not wipe tracked state (regression)", async () => {
135
+ const transport = fakeTransport([
136
+ [snapshot({ job_id: "job-a", status: "running" })], // tick 1: seed
137
+ new Error("network blip"), // tick 2: transient failure
138
+ [snapshot({ job_id: "job-a", status: "running" })], // tick 3: unchanged
139
+ [snapshot({ job_id: "job-a", status: "completed" })], // tick 4: the only real change
140
+ ]);
141
+ const iter = pollingJobEvents(transport);
142
+ const nextPromise = iter.next();
143
+
144
+ // if the failed tick wiped `prev`, job-a would look "new" again on
145
+ // tick 3 (an unchanged snapshot) and this promise would already have
146
+ // resolved with a spurious `from: null` event after just 2 advances.
147
+ await advanceTicks(3);
148
+ const result = await nextPromise;
149
+
150
+ expect(result.value).toMatchObject({
151
+ kind: "status_changed",
152
+ job_id: "job-a",
153
+ from: "running",
154
+ to: "completed",
155
+ });
156
+ });
157
+
158
+ it("a disappearing job_id is silently dropped, not yielded", async () => {
159
+ const transport = fakeTransport([
160
+ [snapshot({ job_id: "job-a", status: "completed" })],
161
+ [], // job-a no longer present
162
+ [snapshot({ job_id: "job-b", status: "running" })],
163
+ ]);
164
+ const iter = pollingJobEvents(transport);
165
+ const nextPromise = iter.next();
166
+
167
+ await advanceTicks(2);
168
+ const result = await nextPromise;
169
+
170
+ // the only event ever produced is job-b appearing - job-a's
171
+ // disappearance after tick 2 produced nothing.
172
+ expect(result.value).toMatchObject({
173
+ kind: "status_changed",
174
+ job_id: "job-b",
175
+ from: null,
176
+ to: "running",
177
+ });
178
+ });
179
+
180
+ it("stops promptly when the abort signal fires", async () => {
181
+ const controller = new AbortController();
182
+ const transport = fakeTransport([[snapshot({ job_id: "job-a", status: "running" })]]);
183
+ const iter = pollingJobEvents(transport, undefined, controller.signal);
184
+
185
+ const nextPromise = iter.next();
186
+ controller.abort();
187
+ await advanceTicks(1);
188
+ const result = await nextPromise;
189
+
190
+ expect(result.done).toBe(true);
191
+ });
192
+ });