@authzflow/core 0.1.0-alpha.8 → 0.1.0-beta.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @authzflow/core
2
2
 
3
- MIT-licensed alpha authorization-state runtime maintained by Accelflow Inc. Ships prebuilt Rust/Wasm, ESM JavaScript, TypeScript declarations and a checked reader. npm consumers need no Rust compiler.
3
+ MIT-licensed beta authorization-state runtime maintained by Accelflow Inc. Ships prebuilt Rust/Wasm, ESM JavaScript, TypeScript declarations and a checked reader. npm consumers need no Rust compiler.
4
4
 
5
5
  ```js
6
6
  import { createRuntime, createCheckedReader } from '@authzflow/core';
@@ -10,7 +10,7 @@ The host supplies verified sessions, clocks, an atomic durable checkpoint store,
10
10
 
11
11
  See https://github.com/Accelflow/AuthzFlow/blob/main/docs-public/getting-started.md for installation and the executable example, and https://github.com/Accelflow/AuthzFlow/blob/main/docs-public/adapters.md for the full callback contract. Node 22/24, ESM, TypeScript 5.9+ and modern secure-context WebAssembly browsers are initial targets.
12
12
 
13
- Offline permissions expire at their original bounded deadline and are lost across reader/process restarts. Server-side revocation cannot instantly reach disconnected clients. Already returned plaintext, raw store bypasses, multi-owner coordination and stream recovery are outside alpha guarantees.
13
+ Offline permissions expire at their original bounded deadline and are lost across reader/process restarts. Server-side revocation cannot instantly reach disconnected clients. Already returned plaintext, raw store bypasses, multi-owner coordination and stream recovery are outside beta guarantees.
14
14
 
15
15
  LICENSE and THIRD_PARTY_NOTICES.md are included; the latter contains dependency and Rust standard-library attributions and license texts. Security reporting: https://github.com/Accelflow/AuthzFlow/security/advisories/new .
16
16
 
@@ -35,3 +35,27 @@ do not supply that grant. A different cached value is rejected without destroyin
35
35
  the valid grant for the original content. Session/clock/revision failures fence it.
36
36
  Hosts can use this for secondary cache projections; normal screen revalidation
37
37
  continues to call `readWithPermit()`. It cannot turn a cached checkpoint into permission.
38
+
39
+ ### Optional Resource Group integration
40
+
41
+ The opt-in `resource-group` entrypoint aggregates invalidations for related
42
+ Resources. `createResourceGroupCoordinator` uses Rust protocol v2;
43
+ `createNativeResourceGroupCoordinator` in `native-reader` uses the same Rust
44
+ transitions. Attach it through the Resource authority's `groups` option and pass
45
+ `resourceGroupId` on each Resource input. Do not enable the legacy `changes`
46
+ option on that same authority.
47
+
48
+ `group-sqlite`, `group-indexeddb`, and `group-notifications` provide shared-store
49
+ and wakeup adapters. The host must retain session checks and atomic, term-fenced
50
+ purge. Group registration, shared state and notifications never grant content;
51
+ each reader retains its own fresh-check permit. Cold start cannot inherit another
52
+ tab's permit. Close the authority before closing its host-owned coordinator.
53
+
54
+ `readCachedManyWithPermit` checks up to 64 cached Resource inputs (4 MiB aggregate)
55
+ without HTTP checks or deadline renewal. It returns ordered per-item results and
56
+ rechecks shared fences before delivery. It does not load missing permits or make
57
+ an entire search index readable offline.
58
+
59
+ Versioned source contracts live under `contracts/`. Browser-tab and independent
60
+ SQLite-process contracts are tested; each product still needs its own integration
61
+ acceptance and revision-fenced purge implementation.
@@ -0,0 +1,3 @@
1
+ import type { GroupCoordinationStorage } from './resource-group-driver.js';
2
+ /** Pass the product's DB/store to share the transaction boundary with product replicas. */
3
+ export declare function createIndexedDBGroupStorage(db: IDBDatabase, storeName: string): GroupCoordinationStorage;
@@ -0,0 +1,37 @@
1
+ /** Pass the product's DB/store to share the transaction boundary with product replicas. */
2
+ export function createIndexedDBGroupStorage(db, storeName) {
3
+ if (!db.objectStoreNames.contains(storeName))
4
+ throw Error('group_store_missing');
5
+ const request = (r) => new Promise((resolve, reject) => { r.onsuccess = () => resolve(r.result); r.onerror = () => reject(r.error); });
6
+ const run = async (mode, work) => {
7
+ const tx = db.transaction([storeName], mode, { durability: 'strict' });
8
+ const done = new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onabort = () => reject(tx.error ?? Error('group_transaction_aborted')); });
9
+ try {
10
+ const result = await work(tx.objectStore(storeName));
11
+ await done;
12
+ return result;
13
+ }
14
+ catch (error) {
15
+ try {
16
+ tx.abort();
17
+ }
18
+ catch { }
19
+ await done.catch(() => { });
20
+ throw error;
21
+ }
22
+ };
23
+ return {
24
+ async read(key) { return run('readonly', async (store) => (await request(store.get(key))) ?? null); },
25
+ async readMany(keys) { return run('readonly', async (store) => Promise.all(keys.map(async (key) => (await request(store.get(key))) ?? null))); },
26
+ async transaction(key, work) {
27
+ return run('readwrite', async (store) => {
28
+ const saved = (await request(store.get(key))) ?? null;
29
+ const { row, result } = work(saved);
30
+ if (new TextEncoder().encode(JSON.stringify(row)).byteLength > 1048576)
31
+ throw Error('group_capacity');
32
+ await request(store.put(row, key));
33
+ return result;
34
+ });
35
+ },
36
+ };
37
+ }
@@ -0,0 +1,6 @@
1
+ import type { GroupNotice } from './resource-group-driver.js';
2
+ /** Host creates/closes a same-profile channel. Messages only trigger durable reloads. */
3
+ export declare function createGroupNotificationPort(channel: Pick<BroadcastChannel, 'postMessage' | 'addEventListener' | 'removeEventListener'>): {
4
+ notify(notice: GroupNotice): void;
5
+ listen(receive: (notice: unknown) => void): () => void;
6
+ };
@@ -0,0 +1,7 @@
1
+ /** Host creates/closes a same-profile channel. Messages only trigger durable reloads. */
2
+ export function createGroupNotificationPort(channel) {
3
+ return {
4
+ notify(notice) { channel.postMessage(notice); },
5
+ listen(receive) { const handler = (event) => receive(event.data); channel.addEventListener('message', handler); return () => channel.removeEventListener('message', handler); },
6
+ };
7
+ }
@@ -0,0 +1,4 @@
1
+ import type { SQLiteDatabase } from './sqlite.js';
2
+ import type { GroupCoordinationStorage } from './resource-group-driver.js';
3
+ /** Uses the host connection; place content and Group fences in the same database for purge. */
4
+ export declare function createSQLiteGroupStorage(db: SQLiteDatabase): GroupCoordinationStorage;
@@ -0,0 +1,40 @@
1
+ /** Uses the host connection; place content and Group fences in the same database for purge. */
2
+ export function createSQLiteGroupStorage(db) {
3
+ db.exec('CREATE TABLE IF NOT EXISTS authz_resource_groups (key TEXT PRIMARY KEY,payload TEXT NOT NULL) STRICT; PRAGMA busy_timeout=5000;');
4
+ const read = (key) => {
5
+ const row = db.prepare('SELECT payload FROM authz_resource_groups WHERE key=?').get(key);
6
+ if (!row)
7
+ return null;
8
+ if (typeof row.payload !== 'string' || row.payload.length > 1048576)
9
+ throw Error('group_state_invalid');
10
+ return JSON.parse(row.payload);
11
+ };
12
+ return {
13
+ async read(key) { return read(key); },
14
+ async readMany(keys) { db.exec('BEGIN'); try {
15
+ const result = keys.map(read);
16
+ db.exec('COMMIT');
17
+ return result;
18
+ }
19
+ catch (error) {
20
+ db.exec('ROLLBACK');
21
+ throw error;
22
+ } },
23
+ async transaction(key, work) {
24
+ db.exec('BEGIN IMMEDIATE');
25
+ try {
26
+ const { row, result } = work(read(key));
27
+ const payload = JSON.stringify(row);
28
+ if (new TextEncoder().encode(payload).byteLength > 1048576)
29
+ throw Error('group_capacity');
30
+ db.prepare('INSERT OR REPLACE INTO authz_resource_groups VALUES(?,?)').run(key, payload);
31
+ db.exec('COMMIT');
32
+ return result;
33
+ }
34
+ catch (error) {
35
+ db.exec('ROLLBACK');
36
+ throw error;
37
+ }
38
+ },
39
+ };
40
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export { createResourceAuthority, type ResourceAuthority, type ResourceAuthorityOptions, type ResourceReadInput, type ResourceReadLease, type ResourceIdentity, type ResourceAuthorityState, type ResourceAuthorityPhase, type ResourceAuthorityReason, type ResourceAuthorityEvent, type ResourceAuthorityEventCode, type ResourceAuthorityDiagnostic } from './resource-authority.js';
2
2
  export { createChangeConsumer, type ChangeConsumer, type ChangeConsumerOptions } from './change-consumer.js';
3
+ export { createResourceGroupCoordinator } from './resource-group.js';
4
+ export type * from './resource-group-driver.js';
5
+ export type { CachedItemResult } from './resource-authority-driver.js';
3
6
  export { createCheckedReader, type CheckedRead, type CheckedReader, type CheckedReaderOptions } from './checked-reader.js';
4
7
  export interface Clock {
5
8
  boot: string;
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { createResourceAuthority } from './resource-authority.js';
2
2
  import { createDrivenRuntime } from './runtime-driver.js';
3
3
  export { createChangeConsumer } from './change-consumer.js';
4
+ export { createResourceGroupCoordinator } from './resource-group.js';
4
5
  import init, { WasmRuntime, runtime_abi_version } from '../wasm/authzflow_wasm.js';
5
6
  export { createCheckedReader } from './checked-reader.js';
6
7
  /** Loads the Rust authority. Restored state always requires a fresh server check. */
@@ -18,3 +18,8 @@ export declare function createNativeResourceAuthority<T = unknown>(options: Omit
18
18
  native: NativePort;
19
19
  randomUUID(): string;
20
20
  }): import("./resource-authority-driver.js").ResourceAuthority<T>;
21
+ import { type ResourceGroupOptions } from './resource-group-driver.js';
22
+ /** Requires native Group protocol support; no Wasm import or JavaScript fallback. */
23
+ export declare function createNativeResourceGroupCoordinator(options: ResourceGroupOptions & {
24
+ native: NativePort;
25
+ }): import("./resource-group-driver.js").ResourceGroupCoordinator;
@@ -41,3 +41,13 @@ import { createDrivenResourceAuthority } from './resource-authority-driver.js';
41
41
  export function createNativeResourceAuthority(options) {
42
42
  return createDrivenResourceAuthority({ ...options, clone: options.clone ?? (value => JSON.parse(JSON.stringify(value))), runtime: options.runtime ?? (settings => createNativeRuntime({ ...settings, native: options.native })), reader: options.reader ?? (settings => createNativeCheckedReader({ ...settings, native: options.native, randomUUID: options.randomUUID })) });
43
43
  }
44
+ import { createDrivenResourceGroup } from './resource-group-driver.js';
45
+ /** Requires native Group protocol support; no Wasm import or JavaScript fallback. */
46
+ export function createNativeResourceGroupCoordinator(options) {
47
+ return createDrivenResourceGroup(options, request => {
48
+ const response = JSON.parse(options.native.call(JSON.stringify({ op: 'group', request })));
49
+ if (response?.ok !== true)
50
+ throw Error(typeof response?.error === 'string' ? response.error : 'native_response_invalid');
51
+ return response.value;
52
+ });
53
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Checkpoint, Options, Plan, Runtime, RuntimeEvent } from './index.js';
2
2
  import type { CheckedReader, CheckedReaderOptions } from './checked-reader.js';
3
+ import type { ResourceGroupCoordinator } from './resource-group-driver.js';
3
4
  export interface ResourceIdentity {
4
5
  binding: string;
5
6
  resourceId: string;
@@ -8,6 +9,7 @@ export interface ResourceIdentity {
8
9
  export interface ResourceReadInput<T = unknown> extends ResourceIdentity {
9
10
  content: string;
10
11
  target?: T;
12
+ resourceGroupId?: string;
11
13
  }
12
14
  export type ResourceAuthorityPhase = 'idle' | 'checking' | 'allowed' | 'revalidate' | 'denied' | 'expired' | 'closed';
13
15
  export type ResourceAuthorityReason = 'initial' | 'check_started' | 'online_grant' | 'offline_grant' | 'revalidation_required' | 'server_denied' | 'deadline_elapsed' | 'context_changed' | 'clock_discontinuity' | 'check_failed' | 'purge_blocked' | 'closed' | 'evicted';
@@ -37,6 +39,16 @@ export interface ResourceReadLease {
37
39
  validForMs: number;
38
40
  expiresAt: number;
39
41
  }
42
+ export type CachedItemResult = {
43
+ status: 'allowed';
44
+ lease: ResourceReadLease;
45
+ } | {
46
+ status: 'denied';
47
+ reason: 'access_denied';
48
+ } | {
49
+ status: 'unavailable';
50
+ reason: 'permit_missing' | 'expired' | 'content_changed' | 'check_in_progress' | 'context_changed' | 'revalidation_required' | 'storage_failed';
51
+ };
40
52
  type Response = {
41
53
  status: number;
42
54
  json(): Promise<unknown>;
@@ -65,6 +77,8 @@ export interface ResourceAuthorityOptions<T = unknown> {
65
77
  cursor: string;
66
78
  }): Promise<Response>;
67
79
  };
80
+ /** Mutually exclusive with the legacy per-Resource feed. Own/close this coordinator at host scope. */
81
+ groups?: ResourceGroupCoordinator;
68
82
  /** Trusted host storage proof, never a renderer-provided flag. Does not grant or renew. */
69
83
  ownsContent?(input: ResourceReadInput<T>): Promise<boolean>;
70
84
  onGranted?(input: ResourceReadInput<T>): Promise<void>;
@@ -84,6 +98,7 @@ export interface ResourceAuthorityOptions<T = unknown> {
84
98
  export interface ResourceAuthority<T = unknown> {
85
99
  readWithPermit(input: ResourceReadInput<T>): Promise<ResourceReadLease>;
86
100
  readCachedWithPermit(input: ResourceReadInput<T>): Promise<ResourceReadLease>;
101
+ readCachedManyWithPermit(inputs: readonly ResourceReadInput<T>[]): Promise<CachedItemResult[]>;
87
102
  /** Drops the old in-memory permit; only a fresh online full check can succeed. */
88
103
  revalidate(input: ResourceReadInput<T>): Promise<ResourceReadLease>;
89
104
  getState(input: ResourceIdentity): ResourceAuthorityState;
@@ -1,7 +1,7 @@
1
1
  import { createDrivenResourceChangeTracker } from './resource-change-tracker-driver.js';
2
2
  /** Bounded multi-resource coordination of Rust readers, durable cursors and host-owned replicas. */
3
3
  export function createDrivenResourceAuthority(options) {
4
- if (options.changes && typeof options.onDenied !== 'function' || (options.runtime === undefined) !== (options.reader === undefined))
4
+ if (options.changes && typeof options.onDenied !== 'function' || options.changes && options.groups || (options.runtime === undefined) !== (options.reader === undefined))
5
5
  throw Error('authz_authority_config_invalid');
6
6
  const clone = options.clone ?? structuredClone;
7
7
  const readers = new Map();
@@ -104,6 +104,7 @@ export function createDrivenResourceAuthority(options) {
104
104
  readers.delete(key);
105
105
  entry?.reader?.close();
106
106
  entry?.tracker?.close();
107
+ entry?.groupOff?.();
107
108
  if (reason === 'evicted')
108
109
  observations.delete(key);
109
110
  };
@@ -141,14 +142,24 @@ export function createDrivenResourceAuthority(options) {
141
142
  const version = owned.version;
142
143
  return options.reader({ binding: input.binding, resourceId: resource, scopeId: input.scopeId, session: options.session, clock: options.clock, wasmBytes: options.wasmBytes, onEvent: runtimeEvent,
143
144
  storage: { load: () => options.storage.load('reader', resource), commit: plan => options.storage.commit('reader', resource, plan) },
144
- check: async ({ requestId }) => { owned.readContent = undefined; try {
145
- return await options.check(owned.input, requestId);
146
- }
147
- catch (error) {
148
- if ((error instanceof TypeError || error?.name === 'TimeoutError') && owned.grantedContent && owned.input.content !== owned.grantedContent && await options.ownsContent?.(owned.input))
149
- owned.readContent = owned.grantedContent;
150
- throw error;
151
- } }, read: async () => owned.readContent ?? owned.input.content, contentHash: options.hash, }).then(reader => { if (closed || readers.get(key) !== owned || version !== owned.version) {
145
+ check: async ({ requestId }) => {
146
+ owned.readContent = undefined;
147
+ owned.groupAnchor = undefined;
148
+ try {
149
+ const response = await options.check(owned.input, requestId);
150
+ if (options.groups && response.status === 200) {
151
+ const body = await response.json();
152
+ owned.groupAnchor = await options.groups.acceptCheck({ ...owned.input, resourceGroupId: owned.input.resourceGroupId }, body?.resourceGroup);
153
+ return { status: response.status, json: async () => body };
154
+ }
155
+ return response;
156
+ }
157
+ catch (error) {
158
+ if ((error instanceof TypeError || error?.name === 'TimeoutError') && owned.grantedContent && owned.input.content !== owned.grantedContent && await options.ownsContent?.(owned.input))
159
+ owned.readContent = owned.grantedContent;
160
+ throw error;
161
+ }
162
+ }, read: async () => owned.readContent ?? owned.input.content, contentHash: options.hash, }).then(reader => { if (closed || readers.get(key) !== owned || version !== owned.version) {
152
163
  reader.close();
153
164
  throw Error('context_changed');
154
165
  } owned.reader = reader; return reader; });
@@ -202,6 +213,8 @@ export function createDrivenResourceAuthority(options) {
202
213
  void owned.trackerReady.catch(() => { });
203
214
  }
204
215
  }
216
+ if (entry.input.resourceGroupId !== input.resourceGroupId)
217
+ throw Error('group_membership_changed');
205
218
  if (entry.pending) {
206
219
  if (cached)
207
220
  throw Error('check_in_progress');
@@ -219,6 +232,46 @@ export function createDrivenResourceAuthority(options) {
219
232
  const valid = () => { if (closed || readers.get(key) !== owned || owned.version !== turn)
220
233
  throw Error('context_changed'); };
221
234
  const run = Promise.resolve().then(async () => {
235
+ if (options.groups) {
236
+ if (!input.resourceGroupId)
237
+ throw Error('group_identity_invalid');
238
+ const groupInput = { ...input, resourceGroupId: input.resourceGroupId };
239
+ if (!owned.groupOff)
240
+ owned.groupOff = await options.groups.register(groupInput, action => {
241
+ owned.version++;
242
+ owned.reader?.close();
243
+ owned.reader = undefined;
244
+ owned.ready = undefined;
245
+ owned.grantedContent = undefined;
246
+ owned.groupStamp = undefined;
247
+ publish(record, action === 'revoke' ? 'denied' : 'revalidate', action === 'revoke' ? 'server_denied' : 'revalidation_required');
248
+ try {
249
+ void Promise.resolve(options.onInvalidated?.(owned.input)).catch(() => { });
250
+ }
251
+ catch { }
252
+ });
253
+ if (closed || readers.get(key) !== owned) {
254
+ owned.groupOff?.();
255
+ owned.groupOff = undefined;
256
+ throw Error('context_changed');
257
+ }
258
+ if (owned.groupStamp) {
259
+ try {
260
+ await options.groups.guard(groupInput, owned.groupStamp);
261
+ }
262
+ catch (error) {
263
+ owned.reader?.close();
264
+ owned.reader = undefined;
265
+ owned.ready = undefined;
266
+ owned.grantedContent = undefined;
267
+ owned.groupStamp = undefined;
268
+ if (cached)
269
+ throw error;
270
+ }
271
+ }
272
+ else if (cached)
273
+ throw Error('authz_cached_permit_missing');
274
+ }
222
275
  if (strict) {
223
276
  owned.version++;
224
277
  owned.reader?.close();
@@ -259,6 +312,17 @@ export function createDrivenResourceAuthority(options) {
259
312
  await effect(owned, async () => { valid(); await options.onGranted?.(owned.input); valid(); });
260
313
  owned.grantedContent = owned.input.content;
261
314
  }
315
+ if (options.groups) {
316
+ const groupInput = { ...input, resourceGroupId: input.resourceGroupId };
317
+ if (!cached && !result.offline) {
318
+ if (!owned.groupAnchor)
319
+ throw Error('group_revalidation_required');
320
+ owned.groupStamp = await options.groups.granted(groupInput, owned.groupAnchor);
321
+ }
322
+ if (!owned.groupStamp)
323
+ throw Error('group_revalidation_required');
324
+ await options.groups.guard(groupInput, owned.groupStamp);
325
+ }
262
326
  if (closed || readers.get(key) !== owned)
263
327
  throw Error('context_changed');
264
328
  const now = options.clock();
@@ -303,6 +367,101 @@ export function createDrivenResourceAuthority(options) {
303
367
  return complete;
304
368
  };
305
369
  const api = { readWithPermit: input => verify(input), readCachedWithPermit: input => verify(input, true), revalidate: input => verify(input, false, true),
370
+ async readCachedManyWithPermit(inputs) {
371
+ if (!Array.isArray(inputs) || inputs.length > 64)
372
+ throw Error('authz_batch_invalid');
373
+ let bytes = 0;
374
+ const snapshot = clone(inputs);
375
+ for (const input of snapshot) {
376
+ keyOf(input);
377
+ if (typeof input.content !== 'string' || !input.content.length || input.content.length > 1048576)
378
+ throw Error('authz_batch_invalid');
379
+ let itemBytes = 0;
380
+ // UTF-8 byte length without browser-only globals (native/Hermes uses this same path).
381
+ for (const point of input.content) {
382
+ const code = point.codePointAt(0);
383
+ itemBytes += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4;
384
+ }
385
+ bytes += itemBytes;
386
+ if (itemBytes > 1048576)
387
+ throw Error('authz_batch_invalid');
388
+ if (bytes > 4194304)
389
+ throw Error('authz_batch_invalid');
390
+ }
391
+ const started = options.clock(), actor = clone(await options.session()), results = new Array(snapshot.length), deadlines = new Array(snapshot.length), fences = new Array(snapshot.length);
392
+ const classify = (error, input) => {
393
+ const message = error?.message;
394
+ const state = observations.get(keyOf(input))?.state;
395
+ if (message === 'authz_check_denied' || state?.phase === 'denied' && state.reason === 'server_denied')
396
+ return { status: 'denied', reason: 'access_denied' };
397
+ return { status: 'unavailable', reason: message === 'authz_cached_permit_missing' ? 'permit_missing' : message === 'authz_offline_expired' ? 'expired' : message === 'authz_content_changed' ? 'content_changed' : message === 'check_in_progress' ? 'check_in_progress' : message === 'context_changed' || message === 'clock_discontinuity' ? 'context_changed' : message?.startsWith('group_') || message === 'runtime_unavailable' ? 'revalidation_required' : 'storage_failed' };
398
+ };
399
+ // Keep duplicates in one lane; each Resource's own reader serializes access.
400
+ const lanes = new Map();
401
+ snapshot.forEach((input, index) => { const key = keyOf(input); const lane = lanes.get(key) ?? []; lane.push(index); lanes.set(key, lane); });
402
+ const jobs = [...lanes.values()];
403
+ let at = 0;
404
+ await Promise.all(Array.from({ length: Math.min(8, jobs.length) }, async () => { while (at < jobs.length) {
405
+ const indices = jobs[at++];
406
+ for (const index of indices) {
407
+ try {
408
+ const lease = await verify(snapshot[index], true);
409
+ results[index] = { status: 'allowed', lease };
410
+ deadlines[index] = options.clock().now_ms + lease.validForMs;
411
+ }
412
+ catch (error) {
413
+ results[index] = classify(error, snapshot[index]);
414
+ }
415
+ }
416
+ } }));
417
+ for (let index = 0; index < snapshot.length; index++)
418
+ if (results[index]?.status === 'allowed') {
419
+ try {
420
+ const lease = await verify(snapshot[index], true);
421
+ results[index] = { status: 'allowed', lease };
422
+ deadlines[index] = Math.min(deadlines[index], options.clock().now_ms + lease.validForMs);
423
+ const entry = readers.get(keyOf(snapshot[index]));
424
+ fences[index] = { entry, version: entry?.version };
425
+ }
426
+ catch (error) {
427
+ results[index] = classify(error, snapshot[index]);
428
+ }
429
+ }
430
+ if (options.groups) {
431
+ const protectedItems = snapshot.map((input, index) => ({ input, index, entry: readers.get(keyOf(input)) })).filter(value => results[value.index]?.status === 'allowed');
432
+ try {
433
+ const decisions = await options.groups.guardMany(protectedItems.map(({ input, entry }) => ({ input: { ...input, resourceGroupId: input.resourceGroupId }, stamp: entry?.groupStamp })));
434
+ protectedItems.forEach((item, i) => { if (!decisions[i])
435
+ results[item.index] = { status: 'unavailable', reason: 'revalidation_required' }; });
436
+ }
437
+ catch {
438
+ for (const item of protectedItems)
439
+ results[item.index] = { status: 'unavailable', reason: 'storage_failed' };
440
+ }
441
+ }
442
+ const current = await options.session(), now = options.clock();
443
+ const changed = closed || current.binding !== actor.binding || current.context !== actor.context || !actor.context || !Number.isSafeInteger(current.expiresAtMs) || !Number.isSafeInteger(actor.expiresAtMs) || now.wall_ms >= Math.min(current.expiresAtMs, actor.expiresAtMs) || now.boot !== started.boot || now.now_ms < started.now_ms || now.wall_ms < started.wall_ms;
444
+ if (changed)
445
+ return results.map(() => ({ status: 'unavailable', reason: 'context_changed' }));
446
+ const storageFailed = results.some(result => result.status === 'unavailable' && result.reason === 'storage_failed');
447
+ for (let i = 0; i < results.length; i++) {
448
+ const result = results[i];
449
+ if (result?.status !== 'allowed')
450
+ continue;
451
+ if (storageFailed) {
452
+ results[i] = { status: 'unavailable', reason: 'storage_failed' };
453
+ continue;
454
+ }
455
+ const entry = readers.get(keyOf(snapshot[i])), fence = fences[i];
456
+ if (!entry || entry !== fence?.entry || entry.version !== fence.version) {
457
+ results[i] = { status: 'unavailable', reason: 'revalidation_required' };
458
+ continue;
459
+ }
460
+ const remaining = Math.floor(Math.min(deadlines[i] - now.now_ms, result.lease.expiresAt - now.wall_ms, current.expiresAtMs - now.wall_ms));
461
+ results[i] = remaining > 0 ? { status: 'allowed', lease: { allowed: true, validForMs: remaining, expiresAt: Math.min(result.lease.expiresAt, now.wall_ms + remaining) } } : { status: 'unavailable', reason: 'expired' };
462
+ }
463
+ return results;
464
+ },
306
465
  getState(input) { const key = keyOf(input), record = observations.get(key); return record ? sample(record) : initial(closed ? 'closed' : 'idle', closed ? 'closed' : 'initial'); },
307
466
  subscribe(input, listener) { if (typeof listener !== 'function')
308
467
  throw Error('authz_observer_invalid'); if (closed) {
@@ -314,6 +473,8 @@ export function createDrivenResourceAuthority(options) {
314
473
  record.timer = undefined;
315
474
  } }; },
316
475
  async catchUp() {
476
+ if (options.groups)
477
+ return options.groups.catchUp();
317
478
  if (polling)
318
479
  return polling;
319
480
  const task = (async () => { let failure; const entries = [...readers.values()].filter(entry => entry.trackerReady); for (let index = 0; index < entries.length; index += 8) {
@@ -0,0 +1,119 @@
1
+ /** Versioned host ports. Group metadata fences Resource permits; it never grants content. */
2
+ export interface ResourceGroup {
3
+ binding: string;
4
+ resourceGroupId: string;
5
+ }
6
+ export interface GroupPosition {
7
+ epoch: string;
8
+ sequence: string;
9
+ cursor: string;
10
+ }
11
+ export interface GroupAnchor {
12
+ resourceGroupId: string;
13
+ epoch: string;
14
+ sequence: string;
15
+ }
16
+ export interface GroupCheckpoint extends ResourceGroup, GroupPosition {
17
+ schemaVersion: 2;
18
+ kind: 'resource_group';
19
+ generation: string;
20
+ revision: string;
21
+ resources: Record<string, {
22
+ grantGeneration: string | null;
23
+ intent: 'revoke' | 'revalidate' | null;
24
+ }>;
25
+ }
26
+ export interface GroupRow {
27
+ term: string;
28
+ owner: {
29
+ id: string;
30
+ context: string;
31
+ until: number;
32
+ } | null;
33
+ state: GroupCheckpoint | null;
34
+ }
35
+ export interface GroupCoordinationStorage {
36
+ read(key: string): Promise<GroupRow | null>;
37
+ /** One readonly transaction for the batch's common shared-store boundary. */
38
+ readMany(keys: readonly string[]): Promise<(GroupRow | null)[]>;
39
+ /** Runs a synchronous callback in a serializable durable transaction. No shared mutable references. */
40
+ transaction<T>(key: string, work: (row: GroupRow | null) => {
41
+ row: GroupRow;
42
+ result: T;
43
+ }): Promise<T>;
44
+ }
45
+ export interface GroupStamp extends ResourceGroup {
46
+ generation: string;
47
+ term: string;
48
+ epoch: string;
49
+ sequence: string;
50
+ }
51
+ export interface GroupNotice extends ResourceGroup {
52
+ schemaVersion: 1;
53
+ namespace: string;
54
+ term: string;
55
+ revision: string;
56
+ }
57
+ type Reply = {
58
+ status: number;
59
+ json(): Promise<unknown>;
60
+ };
61
+ export interface ResourceGroupOptions {
62
+ namespace: string;
63
+ storage: GroupCoordinationStorage;
64
+ session(): Promise<{
65
+ binding: string;
66
+ context: string;
67
+ expiresAtMs: number;
68
+ }>;
69
+ wall(): number;
70
+ randomUUID(): string;
71
+ transport: {
72
+ subscribe(group: ResourceGroup, requestId: string): Promise<Reply>;
73
+ fetch(group: ResourceGroup, query: {
74
+ requestId: string;
75
+ cursor: string;
76
+ }): Promise<Reply>;
77
+ };
78
+ /** Must compare expected term/generation inside the host's deletion transaction. */
79
+ purge(input: ResourceGroup & {
80
+ resourceId: string;
81
+ action: 'revoke' | 'revalidate';
82
+ expected: GroupStamp;
83
+ assertCurrent(): Promise<void>;
84
+ }): Promise<{
85
+ status: 'purged' | 'blocked';
86
+ }>;
87
+ notify?(notice: GroupNotice): void | Promise<void>;
88
+ listen?(receive: (notice: unknown) => void): () => void;
89
+ pollIntervalMs?: number;
90
+ ownerLeaseMs?: number;
91
+ clone?<T>(value: T): T;
92
+ }
93
+ export interface ResourceGroupCoordinator {
94
+ register(input: ResourceGroup & {
95
+ resourceId: string;
96
+ }, invalidate?: (action: 'revoke' | 'revalidate') => void): Promise<() => void>;
97
+ prepare(group: ResourceGroup): Promise<GroupStamp>;
98
+ catchUp(group?: ResourceGroup): Promise<void>;
99
+ acceptCheck(input: ResourceGroup & {
100
+ resourceId: string;
101
+ }, anchor: GroupAnchor): Promise<GroupAnchor>;
102
+ granted(input: ResourceGroup & {
103
+ resourceId: string;
104
+ }, anchor: GroupAnchor): Promise<GroupStamp>;
105
+ guard(input: ResourceGroup & {
106
+ resourceId: string;
107
+ }, stamp: GroupStamp): Promise<void>;
108
+ guardMany(inputs: readonly {
109
+ input: ResourceGroup & {
110
+ resourceId: string;
111
+ };
112
+ stamp: GroupStamp;
113
+ }[]): Promise<boolean[]>;
114
+ stamp(group: ResourceGroup): Promise<GroupStamp>;
115
+ close(): Promise<void>;
116
+ }
117
+ /** Runtime-neutral orchestration; the synchronous dispatch port executes real Rust. */
118
+ export declare function createDrivenResourceGroup(options: ResourceGroupOptions, dispatch: (request: unknown) => unknown): ResourceGroupCoordinator;
119
+ export {};
@@ -0,0 +1,370 @@
1
+ /** Runtime-neutral orchestration; the synchronous dispatch port executes real Rust. */
2
+ export function createDrivenResourceGroup(options, dispatch) {
3
+ const clone = options.clone ?? ((v) => JSON.parse(JSON.stringify(v)));
4
+ const validId = (v) => typeof v === 'string' && v.length > 0 && v.length <= 4096;
5
+ if (!validId(options.namespace) || !options.storage || !options.purge)
6
+ throw Error('group_config_invalid');
7
+ const lease = options.ownerLeaseMs ?? 15000, interval = options.pollIntervalMs ?? 5000;
8
+ if (!Number.isSafeInteger(lease) || lease < 1000 || lease > 60000 || !Number.isSafeInteger(interval) || interval < 0 || interval >= lease)
9
+ throw Error('group_config_invalid');
10
+ const owner = options.randomUUID();
11
+ if (!validId(owner))
12
+ throw Error('group_config_invalid');
13
+ const groups = new Map(), membership = new Map();
14
+ let closed = false, lastWall = -1;
15
+ const clock = () => {
16
+ const value = options.wall();
17
+ if (!Number.isSafeInteger(value) || value < 0 || value < lastWall)
18
+ throw Error('clock_discontinuity');
19
+ lastWall = value;
20
+ return value;
21
+ };
22
+ const entry = (input) => {
23
+ if (closed)
24
+ throw Error('context_changed');
25
+ if (!input || !validId(input.binding) || !validId(input.resourceGroupId))
26
+ throw Error('group_identity_invalid');
27
+ const group = { binding: input.binding, resourceGroupId: input.resourceGroupId };
28
+ const key = JSON.stringify([options.namespace, 2, group.binding, group.resourceGroupId]);
29
+ let e = groups.get(key);
30
+ if (!e) {
31
+ if (groups.size >= 64)
32
+ throw Error('group_capacity');
33
+ e = { group, key, tail: Promise.resolve(), listeners: new Map() };
34
+ groups.set(key, e);
35
+ }
36
+ return e;
37
+ };
38
+ const current = async (e) => {
39
+ const actor = clone(await options.session());
40
+ if (closed || actor.binding !== e.group.binding || !validId(actor.context) || !Number.isSafeInteger(actor.expiresAtMs) || actor.expiresAtMs <= clock() || e.context && e.context !== actor.context)
41
+ throw Error('context_changed');
42
+ e.context ??= actor.context;
43
+ return actor;
44
+ };
45
+ const rust = (op, fields) => dispatch({ op, ...fields });
46
+ const stateOf = (e, row) => {
47
+ if (!row?.state || row.state.binding !== e.group.binding || row.state.resourceGroupId !== e.group.resourceGroupId)
48
+ throw Error('group_revalidation_required');
49
+ // The Rust guard validates the entire persisted checkpoint, even when no Resource is resident.
50
+ dispatch({ op: 'guard', state: row.state, resourceId: '', generation: row.state.generation });
51
+ if (!/^(0|[1-9][0-9]*)$/.test(row.term) || BigInt(row.term) > 18446744073709551615n)
52
+ throw Error('group_state_invalid');
53
+ if (row.owner && (!validId(row.owner.id) || !validId(row.owner.context) || !Number.isSafeInteger(row.owner.until) || row.owner.until < 0))
54
+ throw Error('group_state_invalid');
55
+ return row.state;
56
+ };
57
+ const makeStamp = (e, row) => {
58
+ const s = stateOf(e, row);
59
+ return { ...e.group, generation: s.generation, term: row.term, epoch: s.epoch, sequence: s.sequence };
60
+ };
61
+ const observed = (e, state, term) => {
62
+ const fence = JSON.stringify([term, state.generation]);
63
+ if (e.observed === fence)
64
+ return;
65
+ e.observed = fence;
66
+ for (const [id, listeners] of e.listeners) {
67
+ // Another process's fresh grant cannot revive this process's old permit or display.
68
+ const action = state.resources[id]?.intent ?? 'revalidate';
69
+ for (const listener of listeners) {
70
+ try {
71
+ listener(action);
72
+ }
73
+ catch { /* Store fence remains authoritative. */ }
74
+ }
75
+ }
76
+ };
77
+ const announce = (e, row) => {
78
+ const state = stateOf(e, row);
79
+ observed(e, state, row.term);
80
+ try {
81
+ void Promise.resolve(options.notify?.({ schemaVersion: 1, namespace: options.namespace, ...e.group, term: row.term, revision: state.revision })).catch(() => { });
82
+ }
83
+ catch { /* Wakeups cannot change decisions. */ }
84
+ };
85
+ const serial = (e, work) => {
86
+ const task = e.tail.then(work);
87
+ e.tail = task.catch(() => { });
88
+ return task;
89
+ };
90
+ const owned = (e, row, term) => {
91
+ if (!row?.owner || row.owner.id !== owner || row.owner.context !== e.context || row.term !== term || row.owner.until <= clock())
92
+ throw Error('group_owner_changed');
93
+ };
94
+ const claim = async (e) => {
95
+ const actor = await current(e), now = clock();
96
+ return options.storage.transaction(e.key, saved => {
97
+ if (closed)
98
+ throw Error('context_changed');
99
+ const row = clone(saved ?? { term: '0', owner: null, state: null });
100
+ if (row.owner && row.owner.until > now && (row.owner.id !== owner || row.owner.context !== actor.context))
101
+ return { row, result: null };
102
+ if (!row.owner || row.owner.until <= now || row.owner.id !== owner || row.owner.context !== actor.context) {
103
+ const next = BigInt(row.term) + 1n;
104
+ if (next > 18446744073709551615n)
105
+ throw Error('group_capacity');
106
+ row.term = String(next);
107
+ }
108
+ row.owner = { id: owner, context: actor.context, until: Math.min(now + lease, actor.expiresAtMs) };
109
+ return { row, result: row.term };
110
+ });
111
+ };
112
+ const update = async (e, term, work) => {
113
+ await current(e);
114
+ const row = await options.storage.transaction(e.key, saved => {
115
+ owned(e, saved, term);
116
+ const row = clone(saved);
117
+ row.state = work(row.state);
118
+ return { row, result: row };
119
+ });
120
+ await current(e);
121
+ announce(e, row);
122
+ return row;
123
+ };
124
+ const subscribe = async (e, term) => {
125
+ const requestId = options.randomUUID(), reply = await options.transport.subscribe(e.group, requestId);
126
+ await current(e);
127
+ if (reply.status !== 200)
128
+ throw Error('group_subscribe_failed');
129
+ const body = await reply.json();
130
+ await current(e);
131
+ if (body?.schemaVersion !== 2 || body.kind !== 'resource_group' || body.requestId !== requestId || body.binding !== e.group.binding || body.resourceGroupId !== e.group.resourceGroupId)
132
+ throw Error('group_response_invalid');
133
+ return update(e, term, state => state ? rust('reset', { state, position: body.position }) : rust('initialize', { identity: e.group, position: body.position }));
134
+ };
135
+ const fence = (e, term) => update(e, term, state => {
136
+ if (!state)
137
+ throw Error('group_revalidation_required');
138
+ return rust('fence', { state });
139
+ });
140
+ const recover = async (e, term) => {
141
+ const row = await options.storage.read(e.key), state = stateOf(e, row);
142
+ owned(e, row, term);
143
+ for (const [resourceId, r] of Object.entries(state.resources)) {
144
+ if (!r.intent)
145
+ continue;
146
+ const expected = makeStamp(e, row);
147
+ try {
148
+ const assertCurrent = async () => {
149
+ await current(e);
150
+ const latest = await options.storage.read(e.key);
151
+ owned(e, latest, term);
152
+ const s = stateOf(e, latest);
153
+ if (s.generation !== expected.generation || s.resources[resourceId]?.grantGeneration !== null || s.resources[resourceId]?.intent !== r.intent)
154
+ throw Error('group_check_raced');
155
+ };
156
+ await assertCurrent();
157
+ const outcome = await options.purge({ ...e.group, resourceId, action: r.intent, expected, assertCurrent });
158
+ if (outcome?.status === 'blocked')
159
+ continue;
160
+ if (outcome?.status !== 'purged')
161
+ throw Error('group_purge_invalid');
162
+ await assertCurrent();
163
+ await update(e, term, state => rust('purged', { state, resourceId, generation: expected.generation }));
164
+ }
165
+ catch { /* Intent is durable; one blocked/failed Resource must not stall the feed. */ }
166
+ }
167
+ };
168
+ const pull = async (e) => {
169
+ const term = await claim(e);
170
+ if (term === null) {
171
+ const row = await options.storage.read(e.key);
172
+ observed(e, stateOf(e, row), row.term);
173
+ return;
174
+ }
175
+ let row = await options.storage.read(e.key);
176
+ if (!row?.state)
177
+ row = await subscribe(e, term);
178
+ await recover(e, term);
179
+ for (let page = 0; page < 32; page++) {
180
+ row = await options.storage.read(e.key);
181
+ const state = stateOf(e, row);
182
+ owned(e, row, term);
183
+ const requestId = options.randomUUID();
184
+ let reply;
185
+ try {
186
+ reply = await options.transport.fetch(e.group, { requestId, cursor: state.cursor });
187
+ }
188
+ catch (error) {
189
+ if (!(error instanceof TypeError || error?.name === 'TimeoutError'))
190
+ await fence(e, term);
191
+ throw error;
192
+ }
193
+ await current(e);
194
+ if (reply.status === 409) {
195
+ await fence(e, term);
196
+ await subscribe(e, term);
197
+ return;
198
+ }
199
+ if (reply.status !== 200) {
200
+ await fence(e, term);
201
+ throw Error('group_fetch_failed');
202
+ }
203
+ try {
204
+ const body = await reply.json();
205
+ await current(e);
206
+ if (body?.schemaVersion !== 2 || body.kind !== 'resource_group' || body.requestId !== requestId || typeof body.hasMore !== 'boolean')
207
+ throw Error('group_response_invalid');
208
+ await update(e, term, latest => {
209
+ if (latest?.epoch !== state.epoch || latest.sequence !== state.sequence || latest.cursor !== state.cursor)
210
+ throw Error('group_check_raced');
211
+ return rust('changes', { state: latest, batch: body.batch });
212
+ });
213
+ await recover(e, term);
214
+ if (!body.hasMore)
215
+ return;
216
+ if (body.batch.throughSequence === body.batch.fromSequence)
217
+ throw Error('group_response_invalid');
218
+ }
219
+ catch (error) {
220
+ await fence(e, term);
221
+ throw error;
222
+ }
223
+ }
224
+ throw Error('group_page_limit');
225
+ };
226
+ const api = {
227
+ async prepare(group) {
228
+ const e = entry(group);
229
+ await current(e);
230
+ if (!(await options.storage.read(e.key))?.state)
231
+ await serial(e, () => pull(e));
232
+ return api.stamp(group);
233
+ },
234
+ async register(input, invalidate) {
235
+ const copy = clone(input);
236
+ if (!validId(copy.resourceId))
237
+ throw Error('group_resource_invalid');
238
+ const e = entry(copy);
239
+ const identity = JSON.stringify([copy.binding, copy.resourceId]), previous = membership.get(identity);
240
+ if (previous && previous !== e.key)
241
+ throw Error('group_membership_changed');
242
+ if (!previous && membership.size >= 64)
243
+ throw Error('group_capacity');
244
+ await api.prepare(copy);
245
+ await current(e);
246
+ const registered = await options.storage.transaction(e.key, saved => {
247
+ const state = stateOf(e, saved), row = clone(saved);
248
+ row.state = rust('register', { state, resourceId: copy.resourceId });
249
+ return { row, result: row };
250
+ });
251
+ observed(e, stateOf(e, registered), registered.term);
252
+ membership.set(identity, e.key);
253
+ let listeners = e.listeners.get(copy.resourceId);
254
+ if (!listeners) {
255
+ listeners = new Set();
256
+ e.listeners.set(copy.resourceId, listeners);
257
+ }
258
+ if (listeners.size >= 64)
259
+ throw Error('group_capacity');
260
+ const listener = invalidate ?? (() => { });
261
+ listeners.add(listener);
262
+ return () => { listeners.delete(listener); if (!listeners.size) {
263
+ e.listeners.delete(copy.resourceId);
264
+ membership.delete(identity);
265
+ } };
266
+ },
267
+ async catchUp(group) {
268
+ const entries = group ? [entry(group)] : [...groups.values()];
269
+ for (const e of entries) {
270
+ if (e.polling) {
271
+ await e.polling;
272
+ continue;
273
+ }
274
+ const task = serial(e, () => pull(e));
275
+ e.polling = task;
276
+ try {
277
+ await task;
278
+ }
279
+ finally {
280
+ if (e.polling === task)
281
+ e.polling = undefined;
282
+ }
283
+ }
284
+ },
285
+ async acceptCheck(input, anchor) {
286
+ const copy = clone(anchor), e = entry(input);
287
+ await current(e);
288
+ if (!copy || copy.resourceGroupId !== input.resourceGroupId)
289
+ throw Error('group_membership_changed');
290
+ await api.catchUp(input);
291
+ const state = stateOf(e, await options.storage.read(e.key));
292
+ // Rust validates canonical sequences, exact identity and the server watermark; this does not commit a grant.
293
+ rust('grant', { state, resourceId: input.resourceId, anchor: copy });
294
+ return copy;
295
+ },
296
+ async granted(input, anchor) {
297
+ const e = entry(input);
298
+ await current(e);
299
+ const row = await options.storage.transaction(e.key, saved => {
300
+ const state = stateOf(e, saved), row = clone(saved);
301
+ row.state = rust('grant', { state, resourceId: input.resourceId, anchor });
302
+ return { row, result: row };
303
+ });
304
+ await current(e);
305
+ return makeStamp(e, row);
306
+ },
307
+ async stamp(group) { const e = entry(group); await current(e); const row = await options.storage.read(e.key); await current(e); return makeStamp(e, row); },
308
+ async guard(input, stamp) {
309
+ const e = entry(input);
310
+ await current(e);
311
+ const row = await options.storage.read(e.key);
312
+ await current(e);
313
+ const state = stateOf(e, row);
314
+ observed(e, state, row.term);
315
+ if (!stamp || stamp.binding !== input.binding || stamp.resourceGroupId !== input.resourceGroupId || stamp.epoch !== state.epoch || stamp.term !== row.term
316
+ || dispatch({ op: 'guard', state, resourceId: input.resourceId, generation: stamp.generation }) !== true)
317
+ throw Error('group_revalidation_required');
318
+ },
319
+ async guardMany(inputs) {
320
+ const copies = clone(inputs), entries = copies.map(value => entry(value.input));
321
+ for (const e of new Set(entries))
322
+ await current(e);
323
+ const rows = await options.storage.readMany(entries.map(e => e.key));
324
+ if (closed || rows.length !== entries.length)
325
+ throw Error('context_changed');
326
+ return copies.map(({ input, stamp }, index) => {
327
+ const e = entries[index], state = stateOf(e, rows[index]);
328
+ observed(e, state, rows[index].term);
329
+ return !!stamp && stamp.binding === input.binding && stamp.resourceGroupId === input.resourceGroupId && stamp.epoch === state.epoch && stamp.term === rows[index].term && dispatch({ op: 'guard', state, resourceId: input.resourceId, generation: stamp.generation }) === true;
330
+ });
331
+ },
332
+ async close() {
333
+ if (closed)
334
+ return;
335
+ closed = true;
336
+ clearInterval(timer);
337
+ off?.();
338
+ await Promise.allSettled([...groups.values()].map(async (e) => {
339
+ await options.storage.transaction(e.key, saved => {
340
+ const row = clone(saved ?? { term: '0', owner: null, state: null });
341
+ if (row.owner?.id === owner)
342
+ row.owner = null;
343
+ return { row, result: null };
344
+ });
345
+ }));
346
+ groups.clear();
347
+ membership.clear();
348
+ },
349
+ };
350
+ const off = options.listen?.(notice => {
351
+ const n = notice;
352
+ if (closed || n?.schemaVersion !== 1 || n.namespace !== options.namespace || !validId(n.binding) || !validId(n.resourceGroupId))
353
+ return;
354
+ const key = JSON.stringify([options.namespace, 2, n.binding, n.resourceGroupId]), e = groups.get(key);
355
+ if (!e)
356
+ return;
357
+ void current(e).then(() => options.storage.read(key)).then(row => observed(e, stateOf(e, row), row.term)).catch(() => {
358
+ for (const listeners of e.listeners.values())
359
+ for (const listener of listeners) {
360
+ try {
361
+ listener('revalidate');
362
+ }
363
+ catch { }
364
+ }
365
+ });
366
+ });
367
+ const timer = interval ? setInterval(() => { void api.catchUp().catch(() => { }); }, interval) : undefined;
368
+ timer?.unref?.();
369
+ return api;
370
+ }
@@ -0,0 +1,6 @@
1
+ import { type ResourceGroupOptions } from './resource-group-driver.js';
2
+ export type * from './resource-group-driver.js';
3
+ /** Wasm transport only; Group protocol and transition validation execute Rust. */
4
+ export declare function createResourceGroupCoordinator(options: ResourceGroupOptions & {
5
+ wasmBytes?: Uint8Array<ArrayBuffer>;
6
+ }): Promise<import("./resource-group-driver.js").ResourceGroupCoordinator>;
@@ -0,0 +1,7 @@
1
+ import init, { dispatch } from '../wasm/authzflow_wasm.js';
2
+ import { createDrivenResourceGroup } from './resource-group-driver.js';
3
+ /** Wasm transport only; Group protocol and transition validation execute Rust. */
4
+ export async function createResourceGroupCoordinator(options) {
5
+ await init(options.wasmBytes ? { module_or_path: options.wasmBytes } : undefined);
6
+ return createDrivenResourceGroup(options, request => JSON.parse(dispatch(JSON.stringify({ op: 'group', request }))));
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@authzflow/core",
3
- "version": "0.1.0-alpha.8",
3
+ "version": "0.1.0-beta.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "author": "Accelflow Inc.",
@@ -36,6 +36,22 @@
36
36
  "./native-reader": {
37
37
  "types": "./dist/native-reader.d.ts",
38
38
  "import": "./dist/native-reader.js"
39
+ },
40
+ "./resource-group": {
41
+ "types": "./dist/resource-group.d.ts",
42
+ "import": "./dist/resource-group.js"
43
+ },
44
+ "./group-sqlite": {
45
+ "types": "./dist/group-sqlite.d.ts",
46
+ "import": "./dist/group-sqlite.js"
47
+ },
48
+ "./group-indexeddb": {
49
+ "types": "./dist/group-indexeddb.d.ts",
50
+ "import": "./dist/group-indexeddb.js"
51
+ },
52
+ "./group-notifications": {
53
+ "types": "./dist/group-notifications.d.ts",
54
+ "import": "./dist/group-notifications.js"
39
55
  }
40
56
  },
41
57
  "files": [
Binary file