@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.
@@ -0,0 +1,228 @@
1
+ import { snapshotSyncFileMutation } from "./sync-repository";
2
+ const FILE_GRANTS = [
3
+ "crypto:nostr-sync",
4
+ "vault:stream",
5
+ "metadata:identity",
6
+ ];
7
+ const STORE_GRANTS = ["crypto:nostr-sync", "plugin:generated-state"];
8
+ /** Grant-checked lifetime boundary; providers retain all platform-specific authority. */
9
+ export class ScopedSyncLocalRuntime {
10
+ options;
11
+ #active = true;
12
+ #provider;
13
+ #capabilities;
14
+ #selectionError;
15
+ #selected = false;
16
+ #closing;
17
+ #flights = new Set();
18
+ #streams = new Set();
19
+ constructor(options) {
20
+ this.options = options;
21
+ }
22
+ capabilities = {
23
+ list: () => this.#invoke(FILE_GRANTS, async (local) => {
24
+ const files = await local.list();
25
+ return files.map((file) => ({
26
+ path: file.path,
27
+ fileId: file.fileId,
28
+ hostFileId: file.hostFileId,
29
+ contentGeneration: file.contentGeneration,
30
+ openRead: () => this.#invoke(FILE_GRANTS, async () => (await this.#stream(await file.openRead(), FILE_GRANTS)).stream),
31
+ }));
32
+ }),
33
+ repository: {
34
+ readState: () => this.#invoke(STORE_GRANTS, (local) => local.repository.readState()),
35
+ compareAndSwap: (expectedVersion, next, authority) => {
36
+ const captured = structuredClone(next);
37
+ const fence = authority ? structuredClone(authority) : undefined;
38
+ return this.#invoke(STORE_GRANTS, (local) => fence
39
+ ? local.repository.compareAndSwap(expectedVersion, captured, fence)
40
+ : local.repository.compareAndSwap(expectedVersion, captured));
41
+ },
42
+ blobs: {
43
+ has: (hash) => this.#invoke(STORE_GRANTS, (local) => local.repository.blobs.has(hash)),
44
+ get: (hash) => this.#invoke(STORE_GRANTS, (local) => local.repository.blobs.get(hash)),
45
+ put: (bytes) => {
46
+ const captured = Uint8Array.from(bytes);
47
+ return this.#invoke(STORE_GRANTS, (local) => local.repository.blobs.put(captured));
48
+ },
49
+ },
50
+ },
51
+ apply: {
52
+ receipt: (id, authority) => {
53
+ const fence = authority ? structuredClone(authority) : undefined;
54
+ return this.#apply(undefined, (local) => fence ? local.apply.receipt(id, fence) : local.apply.receipt(id));
55
+ },
56
+ mutate: (operation, source, authority) => {
57
+ return this.#track((async () => {
58
+ let guarded;
59
+ try {
60
+ const captured = snapshotSyncFileMutation(operation);
61
+ const fence = authority ? structuredClone(authority) : undefined;
62
+ guarded = source
63
+ ? await this.#stream(source, FILE_GRANTS)
64
+ : undefined;
65
+ return await this.#apply(captured.path, async (local) => {
66
+ try {
67
+ this.#assert([...FILE_GRANTS, "plugin:generated-state"]);
68
+ return await (fence
69
+ ? local.apply.mutate(captured, guarded?.stream, fence)
70
+ : local.apply.mutate(captured, guarded?.stream));
71
+ }
72
+ finally {
73
+ await guarded?.close();
74
+ }
75
+ });
76
+ }
77
+ finally {
78
+ if (guarded)
79
+ await guarded.close();
80
+ else if (source && !source.locked)
81
+ await source.cancel();
82
+ }
83
+ })());
84
+ },
85
+ },
86
+ };
87
+ #assert(grants) {
88
+ if (!this.#active)
89
+ throw new Error("Local Sync runtime is revoked");
90
+ for (const grant of grants)
91
+ this.options.assertGranted(grant);
92
+ }
93
+ #local() {
94
+ if (!this.#selected) {
95
+ this.#selected = true;
96
+ try {
97
+ const provider = this.options.selectProvider();
98
+ if (!provider)
99
+ throw new Error("Local Sync host capability is unavailable");
100
+ if (typeof provider.forRuntime !== "function" ||
101
+ typeof provider.revokeRuntime !== "function")
102
+ throw new Error("Invalid local Sync provider");
103
+ this.#provider = provider;
104
+ this.#capabilities = provider.forRuntime(this.options.runtimeId);
105
+ if (!this.#capabilities || typeof this.#capabilities !== "object")
106
+ throw new Error("Invalid local Sync provider capabilities");
107
+ }
108
+ catch (error) {
109
+ this.#selectionError =
110
+ error instanceof Error
111
+ ? error
112
+ : new Error("Local Sync provider selection failed");
113
+ }
114
+ }
115
+ if (this.#selectionError)
116
+ throw this.#selectionError;
117
+ return this.#capabilities;
118
+ }
119
+ #invoke(grants, operation) {
120
+ const flight = (async () => {
121
+ this.#assert(grants);
122
+ const result = await operation(this.#local());
123
+ this.#assert(grants);
124
+ return result;
125
+ })();
126
+ return this.#track(flight);
127
+ }
128
+ #track(flight) {
129
+ this.#flights.add(flight);
130
+ void flight.then(() => this.#flights.delete(flight), () => this.#flights.delete(flight));
131
+ return flight;
132
+ }
133
+ async #apply(path, operation) {
134
+ this.#assert([...FILE_GRANTS, "plugin:generated-state"]);
135
+ const guard = this.options.guardApply;
136
+ if (!guard)
137
+ throw new Error("Local Sync host apply guard is unavailable");
138
+ return this.#invoke([...FILE_GRANTS, "plugin:generated-state"], async (local) => {
139
+ return guard(path, async () => {
140
+ this.#assert([...FILE_GRANTS, "plugin:generated-state"]);
141
+ return operation(local);
142
+ });
143
+ });
144
+ }
145
+ async #stream(source, grants) {
146
+ try {
147
+ this.#assert(grants);
148
+ }
149
+ catch (error) {
150
+ await source.cancel(error);
151
+ throw error;
152
+ }
153
+ const reader = source.getReader();
154
+ let terminal = false;
155
+ let stopping;
156
+ let controller;
157
+ const release = () => {
158
+ this.#streams.delete(stop);
159
+ reader.releaseLock();
160
+ };
161
+ const cancel = (reason) => {
162
+ if (stopping)
163
+ return stopping;
164
+ if (terminal)
165
+ return Promise.resolve();
166
+ terminal = true;
167
+ stopping = reader.cancel(reason).finally(release);
168
+ return stopping;
169
+ };
170
+ const stop = (reason = new Error("Local Sync runtime is revoked")) => {
171
+ if (!terminal)
172
+ controller.error(reason);
173
+ return cancel(reason);
174
+ };
175
+ this.#streams.add(stop);
176
+ const stream = new ReadableStream({
177
+ start(value) {
178
+ controller = value;
179
+ },
180
+ pull: async () => {
181
+ if (terminal)
182
+ return;
183
+ try {
184
+ this.#assert(grants);
185
+ const result = await reader.read();
186
+ this.#assert(grants);
187
+ if (terminal)
188
+ return;
189
+ if (result.done) {
190
+ terminal = true;
191
+ release();
192
+ controller.close();
193
+ }
194
+ else {
195
+ if (!(result.value instanceof Uint8Array))
196
+ throw new Error("Invalid local Sync stream chunk");
197
+ controller.enqueue(result.value);
198
+ }
199
+ }
200
+ catch (error) {
201
+ if (!terminal)
202
+ controller.error(error);
203
+ await cancel(error);
204
+ }
205
+ },
206
+ cancel,
207
+ }, { highWaterMark: 0 });
208
+ return {
209
+ stream,
210
+ close: () => stop(new Error("Local Sync operation has completed")),
211
+ };
212
+ }
213
+ revoke() {
214
+ if (this.#closing)
215
+ return this.#closing;
216
+ this.#active = false;
217
+ this.#closing = (async () => {
218
+ const streams = await Promise.allSettled([...this.#streams].map((stop) => stop()));
219
+ await Promise.allSettled([...this.#flights]);
220
+ // Even failed cancellation must release the platform provider.
221
+ await this.#provider?.revokeRuntime(this.options.runtimeId);
222
+ const failure = streams.find((result) => result.status === "rejected");
223
+ if (failure?.status === "rejected")
224
+ throw failure.reason;
225
+ })();
226
+ return this.#closing;
227
+ }
228
+ }
@@ -1,6 +1,8 @@
1
1
  import { type Event as NostrEvent } from "nostr-tools/pure";
2
- import type { AppDatabase } from "./storage/app-database";
3
- export type SyncCryptoObjectType = "file-content" | "file-manifest" | "tree-manifest" | "revision-payload" | "revision-archive" | "head-payload" | "membership-payload";
2
+ import type { HostMetadataStore } from "./metadata-store";
3
+ /** Host-owned persistence for wrapped keys and network grants, outside disposable indexes. */
4
+ export type SyncSecurityStore = HostMetadataStore;
5
+ export type SyncCryptoObjectType = "file-content" | "file-manifest" | "tree-manifest" | "revision-payload" | "revision-archive" | "head-payload" | "membership-payload" | "local-state";
4
6
  export type SyncDeviceRole = "owner" | "member";
5
7
  export type SyncIdentityState = "setup-required" | "active" | "rotation-pending";
6
8
  export interface SyncIdentityHistoryEntry {
@@ -64,6 +66,8 @@ export interface SyncCryptoStatus {
64
66
  role?: SyncDeviceRole;
65
67
  deviceState?: "active" | "revoked" | "recovery-pending";
66
68
  currentEpoch?: number;
69
+ /** Installed content-key epoch numbers, sorted and copied; present only while unlocked. */
70
+ installedEpochs?: number[];
67
71
  membershipGeneration?: number;
68
72
  canManageDevices?: boolean;
69
73
  syncIdentity?: SyncIdentityPublicStatus;
@@ -144,20 +148,31 @@ export interface SyncPairingActionResult {
144
148
  outboundEvents: NostrEvent[];
145
149
  /** Public transport endpoints for this bounded pairing session. */
146
150
  relayUrls: string[];
151
+ /** Omitted by older hosts, whose invitations use relay delivery. */
152
+ delivery?: SyncPairingDelivery;
147
153
  invitationUri?: string;
148
154
  membershipEvent?: NostrEvent;
149
155
  /** Bounded plugin data recovered only from the encrypted pairing transfer. */
150
156
  metadata?: unknown;
151
157
  }
158
+ export type SyncPairingDelivery = "relay" | "host";
159
+ export type SyncPairingOwnerRequest = {
160
+ cutoffHeads?: Record<string, string>;
161
+ metadata?: unknown;
162
+ } & ({
163
+ delivery?: "relay";
164
+ relayUrls: string[];
165
+ } | {
166
+ delivery: "host";
167
+ relayUrls?: never;
168
+ });
152
169
  export interface SyncPairingCapability {
153
- beginOwner(input: {
154
- relayUrls: string[];
155
- cutoffHeads?: Record<string, string>;
156
- metadata?: unknown;
157
- }): Promise<SyncPairingActionResult>;
170
+ beginOwner(input: SyncPairingOwnerRequest): Promise<SyncPairingActionResult>;
158
171
  acceptInvite(input: {
159
172
  invitationUri: string;
160
173
  deviceAlias: string;
174
+ /** Host delivery must be selected explicitly for an invitation with no relay addresses. */
175
+ delivery?: SyncPairingDelivery;
161
176
  }): Promise<SyncPairingActionResult>;
162
177
  ingest(input: {
163
178
  sessionId: string;
@@ -257,9 +272,9 @@ export interface SyncHostSecurityOptions {
257
272
  }
258
273
  export declare class SyncHostSecurityService {
259
274
  #private;
260
- readonly database: AppDatabase;
275
+ readonly database: SyncSecurityStore;
261
276
  readonly assertRuntime: (pluginId: string, runtimeId: string) => void;
262
- constructor(database: AppDatabase, assertRuntime: (pluginId: string, runtimeId: string) => void, options?: SyncHostSecurityOptions);
277
+ constructor(database: SyncSecurityStore, assertRuntime: (pluginId: string, runtimeId: string) => void, options?: SyncHostSecurityOptions);
263
278
  enroll(pluginId: string, passphrase: string, request?: SyncCryptoEnrollmentRequest): Promise<SyncCryptoStatus>;
264
279
  enrollWithWrappingKey(pluginId: string, wrappingKey: Uint8Array, descriptor: Extract<SyncWrappingKeyDescriptor, {
265
280
  name: "external";
@@ -28,6 +28,7 @@ function typeTag(type) {
28
28
  "revision-archive": 5,
29
29
  "head-payload": 6,
30
30
  "membership-payload": 7,
31
+ "local-state": 8,
31
32
  }[type];
32
33
  }
33
34
  function requireCryptoSize(bytes) {
@@ -98,12 +99,21 @@ function requirePublicKey(value, label = "Device public key") {
98
99
  }
99
100
  return value;
100
101
  }
102
+ function requirePairingDelivery(value) {
103
+ if (value === undefined)
104
+ return "relay";
105
+ if (value === "relay" || value === "host")
106
+ return value;
107
+ throw new Error("Unsupported pairing delivery mode");
108
+ }
101
109
  function requireRelayUrls(values) {
102
- if (values.length < 1 || values.length > 8) {
110
+ if (!Array.isArray(values) || values.length < 1 || values.length > 8) {
103
111
  throw new Error("Pairing requires between one and eight relay URLs");
104
112
  }
105
113
  const result = [];
106
114
  for (const value of values) {
115
+ if (typeof value !== "string")
116
+ throw new Error("Pairing relay URLs must be strings");
107
117
  const url = new URL(value);
108
118
  if (!["wss:", "ws:"].includes(url.protocol)) {
109
119
  throw new Error("Pairing relays must use wss or loopback ws");
@@ -286,6 +296,9 @@ export class SyncHostSecurityService {
286
296
  constructor(database, assertRuntime, options = {}) {
287
297
  this.database = database;
288
298
  this.assertRuntime = assertRuntime;
299
+ if (!database ||
300
+ ["getMeta", "setMeta", "transactMeta"].some((name) => typeof database[name] !== "function"))
301
+ throw new Error("Invalid Sync security store");
289
302
  this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
290
303
  this.#WebSocket = options.WebSocket ?? globalThis.WebSocket;
291
304
  this.#argon2 = {
@@ -387,12 +400,19 @@ export class SyncHostSecurityService {
387
400
  const keys = pending?.material
388
401
  ? this.#materialToUnlocked(pending.material, wrappingKey, descriptor)
389
402
  : this.#createOwnerKeys(requireAlias(request.deviceAlias ?? "This device"), wrappingKey, descriptor);
390
- const envelope = this.#sealEnvelope(pluginId, keys);
391
- const stored = await this.database.transactMeta({
392
- checks: [{ key: envelopeKey(pluginId), exists: false }],
393
- mutations: [{ key: envelopeKey(pluginId), value: envelope }],
394
- });
395
- if (!stored.applied) {
403
+ let applied;
404
+ try {
405
+ const envelope = this.#sealEnvelope(pluginId, keys);
406
+ applied = (await this.database.transactMeta({
407
+ checks: [{ key: envelopeKey(pluginId), exists: false }],
408
+ mutations: [{ key: envelopeKey(pluginId), value: envelope }],
409
+ })).applied;
410
+ }
411
+ catch (error) {
412
+ this.#destroyKeys(keys);
413
+ throw error;
414
+ }
415
+ if (!applied) {
396
416
  this.#destroyKeys(keys);
397
417
  throw new Error(`Sync crypto enrollment changed concurrently for ${pluginId}`);
398
418
  }
@@ -708,6 +728,7 @@ export class SyncHostSecurityService {
708
728
  role: unlocked.role,
709
729
  deviceState: this.#deviceState(unlocked),
710
730
  currentEpoch: unlocked.currentEpoch,
731
+ installedEpochs: [...unlocked.vaultKeys.keys()].sort((left, right) => left - right),
711
732
  membershipGeneration: unlocked.membershipGeneration,
712
733
  canManageDevices: unlocked.role === "owner",
713
734
  syncIdentity: this.#syncIdentityStatus(unlocked),
@@ -1059,20 +1080,24 @@ export class SyncHostSecurityService {
1059
1080
  session: this.#pairingSnapshot(session),
1060
1081
  outboundEvents,
1061
1082
  relayUrls: [...session.relays],
1083
+ delivery: session.delivery,
1062
1084
  ...extras,
1063
1085
  });
1064
1086
  return {
1065
- beginOwner: async ({ relayUrls, cutoffHeads = {}, metadata }) => {
1087
+ beginOwner: async ({ relayUrls, delivery, cutoffHeads = {}, metadata, }) => {
1066
1088
  assertActive();
1067
1089
  const keys = this.#requireUnlocked(pluginId);
1068
1090
  if (keys.role !== "owner" || !keys.ownerSecret) {
1069
1091
  throw new Error("Only the owner device may begin pairing");
1070
1092
  }
1093
+ const selectedDelivery = requirePairingDelivery(delivery);
1094
+ if (selectedDelivery === "host" && relayUrls !== undefined)
1095
+ throw new Error("Host-delivered pairing must not specify relay URLs");
1096
+ const relays = selectedDelivery === "host" ? [] : requireRelayUrls(relayUrls);
1071
1097
  const ephemeralSecret = randomBytes(KEY_LENGTH);
1072
1098
  const sessionSecret = toBase64Url(randomBytes(KEY_LENGTH));
1073
1099
  const sessionId = pairingSessionId(sessionSecret);
1074
1100
  const expiresAt = Date.now() + PAIRING_TTL_MS;
1075
- const relays = requireRelayUrls(relayUrls);
1076
1101
  const session = {
1077
1102
  pluginId,
1078
1103
  sessionId,
@@ -1083,6 +1108,7 @@ export class SyncHostSecurityService {
1083
1108
  ephemeralPublicKey: getPublicKey(ephemeralSecret),
1084
1109
  sessionSecret,
1085
1110
  relays,
1111
+ delivery: selectedDelivery,
1086
1112
  sequence: 0,
1087
1113
  lastReceivedSequence: 0,
1088
1114
  localConfirmed: false,
@@ -1107,12 +1133,13 @@ export class SyncHostSecurityService {
1107
1133
  }
1108
1134
  return result(session, [], { invitationUri });
1109
1135
  },
1110
- acceptInvite: async ({ invitationUri, deviceAlias }) => {
1136
+ acceptInvite: async ({ invitationUri, deviceAlias, delivery }) => {
1111
1137
  assertActive();
1112
1138
  if ((await this.status(pluginId)).state !== "unenrolled") {
1113
1139
  throw new Error("Pairing import requires an unenrolled sync identity");
1114
1140
  }
1115
- const invitation = this.#parsePairingInvitation(invitationUri);
1141
+ const selectedDelivery = requirePairingDelivery(delivery);
1142
+ const invitation = this.#parsePairingInvitation(invitationUri, selectedDelivery);
1116
1143
  if (Date.now() >= invitation.e)
1117
1144
  throw new Error("Pairing invitation expired");
1118
1145
  const ephemeralSecret = randomBytes(KEY_LENGTH);
@@ -1127,7 +1154,8 @@ export class SyncHostSecurityService {
1127
1154
  ephemeralSecret,
1128
1155
  ephemeralPublicKey: getPublicKey(ephemeralSecret),
1129
1156
  sessionSecret: invitation.s,
1130
- relays: requireRelayUrls(invitation.r),
1157
+ relays: [...invitation.r],
1158
+ delivery: selectedDelivery,
1131
1159
  sequence: 0,
1132
1160
  lastReceivedSequence: 0,
1133
1161
  peerEphemeralPublicKey: requirePublicKey(invitation.p, "Pairing public key"),
@@ -1717,7 +1745,7 @@ export class SyncHostSecurityService {
1717
1745
  session.pendingDeviceSecret?.fill(0);
1718
1746
  session.pendingDeviceSecret = undefined;
1719
1747
  }
1720
- #parsePairingInvitation(invitationUri) {
1748
+ #parsePairingInvitation(invitationUri, delivery) {
1721
1749
  if (invitationUri.length > 2_048) {
1722
1750
  throw new Error("Pairing invitation exceeds the app URL limit");
1723
1751
  }
@@ -1752,7 +1780,12 @@ export class SyncHostSecurityService {
1752
1780
  throw new Error("Pairing invitation data is invalid");
1753
1781
  }
1754
1782
  requirePublicKey(invitation.p, "Pairing public key");
1755
- requireRelayUrls(invitation.r);
1783
+ if (delivery === "host") {
1784
+ if (invitation.r.length !== 0)
1785
+ throw new Error("Host-delivered pairing invitations must not contain relay URLs");
1786
+ }
1787
+ else
1788
+ invitation.r = requireRelayUrls(invitation.r);
1756
1789
  return invitation;
1757
1790
  }
1758
1791
  #createPairingEvent(session, input) {
@@ -3,7 +3,9 @@ import { TFile } from "./storage/fs";
3
3
  import type { VaultReadHandle } from "./storage/fs";
4
4
  import type { Vault } from "./storage/vault.svelte";
5
5
  import type { HostedPluginCapability } from "./plugin-capability-facade";
6
- import { type PluginNetworkCapability, type SyncCryptoCapability, type SyncCryptoEnrollmentRequest, type SyncCryptoStatus, type SyncIdentityCapability, type SyncHostSecurityOptions, type SyncMembershipCapability, type SyncPairingCapability, type SyncRecoveryCapability, type SyncWrappingKeyDescriptor } from "./sync-host-security";
6
+ import type { SyncHostLocalProvider, SyncLocalApplyGuard, SyncLocalHostCapabilities } from "./sync-repository";
7
+ export type { SyncHostLocalProvider, SyncLocalApplyGuard, SyncLocalHostCapabilities, } from "./sync-repository";
8
+ import { type PluginNetworkCapability, type SyncCryptoCapability, type SyncCryptoEnrollmentRequest, type SyncCryptoStatus, type SyncIdentityCapability, type SyncHostSecurityOptions, type SyncMembershipCapability, type SyncPairingCapability, type SyncRecoveryCapability, type SyncSecurityStore, type SyncWrappingKeyDescriptor } from "./sync-host-security";
7
9
  export type HostFileIdentityState = "present" | "tombstoned";
8
10
  export type HostFileChangeKind = "create" | "modify" | "rename" | "delete";
9
11
  export interface HostFileIdentity {
@@ -92,6 +94,8 @@ export interface PluginGeneratedState {
92
94
  clear(): Promise<void>;
93
95
  }
94
96
  export interface SyncHostCapabilities {
97
+ /** Omitted by hosts without durable guarded local Sync support. */
98
+ readonly local?: SyncLocalHostCapabilities;
95
99
  readonly files: {
96
100
  getIdentity(path: string): Promise<HostFileIdentity | null>;
97
101
  resolveIdentity(fileId: string): Promise<HostFileIdentity | null>;
@@ -133,7 +137,21 @@ export interface SyncHostCapabilities {
133
137
  readonly recovery: SyncRecoveryCapability;
134
138
  readonly generatedState: PluginGeneratedState;
135
139
  }
140
+ export type SyncEndpointSecurityCapabilities = Pick<SyncHostCapabilities, "crypto" | "syncIdentity" | "pairing" | "membership" | "recovery">;
141
+ /** Trusted host custody. This controller is never included in plugin facades. */
142
+ export interface SyncHostSecurityProvider {
143
+ forRuntime(runtimeId: string): SyncEndpointSecurityCapabilities;
144
+ /** Must stop admission synchronously, then drain and destroy this runtime's keys. */
145
+ revokeRuntime(runtimeId: string): void | Promise<void>;
146
+ }
136
147
  export interface SyncHostServiceOptions {
148
+ localProvider?: (pluginId: string) => SyncHostLocalProvider | undefined;
149
+ /** Required before local mutations or receipt recovery can enter a provider. */
150
+ guardLocalApply?: SyncLocalApplyGuard;
151
+ /** Select custody per plugin; returning undefined explicitly retains legacy custody. */
152
+ securityProvider?: (pluginId: string) => SyncHostSecurityProvider | undefined;
153
+ /** Dedicated host custody; omitted only for legacy AppDatabase-backed callers. */
154
+ securityStore?: SyncSecurityStore;
137
155
  flushOpenEditor?: (path?: string) => Promise<{
138
156
  flushed: boolean;
139
157
  path?: string;
@@ -155,11 +173,13 @@ export declare class SyncHostService {
155
173
  readonly database: AppDatabase;
156
174
  readonly vault: Vault;
157
175
  readonly clock: () => number;
158
- readonly options: SyncHostServiceOptions;
176
+ readonly options: Omit<SyncHostServiceOptions, "securityStore" | "securityProvider" | "localProvider" | "guardLocalApply">;
159
177
  constructor(database: AppDatabase, vault: Vault, clock?: () => number, options?: SyncHostServiceOptions);
160
178
  ready(): Promise<void>;
161
179
  forPlugin(pluginId: string, grantedCapabilities?: readonly HostedPluginCapability[]): SyncHostCapabilities;
162
- revokePlugin(pluginId: string): void;
180
+ revokePlugin(pluginId: string): Promise<void>;
181
+ /** Invalidate first, then wait for every admitted host cleanup and file event. */
182
+ dispose(): Promise<void>;
163
183
  /** Host-controlled enrollment; this operation is intentionally absent from plugin facades. */
164
184
  enrollPluginCrypto(pluginId: string, passphrase: string, request?: SyncCryptoEnrollmentRequest): Promise<SyncCryptoStatus>;
165
185
  /** Host-controlled unlock; plugins can only observe locked state. */