@freqhole/api-client 0.1.30

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.
Files changed (45) hide show
  1. package/auth-test.html +651 -0
  2. package/package.json +42 -0
  3. package/src/AdminClient.ts +154 -0
  4. package/src/CharnelLocalTransport.ts +598 -0
  5. package/src/CharnelTransport.ts +616 -0
  6. package/src/FreqholeClient.ts +261 -0
  7. package/src/WasmTransport.ts +1077 -0
  8. package/src/automerge/IrohNetworkAdapter.ts +727 -0
  9. package/src/automerge/index.ts +14 -0
  10. package/src/codegen/admin_commands.ts +61 -0
  11. package/src/codegen/routes.ts +213 -0
  12. package/src/codegen/schema.ts +6721 -0
  13. package/src/domains/admin.ts +97 -0
  14. package/src/domains/app.ts +98 -0
  15. package/src/domains/auth.ts +191 -0
  16. package/src/domains/favorites.types.ts +52 -0
  17. package/src/domains/jobs.ts +48 -0
  18. package/src/domains/music.ts +1935 -0
  19. package/src/domains/types.ts +19 -0
  20. package/src/domains/upload.ts +214 -0
  21. package/src/index.ts +48 -0
  22. package/src/permissions.ts +468 -0
  23. package/src/playlistz/convert.ts +267 -0
  24. package/src/playlistz/index.ts +6 -0
  25. package/src/playlistz/mutations.ts +181 -0
  26. package/src/playlistz/protocol.ts +222 -0
  27. package/src/playlistz/schema.ts +138 -0
  28. package/src/playlistz/shareLink.ts +76 -0
  29. package/src/storage/blobs.ts +272 -0
  30. package/src/storage/identity.ts +190 -0
  31. package/src/storage/index.ts +5 -0
  32. package/src/storage/webLocks.ts +92 -0
  33. package/src/test/automerge.ts +796 -0
  34. package/src/test/coverage.ts +105 -0
  35. package/src/test/fixtures.ts +460 -0
  36. package/src/test/integration.ts +320 -0
  37. package/src/test/playlistz.ts +631 -0
  38. package/src/test/stateful.ts +561 -0
  39. package/src/test/storage.ts +443 -0
  40. package/src/test.ts +75 -0
  41. package/src/transport.ts +391 -0
  42. package/src/utils.ts +79 -0
  43. package/src/webauthn.ts +228 -0
  44. package/tsconfig.json +22 -0
  45. package/vite.config.js +9 -0
