@lapis-notes/api 0.1.3 → 0.1.4

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/dist/sync-host.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { TFile } from "./storage/fs";
2
2
  import { sha256 } from "@noble/hashes/sha2.js";
3
3
  import { bytesToHex } from "@noble/hashes/utils.js";
4
+ import { ScopedSyncLocalRuntime } from "./sync-host-local";
4
5
  import { SyncHostSecurityService, } from "./sync-host-security";
5
6
  const ROOT_PREFIX = "sync-host:v1:";
6
7
  const DATABASE_GENERATION_KEY = `${ROOT_PREFIX}database-generation`;
@@ -71,19 +72,39 @@ export class SyncHostService {
71
72
  database;
72
73
  vault;
73
74
  clock;
74
- options;
75
75
  #ready;
76
76
  #eventQueue = Promise.resolve();
77
77
  #runtimeIds = new Map();
78
78
  #origins = new Map();
79
79
  #security;
80
+ #selectSecurityProvider;
81
+ #securityProviders = new Map();
82
+ #delegatedRuntimes = new Map();
83
+ #localRuntimes = new Map();
84
+ #selectLocalProvider;
85
+ #guardLocalApply;
86
+ #securityCleanup = new Set();
87
+ #stopObserving;
88
+ #disposed = false;
89
+ #disposal;
90
+ options;
80
91
  constructor(database, vault, clock = Date.now, options = {}) {
81
92
  this.database = database;
82
93
  this.vault = vault;
83
94
  this.clock = clock;
84
- this.options = options;
85
- this.#security = new SyncHostSecurityService(database, (pluginId, runtimeId) => this.#assertRuntime(pluginId, runtimeId), options.security);
86
- this.vault.on("all", (event, file, context) => {
95
+ const { securityStore, securityProvider, localProvider, guardLocalApply, ...retainedOptions } = options;
96
+ this.options =
97
+ securityStore === undefined &&
98
+ securityProvider === undefined &&
99
+ localProvider === undefined &&
100
+ guardLocalApply === undefined
101
+ ? options
102
+ : retainedOptions;
103
+ this.#selectSecurityProvider = securityProvider;
104
+ this.#selectLocalProvider = localProvider;
105
+ this.#guardLocalApply = guardLocalApply;
106
+ this.#security = new SyncHostSecurityService(securityStore === undefined ? database : securityStore, (pluginId, runtimeId) => this.#assertRuntime(pluginId, runtimeId), options.security);
107
+ const eventRef = this.vault.on("all", (event, file, context) => {
87
108
  if (!(file instanceof TFile) || !this.#isChangeKind(event))
88
109
  return;
89
110
  const previousPath = typeof context.oldPath === "string" ? context.oldPath : undefined;
@@ -96,6 +117,7 @@ export class SyncHostService {
96
117
  })
97
118
  .catch(() => undefined);
98
119
  });
120
+ this.#stopObserving = () => this.vault.offref(eventRef);
99
121
  this.#ready = this.#initialize();
100
122
  }
101
123
  async ready() {
@@ -103,6 +125,8 @@ export class SyncHostService {
103
125
  await this.#eventQueue;
104
126
  }
105
127
  forPlugin(pluginId, grantedCapabilities) {
128
+ if (this.#disposed)
129
+ throw new Error("Sync host is disposed");
106
130
  const normalizedPluginId = requireBoundedString(pluginId, "Plugin ID", 128);
107
131
  const runtimeId = randomId();
108
132
  const runtimeIds = this.#runtimeIds.get(normalizedPluginId) ?? new Set();
@@ -116,7 +140,7 @@ export class SyncHostService {
116
140
  throw new Error(`Plugin ${normalizedPluginId} is not granted ${capability}`);
117
141
  }
118
142
  };
119
- return {
143
+ const capabilities = {
120
144
  files: {
121
145
  getIdentity: async (path) => {
122
146
  assertGranted("metadata:identity");
@@ -304,32 +328,154 @@ export class SyncHostService {
304
328
  }),
305
329
  generatedState: this.#generatedState(normalizedPluginId, runtimeId, () => assertGranted("plugin:generated-state")),
306
330
  };
331
+ if (this.#selectLocalProvider) {
332
+ const local = new ScopedSyncLocalRuntime({
333
+ runtimeId,
334
+ assertGranted,
335
+ selectProvider: () => this.#selectLocalProvider(normalizedPluginId),
336
+ guardApply: this.#guardLocalApply,
337
+ });
338
+ this.#localRuntimes.set(runtimeId, local);
339
+ Object.assign(capabilities, { local: local.capabilities });
340
+ }
341
+ let delegated;
342
+ let selected = false;
343
+ for (const group of [
344
+ "crypto",
345
+ "syncIdentity",
346
+ "pairing",
347
+ "membership",
348
+ "recovery",
349
+ ]) {
350
+ const local = capabilities[group];
351
+ const guarded = {};
352
+ for (const method of Object.keys(local)) {
353
+ guarded[method] = async (...args) => {
354
+ assertGranted("crypto:nostr-sync");
355
+ if (!selected) {
356
+ const provider = this.#securityProvider(normalizedPluginId);
357
+ if (provider) {
358
+ this.#delegatedRuntimes.set(runtimeId, provider);
359
+ delegated = provider.forRuntime(runtimeId);
360
+ if (!delegated || typeof delegated !== "object")
361
+ throw new Error("Invalid Sync security provider capabilities");
362
+ }
363
+ selected = true;
364
+ }
365
+ assertGranted("crypto:nostr-sync");
366
+ const target = delegated ? delegated[group] : local;
367
+ const operation = target && target[method];
368
+ if (typeof operation !== "function")
369
+ throw new Error(`Invalid Sync security provider operation: ${group}.${method}`);
370
+ const result = await operation.apply(target, args);
371
+ assertGranted("crypto:nostr-sync");
372
+ return result;
373
+ };
374
+ }
375
+ Object.assign(capabilities, { [group]: guarded });
376
+ }
377
+ return capabilities;
307
378
  }
308
379
  revokePlugin(pluginId) {
380
+ const runtimes = this.#runtimeIds.get(pluginId);
309
381
  this.#runtimeIds.delete(pluginId);
310
382
  for (const [token, owner] of this.#origins) {
311
383
  if (owner.pluginId === pluginId)
312
384
  this.#origins.delete(token);
313
385
  }
314
386
  this.#security.revokePlugin(pluginId);
387
+ const cleanups = [];
388
+ for (const runtimeId of runtimes ?? []) {
389
+ const provider = this.#delegatedRuntimes.get(runtimeId);
390
+ this.#delegatedRuntimes.delete(runtimeId);
391
+ const local = this.#localRuntimes.get(runtimeId);
392
+ this.#localRuntimes.delete(runtimeId);
393
+ if (!provider && !local)
394
+ continue;
395
+ let cleanup;
396
+ try {
397
+ // Local streams/operations must finish while native crypto custody still exists.
398
+ const localCleanup = local?.revoke();
399
+ cleanup = localCleanup
400
+ ? (async () => {
401
+ const result = await Promise.allSettled([localCleanup]);
402
+ await provider?.revokeRuntime(runtimeId);
403
+ if (result[0]?.status === "rejected")
404
+ throw result[0].reason;
405
+ })()
406
+ : Promise.resolve(provider.revokeRuntime(runtimeId));
407
+ }
408
+ catch (error) {
409
+ cleanup = Promise.reject(error);
410
+ }
411
+ this.#securityCleanup.add(cleanup);
412
+ cleanups.push(cleanup);
413
+ }
414
+ return this.#drainSecurity(cleanups);
415
+ }
416
+ /** Invalidate first, then wait for every admitted host cleanup and file event. */
417
+ dispose() {
418
+ if (this.#disposal)
419
+ return this.#disposal;
420
+ this.#disposed = true;
421
+ this.#stopObserving();
422
+ const revocations = [...this.#runtimeIds.keys()].map((pluginId) => this.revokePlugin(pluginId));
423
+ this.#disposal = this.#drainSecurity([
424
+ ...revocations,
425
+ ...this.#securityCleanup,
426
+ this.ready(),
427
+ ]);
428
+ return this.#disposal;
429
+ }
430
+ async #drainSecurity(cleanups) {
431
+ const results = await Promise.allSettled(cleanups);
432
+ for (const cleanup of cleanups)
433
+ this.#securityCleanup.delete(cleanup);
434
+ const failed = results.find((result) => result.status === "rejected");
435
+ if (failed?.status === "rejected")
436
+ throw failed.reason;
437
+ }
438
+ #securityProvider(pluginId) {
439
+ if (!this.#securityProviders.has(pluginId)) {
440
+ const provider = this.#selectSecurityProvider?.(pluginId);
441
+ if (provider !== undefined &&
442
+ (!provider ||
443
+ typeof provider.forRuntime !== "function" ||
444
+ typeof provider.revokeRuntime !== "function"))
445
+ throw new Error("Invalid Sync security provider");
446
+ this.#securityProviders.set(pluginId, provider);
447
+ }
448
+ return this.#securityProviders.get(pluginId);
449
+ }
450
+ #assertLocalSecurity(pluginId) {
451
+ requireBoundedString(pluginId, "Plugin ID", 128);
452
+ if (this.#disposed)
453
+ throw new Error("Sync host is disposed");
454
+ if (this.#securityProvider(pluginId))
455
+ throw new Error(`Sync security for ${pluginId} is delegated to its host provider`);
315
456
  }
316
457
  /** Host-controlled enrollment; this operation is intentionally absent from plugin facades. */
317
- enrollPluginCrypto(pluginId, passphrase, request) {
458
+ async enrollPluginCrypto(pluginId, passphrase, request) {
459
+ this.#assertLocalSecurity(pluginId);
318
460
  return this.#security.enroll(requireBoundedString(pluginId, "Plugin ID", 128), passphrase, request);
319
461
  }
320
462
  /** Host-controlled unlock; plugins can only observe locked state. */
321
- unlockPluginCrypto(pluginId, passphrase) {
463
+ async unlockPluginCrypto(pluginId, passphrase) {
464
+ this.#assertLocalSecurity(pluginId);
322
465
  return this.#security.unlock(requireBoundedString(pluginId, "Plugin ID", 128), passphrase);
323
466
  }
324
467
  /** Host-controlled passkey or OS-credential enrollment. */
325
- enrollPluginCryptoWithWrappingKey(pluginId, wrappingKey, descriptor, request) {
468
+ async enrollPluginCryptoWithWrappingKey(pluginId, wrappingKey, descriptor, request) {
469
+ this.#assertLocalSecurity(pluginId);
326
470
  return this.#security.enrollWithWrappingKey(requireBoundedString(pluginId, "Plugin ID", 128), wrappingKey, descriptor, request);
327
471
  }
328
472
  /** Host-controlled passkey or OS-credential unlock. */
329
- unlockPluginCryptoWithWrappingKey(pluginId, wrappingKey) {
473
+ async unlockPluginCryptoWithWrappingKey(pluginId, wrappingKey) {
474
+ this.#assertLocalSecurity(pluginId);
330
475
  return this.#security.unlockWithWrappingKey(requireBoundedString(pluginId, "Plugin ID", 128), wrappingKey);
331
476
  }
332
477
  lockPluginCrypto(pluginId) {
478
+ this.#assertLocalSecurity(pluginId);
333
479
  this.#security.lock(requireBoundedString(pluginId, "Plugin ID", 128));
334
480
  }
335
481
  grantPluginNetworkOrigin(pluginId, origin, options) {
@@ -0,0 +1,106 @@
1
+ /** Durable opaque sync data. Hosts keep this separate from disposable AppDatabase indexes. */
2
+ export interface SealedSyncState {
3
+ version: number;
4
+ epoch: number;
5
+ ciphertext: Uint8Array;
6
+ }
7
+ export interface SyncRepository {
8
+ readonly blobs: {
9
+ has(hash: string): Promise<boolean>;
10
+ put(ciphertext: Uint8Array): Promise<{
11
+ hash: string;
12
+ size: number;
13
+ }>;
14
+ get(hash: string): Promise<Uint8Array>;
15
+ };
16
+ readState(): Promise<SealedSyncState | null>;
17
+ compareAndSwap(expectedVersion: number, next: SealedSyncState, authority?: SyncOperationAuthority): Promise<boolean>;
18
+ }
19
+ /** Expected endpoint authority for a guarded host operation, outside encrypted protocol data. */
20
+ export interface SyncOperationAuthority {
21
+ vaultId: string;
22
+ devicePublicKey: string;
23
+ epoch: number;
24
+ membershipGeneration: number;
25
+ }
26
+ /** Immutable sealed records addressed by opaque 64-character lowercase hex keys. */
27
+ export interface SealedSyncRecord {
28
+ epoch: number;
29
+ ciphertext: Uint8Array;
30
+ }
31
+ /** Optional repository extension for independently encrypted, indexed local records. */
32
+ export interface SyncIndexedRepository extends SyncRepository {
33
+ readonly records: {
34
+ get(key: string): Promise<SealedSyncRecord | null>;
35
+ /** False preserves the existing record unchanged, including concurrent insertion. */
36
+ putIfAbsent(key: string, record: SealedSyncRecord): Promise<boolean>;
37
+ };
38
+ }
39
+ /** Captured by the owning host; a missing file is represented by null. */
40
+ export interface SyncFileGeneration {
41
+ fileId: string;
42
+ contentGeneration: number;
43
+ hostFileId?: string;
44
+ }
45
+ export interface SyncFileMutation {
46
+ id: string;
47
+ kind: "write" | "remove";
48
+ path: string;
49
+ expected: SyncFileGeneration | null;
50
+ fileId: string;
51
+ }
52
+ /** Capture only protocol-neutral mutation data; file handles may carry read functions. */
53
+ export declare function snapshotSyncFileMutation(input: SyncFileMutation): SyncFileMutation;
54
+ export interface SyncFileMutationReceipt {
55
+ operation: SyncFileMutation;
56
+ result: SyncFileGeneration | null;
57
+ }
58
+ /** The host journals immutable operation IDs and verifies guards at mutation. */
59
+ export interface SyncFileMutationAccess {
60
+ receipt(operationId: string, authority?: SyncOperationAuthority): Promise<SyncFileMutationReceipt | null>;
61
+ mutate(operation: SyncFileMutation, source?: ReadableStream<Uint8Array>, authority?: SyncOperationAuthority): Promise<SyncFileMutationReceipt>;
62
+ }
63
+ export interface SyncFileDescriptor extends SyncFileGeneration {
64
+ path: string;
65
+ }
66
+ export interface SyncGuardedFile extends SyncFileDescriptor {
67
+ openRead(): Promise<ReadableStream<Uint8Array>>;
68
+ }
69
+ /** Captured working files and durable encrypted state owned by the current host. */
70
+ export interface SyncLocalHostCapabilities {
71
+ list(): Promise<SyncGuardedFile[]>;
72
+ readonly repository: SyncRepository;
73
+ readonly apply: SyncFileMutationAccess;
74
+ }
75
+ /** Trusted host adapter. Neither this controller nor storage paths enter plugin facades. */
76
+ export interface SyncHostLocalProvider {
77
+ forRuntime(runtimeId: string): SyncLocalHostCapabilities;
78
+ revokeRuntime(runtimeId: string): void | Promise<void>;
79
+ }
80
+ /** The owning host protects editor state for the entire callback, including recovery. */
81
+ export type SyncLocalApplyGuard = <T>(path: string | undefined, operation: () => Promise<T>) => Promise<T>;
82
+ /** Minimal opaque crypto boundary; implementations retain all secret material. */
83
+ export interface SyncVaultStateCrypto {
84
+ status(): Promise<{
85
+ state: "unenrolled" | "locked" | "unlocked";
86
+ suite: "lapis-sync-v1-xchacha20poly1305-schnorr";
87
+ vaultId?: string;
88
+ devicePublicKey?: string;
89
+ deviceState?: "active" | "revoked" | "recovery-pending";
90
+ currentEpoch?: number;
91
+ membershipGeneration?: number;
92
+ }>;
93
+ encryptObject(input: {
94
+ epoch: number;
95
+ type: "local-state";
96
+ plaintext: Uint8Array;
97
+ }): Promise<{
98
+ bytes: Uint8Array;
99
+ objectHash: string;
100
+ }>;
101
+ decryptObject(input: {
102
+ epoch: number;
103
+ type: "local-state";
104
+ envelope: Uint8Array;
105
+ }): Promise<Uint8Array>;
106
+ }
@@ -0,0 +1,18 @@
1
+ /** Capture only protocol-neutral mutation data; file handles may carry read functions. */
2
+ export function snapshotSyncFileMutation(input) {
3
+ return {
4
+ id: input.id,
5
+ kind: input.kind,
6
+ path: input.path,
7
+ fileId: input.fileId,
8
+ expected: input.expected === null
9
+ ? null
10
+ : {
11
+ fileId: input.expected.fileId,
12
+ contentGeneration: input.expected.contentGeneration,
13
+ ...(input.expected.hostFileId !== undefined
14
+ ? { hostFileId: input.expected.hostFileId }
15
+ : {}),
16
+ },
17
+ };
18
+ }
@@ -1,5 +1,4 @@
1
1
  /* eslint-disable @typescript-eslint/no-unused-vars */
2
- import { DateTime } from "luxon";
3
2
  import { Editor } from "./editor.svelte";
4
3
  import { mount, unmount } from "svelte";
5
4
  import EmptyViewComponent from "./components/empty-view/empty-view.svelte";
@@ -330,22 +329,34 @@ export class TextFileView extends FileView {
330
329
  return this.app.vault.adapter;
331
330
  }
332
331
  async onLoadFile(file) {
333
- return this.app.telemetry.measureAsync("view.text_file.on_load_file", async (span) => {
332
+ const application = this.app;
333
+ return application.telemetry.measureAsync("view.text_file.on_load_file", async (span) => {
334
334
  span.setAttribute("view.type", this.getViewType());
335
335
  span.setAttribute("file.extension", file.extension);
336
- const contents = await this.app.telemetry.measureAsync("vault.read", async () => this.app.vault.read(file), {
336
+ const published = await this.editor.loadDocument(file, () => application.telemetry.measureAsync("vault.read", async () => application.vault.read(file), {
337
337
  attributes: {
338
338
  "file.extension": file.extension,
339
339
  "view.type": this.getViewType(),
340
340
  },
341
341
  slowThresholdMs: 100,
342
+ }), (contents) => {
343
+ if (this.app !== application || this.editor.app !== application)
344
+ throw new Error("Editor document load authority changed");
345
+ const previousFile = this.file, previousData = this.data;
346
+ try {
347
+ this.file = file.copy();
348
+ this.editor.extensions = application.editorExtensions(file.extension, { ...this.getState(), file: file.path });
349
+ this.data = contents;
350
+ this.setViewData(contents, true);
351
+ }
352
+ catch (error) {
353
+ this.file = previousFile;
354
+ this.data = previousData;
355
+ throw error;
356
+ }
342
357
  });
343
- this.file = file.copy();
344
- this.editor.file = file;
345
- await this.setState({ ...this.getState(), file: file.path });
346
- this.editor.extensions = this.app.editorExtensions(this.file.extension, this.getState());
347
- this.data = contents;
348
- this.setViewData(contents, true);
358
+ if (published && this.editor.file === file)
359
+ await this.setState({ ...this.getState(), file: file.path });
349
360
  }, {
350
361
  attributes: {
351
362
  "view.type": this.getViewType(),
@@ -364,18 +375,16 @@ export class TextFileView extends FileView {
364
375
  return Promise.resolve();
365
376
  }
366
377
  onUnloadFile(file) {
378
+ this.editor.cancelDocumentLoad();
367
379
  this.setState({ ...this.getState(), file: "" });
368
380
  return Promise.resolve();
369
381
  }
370
- save(clear) {
371
- return this.app.vault
372
- .modify(this.file, this.data, {
373
- mtime: DateTime.now().toMillis(),
374
- })
375
- .then(() => {
376
- if (clear) {
377
- this.clear();
378
- }
379
- });
382
+ async save(clear) {
383
+ const target = this.editor.file;
384
+ this.editor.save();
385
+ await this.editor.flushChanges();
386
+ this.data = this.editor.data;
387
+ if (clear && this.editor.file === target)
388
+ this.clear();
380
389
  }
381
390
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lapis-notes/api",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Lapis Notes API",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -224,6 +224,21 @@
224
224
  "types": "./dist/build-tools/vite.d.ts",
225
225
  "import": "./dist/build-tools/vite.js",
226
226
  "default": "./dist/build-tools/vite.js"
227
+ },
228
+ "./sync-repository": {
229
+ "types": "./dist/sync-repository.d.ts",
230
+ "import": "./dist/sync-repository.js",
231
+ "default": "./dist/sync-repository.js"
232
+ },
233
+ "./sync-security-host": {
234
+ "types": "./dist/sync-host-security.d.ts",
235
+ "import": "./dist/sync-host-security.js",
236
+ "default": "./dist/sync-host-security.js"
237
+ },
238
+ "./metadata-store": {
239
+ "types": "./dist/metadata-store.d.ts",
240
+ "import": "./dist/metadata-store.js",
241
+ "default": "./dist/metadata-store.js"
227
242
  }
228
243
  },
229
244
  "devDependencies": {
@@ -266,6 +281,7 @@
266
281
  "check": "pnpm --dir ../.. check:no-tailwind packages/api/src/lib/components && pnpm check:svelte && pnpm check:types",
267
282
  "check:svelte": "svelte-check --fail-on-warnings --output human",
268
283
  "check:types": "tsc --noEmit -p tsconfig.types.json",
284
+ "check:pack-types": "tsc -p tsconfig.contracts.json",
269
285
  "dev": "pnpm --dir ../.. storybook",
270
286
  "storybook": "pnpm --dir ../.. storybook",
271
287
  "test": "vitest run",