@@ -0,0 +1,138 @@
1
+ // zod schemas for the playlistz automerge document model.
2
+ // plain objects + zod only - no automerge imports.
3
+ import * as z from "zod";
4
+
5
+ // the set of blob types a stored binary can represent
6
+ export const BlobKindSchema = z.union([
7
+ z.literal("original"),
8
+ z.literal("thumbnail"),
9
+ z.literal("waveform"),
10
+ z.literal("preview"),
11
+ ]);
12
+ export type BlobKind = z.infer<typeof BlobKindSchema>;
13
+
14
+ // a reference to a binary stored in the blob store, keyed by sha256
15
+ export const ImageRefSchema = z.object({
16
+ blobId: z.string(),
17
+ isPrimary: z.boolean(),
18
+ blobType: BlobKindSchema,
19
+ });
20
+ export type ImageRef = z.infer<typeof ImageRefSchema>;
21
+
22
+ // a named url associated with a playlist or song
23
+ export const EntityUrlSchema = z.object({
24
+ id: z.string().optional(),
25
+ name: z.string().optional(),
26
+ url: z.string(),
27
+ });
28
+ export type EntityUrl = z.infer<typeof EntityUrlSchema>;
29
+
30
+ // a single song as stored in the playlist doc
31
+ export const SongEntrySchema = z.object({
32
+ id: z.string(),
33
+ title: z.string(),
34
+ artist: z.string(),
35
+ album: z.string(),
36
+ duration: z.number(),
37
+ mimeType: z.string(),
38
+ fileSize: z.number(),
39
+ sha256: z.string(),
40
+ blake3: z.string().optional(),
41
+ images: z.array(ImageRefSchema).default([]),
42
+ urls: z.array(EntityUrlSchema).default([]),
43
+ lyrics: z.string().optional(),
44
+ });
45
+ export type SongEntry = z.infer<typeof SongEntrySchema>;
46
+
47
+ // the top-level automerge document for a playlist.
48
+ // all fields have defaults so schema.parse({}) succeeds for graceful degradation.
49
+ export const PlaylistDocSchema = z.object({
50
+ version: z.literal(1).default(1),
51
+ title: z.string().default(""),
52
+ description: z.string().default(""),
53
+ createdAt: z.string().default(() => new Date().toISOString()),
54
+ lastModified: z.string().default(() => new Date().toISOString()),
55
+ lastModifiedBy: z.string().default(""),
56
+ images: z.array(ImageRefSchema).default([]),
57
+ urls: z.array(EntityUrlSchema).default([]),
58
+ songs: z.record(z.string(), SongEntrySchema).default({}),
59
+ order: z.array(z.string()).default([]),
60
+ peers: z
61
+ .record(
62
+ z.string(),
63
+ z.object({
64
+ nodeId: z.string(),
65
+ joinedAt: z.string(),
66
+ lastSeenAt: z.string().optional(),
67
+ }),
68
+ )
69
+ .default({}),
70
+ acl: z
71
+ .record(
72
+ z.string(),
73
+ z.object({
74
+ role: z.union([
75
+ z.literal("owner"),
76
+ z.literal("editor"),
77
+ z.literal("viewer"),
78
+ ]),
79
+ }),
80
+ )
81
+ .optional(),
82
+ deleted: z.boolean().optional(),
83
+ // per-playlist sharing mode. unset = private (not shared).
84
+ sharingMode: z.union([z.literal("public"), z.literal("knock")]).optional(),
85
+ // display filter settings for the playlist background and cover
86
+ bgFilterEnabled: z.boolean().optional(),
87
+ bgFilterBlur: z.number().optional(),
88
+ bgFilterContrast: z.number().optional(),
89
+ bgFilterBrightness: z.number().optional(),
90
+ coverFilterEnabled: z.boolean().optional(),
91
+ coverFilterBlur: z.number().optional(),
92
+ // background image layout
93
+ bgSize: z.string().optional(), // css background-size, default: "cover"
94
+ bgPosition: z.string().optional(), // css background-position, default: "top"
95
+ bgRepeat: z.string().optional(), // css background-repeat, default: "no-repeat"
96
+ });
97
+ export type PlaylistDoc = z.infer<typeof PlaylistDocSchema>;
98
+
99
+ // recursively copy an object keeping only string-keyed enumerable props.
100
+ // automerge doc proxies carry symbol keys (_am_objectId, _am_datatype_) that
101
+ // zod records reject, so raw docs are plainified before parsing.
102
+ function plainify(value: unknown): unknown {
103
+ if (Array.isArray(value)) return value.map(plainify);
104
+ if (value && typeof value === "object") {
105
+ const out: Record<string, unknown> = {};
106
+ for (const key of Object.keys(value)) {
107
+ out[key] = plainify((value as Record<string, unknown>)[key]);
108
+ }
109
+ return out;
110
+ }
111
+ return value;
112
+ }
113
+
114
+ // parse raw unknown data into a PlaylistDoc.
115
+ // on failure, logs a warning and returns schema defaults (graceful degradation).
116
+ export function parsePlaylistDoc(raw: unknown): PlaylistDoc {
117
+ try {
118
+ return PlaylistDocSchema.parse(plainify(raw ?? {}));
119
+ } catch (err) {
120
+ console.warn(
121
+ "[playlistz:schema] parse failed - falling back to defaults. error:",
122
+ err instanceof z.ZodError ? err.issues : err,
123
+ "raw keys:",
124
+ raw && typeof raw === "object" ? Object.keys(raw) : "null",
125
+ );
126
+ return PlaylistDocSchema.parse({});
127
+ }
128
+ }
129
+
130
+ // create an empty PlaylistDoc, optionally merged with caller-supplied fields.
131
+ export function emptyPlaylistDoc(init?: Partial<PlaylistDoc>): PlaylistDoc {
132
+ const now = new Date().toISOString();
133
+ return PlaylistDocSchema.parse({
134
+ createdAt: now,
135
+ lastModified: now,
136
+ ...init,
137
+ });
138
+ }
@@ -0,0 +1,76 @@
1
+ // share token encoding/decoding for playlistz p2p sharing.
2
+ // versioned base64url payload, no padding.
3
+ // format: base64url({ v: 1, n: nodeId, d: docId, t?: titleHint })
4
+ // url fragment: #share/<token>
5
+ import * as z from "zod";
6
+
7
+ // v1 share payload: node id, doc id, optional title hint, optional mode hint
8
+ const SharePayloadV1Schema = z.object({
9
+ v: z.literal(1),
10
+ n: z.string().min(1),
11
+ d: z.string().min(1),
12
+ t: z.string().optional(),
13
+ m: z.enum(["public", "knock"]).optional(), // sharing mode at link-creation time
14
+ });
15
+ export type SharePayloadV1 = z.infer<typeof SharePayloadV1Schema>;
16
+
17
+ // convert standard base64 to base64url (no padding)
18
+ function toBase64Url(b64: string): string {
19
+ return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
20
+ }
21
+
22
+ // normalize base64url back to standard base64 for decoding
23
+ function fromBase64Url(b64url: string): string {
24
+ let s = b64url.replace(/-/g, "+").replace(/_/g, "/");
25
+ // re-add padding
26
+ while (s.length % 4 !== 0) {
27
+ s += "=";
28
+ }
29
+ return s;
30
+ }
31
+
32
+ // encode a share payload to a base64url token (no padding)
33
+ export function encodeShareToken(payload: SharePayloadV1): string {
34
+ const json = JSON.stringify(payload);
35
+ const b64 = btoa(json);
36
+ return toBase64Url(b64);
37
+ }
38
+
39
+ // decode a share token back to a SharePayloadV1.
40
+ // forgiving: strips whitespace, full urls, "#share/" and "share/" prefixes.
41
+ // accepts both base64 and base64url.
42
+ // returns null on anything invalid.
43
+ export function decodeShareToken(input: string): SharePayloadV1 | null {
44
+ try {
45
+ let raw = input.trim();
46
+
47
+ // strip full url up to and including the #share/ fragment
48
+ const hashIdx = raw.indexOf("#share/");
49
+ if (hashIdx !== -1) {
50
+ raw = raw.slice(hashIdx + 7);
51
+ } else if (raw.startsWith("share/")) {
52
+ raw = raw.slice(6);
53
+ }
54
+
55
+ // strip any trailing fragment or query that may have been appended
56
+ const ampIdx = raw.indexOf("&");
57
+ if (ampIdx !== -1) raw = raw.slice(0, ampIdx);
58
+
59
+ if (!raw) return null;
60
+
61
+ const b64 = fromBase64Url(raw);
62
+ const json = atob(b64);
63
+ const parsed: unknown = JSON.parse(json);
64
+
65
+ const result = SharePayloadV1Schema.safeParse(parsed);
66
+ if (!result.success) return null;
67
+ return result.data;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ // build a url fragment for embedding in window.location.hash
74
+ export function shareFragment(payload: SharePayloadV1): string {
75
+ return `#share/${encodeShareToken(payload)}`;
76
+ }
@@ -0,0 +1,272 @@
1
+ // local blob storage for images and media
2
+ // metadata in IndexedDB, actual blob data in OPFS or Cache API
3
+
4
+ import { openDB, type IDBPDatabase } from "idb";
5
+
6
+ // module-local logger - no-op by default; wire up via setStorageLogger
7
+ let logger: (scope: string, ...args: unknown[]) => void = () => {};
8
+
9
+ /**
10
+ * set the logger function used by blob storage operations.
11
+ * call this at app startup to opt into logging.
12
+ */
13
+ export function setStorageLogger(
14
+ fn: (scope: string, ...args: unknown[]) => void,
15
+ ): void {
16
+ logger = fn;
17
+ }
18
+
19
+ export const BLOB_DB_NAME = "freqhole_blobs";
20
+ const BLOB_DB_VERSION = 1;
21
+ const STORE_BLOBS = "blobs";
22
+ const CACHE_NAME = "freqhole-blobs";
23
+
24
+ // global blob URL cache - persists for session, cleared on page reload
25
+ const BLOB_URL_CACHE = new Map<string, string>();
26
+
27
+ export type BlobStorageType = "opfs" | "cache";
28
+
29
+ export interface BlobRecord {
30
+ blob_id: string; // sha256 hash of content (primary key)
31
+ storage_type: BlobStorageType;
32
+ storage_path: string; // opfs path or cache key
33
+ mime_type: string;
34
+ file_size: number;
35
+ created_at: number;
36
+ }
37
+
38
+ let blobDbInstance: IDBPDatabase | null = null;
39
+
40
+ async function initBlobDB(): Promise<IDBPDatabase> {
41
+ if (blobDbInstance) return blobDbInstance;
42
+
43
+ blobDbInstance = await openDB(BLOB_DB_NAME, BLOB_DB_VERSION, {
44
+ upgrade(db) {
45
+ if (!db.objectStoreNames.contains(STORE_BLOBS)) {
46
+ db.createObjectStore(STORE_BLOBS, { keyPath: "blob_id" });
47
+ }
48
+ },
49
+ });
50
+
51
+ return blobDbInstance;
52
+ }
53
+
54
+ // close database connection (required before deletion)
55
+ export function closeBlobDB(): void {
56
+ if (blobDbInstance) {
57
+ blobDbInstance.close();
58
+ blobDbInstance = null;
59
+ logger("blobs", "blob database connection closed");
60
+ }
61
+ }
62
+
63
+ // check if OPFS is available
64
+ async function isOPFSAvailable(): Promise<boolean> {
65
+ try {
66
+ if (!navigator.storage?.getDirectory) return false;
67
+ await navigator.storage.getDirectory();
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ // write blob to OPFS
75
+ async function writeBlobToOPFS(blobId: string, data: Blob): Promise<string> {
76
+ const root = await navigator.storage.getDirectory();
77
+ const blobsDir = await root.getDirectoryHandle("blobs", { create: true });
78
+ const fileHandle = await blobsDir.getFileHandle(blobId, { create: true });
79
+ const writable = await fileHandle.createWritable();
80
+ await writable.write(data);
81
+ await writable.close();
82
+ return `/blobs/${blobId}`;
83
+ }
84
+
85
+ // read blob from OPFS
86
+ async function readBlobFromOPFS(storagePath: string): Promise<Blob | null> {
87
+ try {
88
+ const root = await navigator.storage.getDirectory();
89
+ const blobsDir = await root.getDirectoryHandle("blobs");
90
+ const fileName = storagePath.split("/").pop()!;
91
+ const fileHandle = await blobsDir.getFileHandle(fileName);
92
+ const file = await fileHandle.getFile();
93
+ return file;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ // write blob to Cache API
100
+ async function writeBlobToCache(
101
+ blobId: string,
102
+ data: Blob,
103
+ mimeType: string,
104
+ ): Promise<string> {
105
+ const cache = await caches.open(CACHE_NAME);
106
+ // use synthetic HTTP URL - webkitgtk (linux) rejects non-HTTP URLs in Cache API
107
+ const url = `https://blob.local/${blobId}`;
108
+ const response = new Response(data, {
109
+ headers: { "Content-Type": mimeType },
110
+ });
111
+ await cache.put(url, response);
112
+ return url;
113
+ }
114
+
115
+ // read blob from Cache API
116
+ async function readBlobFromCache(storagePath: string): Promise<Blob | null> {
117
+ try {
118
+ const cache = await caches.open(CACHE_NAME);
119
+ const response = await cache.match(storagePath);
120
+ if (!response) return null;
121
+ return await response.blob();
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * store a blob in local storage and return its ID (sha256 hash).
129
+ * automatically chooses OPFS or Cache API based on availability.
130
+ */
131
+ export async function storeBlob(
132
+ data: Blob,
133
+ mimeType: string,
134
+ ): Promise<string> {
135
+ const db = await initBlobDB();
136
+
137
+ // compute sha256 hash of blob data
138
+ const arrayBuffer = await data.arrayBuffer();
139
+ const hashBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer);
140
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
141
+ const blobId = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
142
+
143
+ // check if already exists
144
+ const existing = await db.get(STORE_BLOBS, blobId);
145
+ if (existing) {
146
+ return blobId;
147
+ }
148
+
149
+ // determine storage type and write blob data
150
+ const useOPFS = await isOPFSAvailable();
151
+ const storageType: BlobStorageType = useOPFS ? "opfs" : "cache";
152
+ const storagePath = useOPFS
153
+ ? await writeBlobToOPFS(blobId, data)
154
+ : await writeBlobToCache(blobId, data, mimeType);
155
+
156
+ // store metadata
157
+ const record: BlobRecord = {
158
+ blob_id: blobId,
159
+ storage_type: storageType,
160
+ storage_path: storagePath,
161
+ mime_type: mimeType,
162
+ file_size: data.size,
163
+ created_at: Date.now(),
164
+ };
165
+
166
+ await db.put(STORE_BLOBS, record);
167
+ return blobId;
168
+ }
169
+
170
+ /**
171
+ * get blob metadata by ID
172
+ */
173
+ export async function getBlobMetadata(
174
+ blobId: string,
175
+ ): Promise<BlobRecord | null> {
176
+ const db = await initBlobDB();
177
+ const record = await db.get(STORE_BLOBS, blobId);
178
+ return record || null;
179
+ }
180
+
181
+ /**
182
+ * get blob data by ID
183
+ */
184
+ export async function getBlob(blobId: string): Promise<Blob | null> {
185
+ const metadata = await getBlobMetadata(blobId);
186
+ if (!metadata) return null;
187
+
188
+ if (metadata.storage_type === "opfs") {
189
+ return await readBlobFromOPFS(metadata.storage_path);
190
+ } else {
191
+ return await readBlobFromCache(metadata.storage_path);
192
+ }
193
+ }
194
+
195
+ /**
196
+ * get blob object URL by ID, cached to avoid repeated OPFS reads and URL creations.
197
+ * returns an object URL string usable directly in an img src.
198
+ * URLs are cached for the session and revoked on page unload.
199
+ */
200
+ export async function getBlobObjectURL(
201
+ blobId: string,
202
+ ): Promise<string | null> {
203
+ if (!blobId) return null;
204
+
205
+ // check cache first
206
+ const cachedUrl = BLOB_URL_CACHE.get(blobId);
207
+ if (cachedUrl) return cachedUrl;
208
+
209
+ // fetch blob and create object URL
210
+ const blob = await getBlob(blobId);
211
+ if (!blob) return null;
212
+
213
+ const objectUrl = URL.createObjectURL(blob);
214
+ BLOB_URL_CACHE.set(blobId, objectUrl);
215
+
216
+ return objectUrl;
217
+ }
218
+
219
+ /**
220
+ * synchronous blob URL cache lookup (no OPFS read).
221
+ * returns cached object URL if available, null otherwise.
222
+ */
223
+ export function getCachedBlobObjectURL(blobId: string): string | null {
224
+ if (!blobId) return null;
225
+ return BLOB_URL_CACHE.get(blobId) ?? null;
226
+ }
227
+
228
+ /**
229
+ * clear all cached blob URLs and revoke object URLs
230
+ */
231
+ export function clearBlobUrlCache(): void {
232
+ BLOB_URL_CACHE.forEach((url) => URL.revokeObjectURL(url));
233
+ BLOB_URL_CACHE.clear();
234
+ }
235
+
236
+ // revoke object URLs on page unload to avoid memory leaks
237
+ if (typeof window !== "undefined") {
238
+ window.addEventListener("beforeunload", () => {
239
+ clearBlobUrlCache();
240
+ });
241
+ }
242
+
243
+ /**
244
+ * delete blob by ID from both storage and metadata db
245
+ */
246
+ export async function deleteBlob(blobId: string): Promise<void> {
247
+ const metadata = await getBlobMetadata(blobId);
248
+ if (!metadata) return;
249
+
250
+ // delete from storage
251
+ if (metadata.storage_type === "opfs") {
252
+ try {
253
+ const root = await navigator.storage.getDirectory();
254
+ const blobsDir = await root.getDirectoryHandle("blobs");
255
+ const fileName = metadata.storage_path.split("/").pop()!;
256
+ await blobsDir.removeEntry(fileName);
257
+ } catch {
258
+ // ignore errors during cleanup
259
+ }
260
+ } else {
261
+ try {
262
+ const cache = await caches.open(CACHE_NAME);
263
+ await cache.delete(metadata.storage_path);
264
+ } catch {
265
+ // ignore errors during cleanup
266
+ }
267
+ }
268
+
269
+ // delete metadata
270
+ const db = await initBlobDB();
271
+ await db.delete(STORE_BLOBS, blobId);
272
+ }
@@ -0,0 +1,190 @@
1
+ // p2p identity resolution with read-only access to spume's freqhole_app IndexedDB.
2
+ //
3
+ // policy:
4
+ // - never create or upgrade the "freqhole_app" database
5
+ // - if freqhole_app exists and has an "app_state" store, use it as the primary store
6
+ // - otherwise, use the injected IdentityStore (local, per-app)
7
+ // - when both have an identity, prefer spume's db
8
+
9
+ const FREQHOLE_APP_DB = "freqhole_app";
10
+ const APP_STATE_STORE = "app_state";
11
+ const IDENTITY_KEY = "p2p_identity";
12
+
13
+ // snake_case fields are a wire/storage compatibility contract with spume
14
+ export interface P2PIdentity {
15
+ id: "p2p_identity";
16
+ secret_key: Uint8Array;
17
+ node_id: string;
18
+ created_at: number;
19
+ }
20
+
21
+ /** per-app fallback store - apps supply their own settings persistence */
22
+ export interface IdentityStore {
23
+ get(): Promise<P2PIdentity | null>;
24
+ set(identity: P2PIdentity): Promise<void>;
25
+ }
26
+
27
+ /**
28
+ * check whether freqhole_app exists without creating it.
29
+ *
30
+ * strategy:
31
+ * 1. use indexedDB.databases() when available (Chrome/Firefox 126+)
32
+ * 2. fall back to a versionless open; abort the versionchange transaction
33
+ * if upgrade fires (meaning the db did not exist), then close and delete.
34
+ */
35
+ async function freqholeAppExists(): Promise<boolean> {
36
+ // prefer the non-destructive databases() API
37
+ if (typeof indexedDB.databases === "function") {
38
+ try {
39
+ const dbs = await indexedDB.databases();
40
+ return dbs.some((d) => d.name === FREQHOLE_APP_DB);
41
+ } catch {
42
+ // fall through to open-based detection
43
+ }
44
+ }
45
+
46
+ // open-based detection: if upgradeneeded fires, the db did not exist -
47
+ // abort immediately so IDB never persists it
48
+ return new Promise<boolean>((resolve) => {
49
+ const req = indexedDB.open(FREQHOLE_APP_DB);
50
+ let existed = true;
51
+
52
+ req.onupgradeneeded = (event) => {
53
+ // upgradeneeded on a version-0 open means the db is being created now
54
+ existed = false;
55
+ // abort the versionchange transaction to prevent creation
56
+ (event.target as IDBOpenDBRequest).transaction?.abort();
57
+ };
58
+
59
+ req.onsuccess = () => {
60
+ req.result.close();
61
+ if (!existed) {
62
+ // clean up the empty database that was created before we could abort
63
+ indexedDB.deleteDatabase(FREQHOLE_APP_DB);
64
+ }
65
+ resolve(existed);
66
+ };
67
+
68
+ req.onerror = () => {
69
+ // the abort above causes an error - if existed is false this is expected
70
+ resolve(existed);
71
+ };
72
+ });
73
+ }
74
+
75
+ /**
76
+ * check whether freqhole_app has the app_state object store.
77
+ * caller must have already confirmed the db exists.
78
+ */
79
+ async function hasAppStateStore(): Promise<boolean> {
80
+ return new Promise<boolean>((resolve) => {
81
+ const req = indexedDB.open(FREQHOLE_APP_DB);
82
+
83
+ req.onupgradeneeded = () => {
84
+ // should not fire here since we know the db exists - if it does, abort
85
+ req.transaction?.abort();
86
+ resolve(false);
87
+ };
88
+
89
+ req.onsuccess = () => {
90
+ const db = req.result;
91
+ const has = db.objectStoreNames.contains(APP_STATE_STORE);
92
+ db.close();
93
+ resolve(has);
94
+ };
95
+
96
+ req.onerror = () => resolve(false);
97
+ });
98
+ }
99
+
100
+ /** read p2p_identity from spume's app_state store */
101
+ function readFromFreqholeApp(): Promise<P2PIdentity | null> {
102
+ return new Promise<P2PIdentity | null>((resolve) => {
103
+ const req = indexedDB.open(FREQHOLE_APP_DB);
104
+
105
+ req.onupgradeneeded = () => {
106
+ req.transaction?.abort();
107
+ resolve(null);
108
+ };
109
+
110
+ req.onsuccess = () => {
111
+ const db = req.result;
112
+ const tx = db.transaction(APP_STATE_STORE, "readonly");
113
+ const getReq = tx.objectStore(APP_STATE_STORE).get(IDENTITY_KEY);
114
+
115
+ getReq.onsuccess = () => {
116
+ db.close();
117
+ resolve((getReq.result as P2PIdentity) ?? null);
118
+ };
119
+ getReq.onerror = () => {
120
+ db.close();
121
+ resolve(null);
122
+ };
123
+ };
124
+
125
+ req.onerror = () => resolve(null);
126
+ });
127
+ }
128
+
129
+ /** write p2p_identity to spume's app_state store (no version bump) */
130
+ function writeToFreqholeApp(identity: P2PIdentity): Promise<void> {
131
+ return new Promise<void>((resolve, reject) => {
132
+ const req = indexedDB.open(FREQHOLE_APP_DB);
133
+
134
+ req.onupgradeneeded = () => {
135
+ req.transaction?.abort();
136
+ reject(new Error("freqhole_app unexpectedly absent during write"));
137
+ };
138
+
139
+ req.onsuccess = () => {
140
+ const db = req.result;
141
+ const tx = db.transaction(APP_STATE_STORE, "readwrite");
142
+ const putReq = tx.objectStore(APP_STATE_STORE).put(identity, IDENTITY_KEY);
143
+
144
+ putReq.onsuccess = () => {
145
+ db.close();
146
+ resolve();
147
+ };
148
+ putReq.onerror = () => {
149
+ db.close();
150
+ reject(putReq.error);
151
+ };
152
+ };
153
+
154
+ req.onerror = () => reject(req.error);
155
+ });
156
+ }
157
+
158
+ /**
159
+ * resolve the p2p identity for this origin.
160
+ * prefers spume's db when both sources have an identity.
161
+ */
162
+ export async function resolveIdentity(
163
+ local: IdentityStore,
164
+ ): Promise<P2PIdentity | null> {
165
+ const exists = await freqholeAppExists();
166
+ if (exists && (await hasAppStateStore())) {
167
+ const spumeIdentity = await readFromFreqholeApp();
168
+ if (spumeIdentity) return spumeIdentity;
169
+ // spume db exists but has no identity - fall through to local
170
+ }
171
+
172
+ return local.get();
173
+ }
174
+
175
+ /**
176
+ * persist a p2p identity.
177
+ * writes to spume's db if it exists and has app_state; otherwise uses local.
178
+ */
179
+ export async function persistIdentity(
180
+ identity: P2PIdentity,
181
+ local: IdentityStore,
182
+ ): Promise<void> {
183
+ const exists = await freqholeAppExists();
184
+ if (exists && (await hasAppStateStore())) {
185
+ await writeToFreqholeApp(identity);
186
+ return;
187
+ }
188
+
189
+ await local.set(identity);
190
+ }
@@ -0,0 +1,5 @@
1
+ // shared browser storage layer - re-exports all storage submodules
2
+
3
+ export * from "./blobs.js";
4
+ export * from "./identity.js";
5
+ export * from "./webLocks.js";