sh3-core 0.9.1 → 0.10.2

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/Shell.svelte CHANGED
@@ -104,7 +104,13 @@
104
104
  // Forwards server-side revocations to the local revocation bus so that
105
105
  // onKeyRevoked fires on the owning shard even when the user revokes from
106
106
  // the Keys & Peers shell view (not via the shard's own ctx.keys.revoke).
107
+ //
108
+ // Gated on isAuthenticated() because /api/keys/events requires a tenant
109
+ // (via tenantRequired middleware) — opening it as a guest produces a 401
110
+ // that Firefox surfaces as "can't establish a connection." The effect
111
+ // re-runs on login/logout thanks to currentSession being $state.
107
112
  $effect(() => {
113
+ if (!isAuthenticated()) return;
108
114
  const stop = startServerSideStream();
109
115
  return stop;
110
116
  });
package/dist/api.d.ts CHANGED
@@ -19,6 +19,7 @@ export { inspectActiveLayout, spliceIntoActiveLayout, dockIntoActiveLayout, focu
19
19
  export type { DocumentHandle, DocumentHandleOptions, DocumentFormat, DocumentMeta, DocumentChange, AutosaveController, } from './documents/types';
20
20
  export { PERMISSION_DOCUMENTS_BROWSE, PERMISSION_DOCUMENTS_READ, PERMISSION_DOCUMENTS_WRITE, } from './documents/types';
21
21
  export type { BrowseCapability } from './documents/browse';
22
+ export type { ContributionsApi } from './contributions/types';
22
23
  export type { SyncPolicy, SyncPolicyRule, DocStatus, ConflictFile, ConflictBranch, } from './documents/sync-types';
23
24
  export { PERMISSION_SYNC_PEER, PERMISSION_SYNC_POLICY } from './documents/sync-types';
24
25
  export { registeredShards, activeShards } from './shards/activate.svelte';
@@ -0,0 +1,2 @@
1
+ export type { ContributionsApi } from './types';
2
+ export { register, list, listPoints, onChange, __resetContributionsForTest } from './registry';
@@ -0,0 +1,8 @@
1
+ /*
2
+ * Contribution-points barrel — internal re-exports.
3
+ *
4
+ * The public type (ContributionsApi) reaches shards via api.ts; this
5
+ * file is internal-only, re-exporting the registry for activate.svelte.ts
6
+ * and for tests.
7
+ */
8
+ export { register, list, listPoints, onChange, __resetContributionsForTest } from './registry';
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Register a descriptor under the given point. Returns an unregister
3
+ * function; calling it more than once is a safe no-op.
4
+ */
5
+ export declare function register<T = unknown>(pointId: string, descriptor: T): () => void;
6
+ /** Enumerate descriptors at the named point in registration order. */
7
+ export declare function list<T = unknown>(pointId: string): T[];
8
+ /** Enumerate every point id with at least one registration. */
9
+ export declare function listPoints(): string[];
10
+ /**
11
+ * Subscribe to registration changes at the named point. The callback
12
+ * receives no arguments — subscribers call `list` themselves to read
13
+ * the current state. Returns an unsubscribe; double-unsubscribe is a
14
+ * safe no-op.
15
+ */
16
+ export declare function onChange(pointId: string, cb: () => void): () => void;
17
+ /**
18
+ * Test-only reset. Not exported from the barrel; tests import it
19
+ * directly from this module.
20
+ */
21
+ export declare function __resetContributionsForTest(): void;
@@ -0,0 +1,89 @@
1
+ /*
2
+ * Contribution point registry — shard-to-shard runtime collaboration.
3
+ *
4
+ * One module-level map holds every point's descriptors. Handles are
5
+ * Symbols so two registrations with identical content stay distinct.
6
+ * Emitters are a Set of zero-arg callbacks; subscribers re-read `list`
7
+ * themselves when notified.
8
+ *
9
+ * No tenant partitioning: the shell is single-tenant per session; a
10
+ * future multi-tenant client would add a tenant dimension to the
11
+ * outer map (see direction spec §5.2).
12
+ */
13
+ const points = new Map();
14
+ const listeners = new Map();
15
+ function emit(pointId) {
16
+ const set = listeners.get(pointId);
17
+ if (!set)
18
+ return;
19
+ for (const cb of set)
20
+ cb();
21
+ }
22
+ /**
23
+ * Register a descriptor under the given point. Returns an unregister
24
+ * function; calling it more than once is a safe no-op.
25
+ */
26
+ export function register(pointId, descriptor) {
27
+ const handle = Symbol();
28
+ let map = points.get(pointId);
29
+ if (!map) {
30
+ map = new Map();
31
+ points.set(pointId, map);
32
+ }
33
+ map.set(handle, descriptor);
34
+ emit(pointId);
35
+ let disposed = false;
36
+ return () => {
37
+ if (disposed)
38
+ return;
39
+ disposed = true;
40
+ const m = points.get(pointId);
41
+ if (!m)
42
+ return;
43
+ if (m.delete(handle)) {
44
+ if (m.size === 0)
45
+ points.delete(pointId);
46
+ emit(pointId);
47
+ }
48
+ };
49
+ }
50
+ /** Enumerate descriptors at the named point in registration order. */
51
+ export function list(pointId) {
52
+ const m = points.get(pointId);
53
+ return m ? Array.from(m.values()) : [];
54
+ }
55
+ /** Enumerate every point id with at least one registration. */
56
+ export function listPoints() {
57
+ return Array.from(points.keys());
58
+ }
59
+ /**
60
+ * Subscribe to registration changes at the named point. The callback
61
+ * receives no arguments — subscribers call `list` themselves to read
62
+ * the current state. Returns an unsubscribe; double-unsubscribe is a
63
+ * safe no-op.
64
+ */
65
+ export function onChange(pointId, cb) {
66
+ let set = listeners.get(pointId);
67
+ if (!set) {
68
+ set = new Set();
69
+ listeners.set(pointId, set);
70
+ }
71
+ set.add(cb);
72
+ let disposed = false;
73
+ return () => {
74
+ if (disposed)
75
+ return;
76
+ disposed = true;
77
+ set.delete(cb);
78
+ if (set.size === 0)
79
+ listeners.delete(pointId);
80
+ };
81
+ }
82
+ /**
83
+ * Test-only reset. Not exported from the barrel; tests import it
84
+ * directly from this module.
85
+ */
86
+ export function __resetContributionsForTest() {
87
+ points.clear();
88
+ listeners.clear();
89
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,109 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { register, list, listPoints, onChange, __resetContributionsForTest, } from './registry';
3
+ describe('contributions registry', () => {
4
+ beforeEach(() => {
5
+ __resetContributionsForTest();
6
+ });
7
+ describe('register / list', () => {
8
+ it('returns an unregister function', () => {
9
+ const unreg = register('p', { id: 'a' });
10
+ expect(typeof unreg).toBe('function');
11
+ });
12
+ it('register then list returns the descriptor', () => {
13
+ register('p', { id: 'a' });
14
+ expect(list('p')).toEqual([{ id: 'a' }]);
15
+ });
16
+ it('lists multiple descriptors in registration order', () => {
17
+ register('p', { id: 'a' });
18
+ register('p', { id: 'b' });
19
+ register('p', { id: 'c' });
20
+ expect(list('p').map((d) => d.id)).toEqual(['a', 'b', 'c']);
21
+ });
22
+ it('separates descriptors by pointId', () => {
23
+ register('p1', { id: 'a' });
24
+ register('p2', { id: 'b' });
25
+ expect(list('p1')).toEqual([{ id: 'a' }]);
26
+ expect(list('p2')).toEqual([{ id: 'b' }]);
27
+ });
28
+ it('returns an empty array for an unknown pointId', () => {
29
+ expect(list('nope')).toEqual([]);
30
+ });
31
+ it('unregister removes the descriptor', () => {
32
+ const unreg = register('p', { id: 'a' });
33
+ unreg();
34
+ expect(list('p')).toEqual([]);
35
+ });
36
+ it('double-unregister is a no-op', () => {
37
+ const unreg = register('p', { id: 'a' });
38
+ unreg();
39
+ unreg();
40
+ expect(list('p')).toEqual([]);
41
+ });
42
+ it('allows duplicate descriptors (framework does not inspect content)', () => {
43
+ register('p', { id: 'a' });
44
+ register('p', { id: 'a' });
45
+ expect(list('p')).toHaveLength(2);
46
+ });
47
+ });
48
+ describe('listPoints', () => {
49
+ it('returns empty when nothing is registered', () => {
50
+ expect(listPoints()).toEqual([]);
51
+ });
52
+ it('returns every pointId with at least one registration', () => {
53
+ register('p1', { id: 'a' });
54
+ register('p2', { id: 'b' });
55
+ expect(listPoints().sort()).toEqual(['p1', 'p2']);
56
+ });
57
+ it('excludes pointIds whose last registration was unregistered', () => {
58
+ const u1 = register('p1', { id: 'a' });
59
+ register('p2', { id: 'b' });
60
+ u1();
61
+ expect(listPoints()).toEqual(['p2']);
62
+ });
63
+ });
64
+ describe('onChange', () => {
65
+ it('fires on register', () => {
66
+ const cb = vi.fn();
67
+ onChange('p', cb);
68
+ register('p', { id: 'a' });
69
+ expect(cb).toHaveBeenCalledTimes(1);
70
+ });
71
+ it('fires on unregister', () => {
72
+ const cb = vi.fn();
73
+ const unreg = register('p', { id: 'a' });
74
+ onChange('p', cb);
75
+ unreg();
76
+ expect(cb).toHaveBeenCalledTimes(1);
77
+ });
78
+ it('does not fire for other pointIds', () => {
79
+ const cb = vi.fn();
80
+ onChange('p1', cb);
81
+ register('p2', { id: 'a' });
82
+ expect(cb).not.toHaveBeenCalled();
83
+ });
84
+ it('supports multiple subscribers', () => {
85
+ const a = vi.fn();
86
+ const b = vi.fn();
87
+ onChange('p', a);
88
+ onChange('p', b);
89
+ register('p', { id: 'x' });
90
+ expect(a).toHaveBeenCalledTimes(1);
91
+ expect(b).toHaveBeenCalledTimes(1);
92
+ });
93
+ it('unsubscribe stops callbacks', () => {
94
+ const cb = vi.fn();
95
+ const off = onChange('p', cb);
96
+ off();
97
+ register('p', { id: 'x' });
98
+ expect(cb).not.toHaveBeenCalled();
99
+ });
100
+ it('double-unsubscribe is a no-op', () => {
101
+ const cb = vi.fn();
102
+ const off = onChange('p', cb);
103
+ off();
104
+ off();
105
+ register('p', { id: 'x' });
106
+ expect(cb).not.toHaveBeenCalled();
107
+ });
108
+ });
109
+ });
@@ -0,0 +1,24 @@
1
+ export interface ContributionsApi {
2
+ /**
3
+ * Register `descriptor` under `pointId`. Descriptors are freeform;
4
+ * the framework does not inspect them. The type parameter exists
5
+ * for ergonomics — provider and contributor agree on the shape via
6
+ * a type-only import of the provider's public types.
7
+ *
8
+ * Returns an unregister function. Calling it is optional (the
9
+ * framework auto-unregisters on shard deactivate) and safe to call
10
+ * more than once.
11
+ */
12
+ register<T = unknown>(pointId: string, descriptor: T): () => void;
13
+ /** Enumerate descriptors at `pointId` in registration order. */
14
+ list<T = unknown>(pointId: string): T[];
15
+ /** Enumerate every point id with at least one registration. */
16
+ listPoints(): string[];
17
+ /**
18
+ * Subscribe to registration changes at `pointId`. The callback
19
+ * receives no arguments — call `list` from inside to read the
20
+ * current state. Returns an unsubscribe; auto-unsubscribed on
21
+ * shard deactivate.
22
+ */
23
+ onChange(pointId: string, cb: () => void): () => void;
24
+ }
@@ -0,0 +1,10 @@
1
+ /*
2
+ * ContributionsApi — the per-shard surface exposed on ShardContext.
3
+ *
4
+ * See docs/sh3-rfcs/2026-04-20-shard-contribution-points.md for
5
+ * motivation and semantics. Every registration and every onChange
6
+ * subscription made through this API is auto-cleaned when the owning
7
+ * shard deactivates; callers should not need to call the returned
8
+ * disposer unless they want to release early.
9
+ */
10
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { MemoryDocumentBackend } from '../documents/backends';
3
+ import { __setDocumentBackend, __setTenantId } from '../documents/config';
4
+ import { registerShard, activateShard, deactivateShard, __resetShardRegistryForTest, } from './activate.svelte';
5
+ import { __resetContributionsForTest, list, listPoints } from '../contributions';
6
+ describe('ctx.contributions', () => {
7
+ beforeEach(() => {
8
+ __resetShardRegistryForTest();
9
+ __resetContributionsForTest();
10
+ __setDocumentBackend(new MemoryDocumentBackend());
11
+ __setTenantId('tenant-a');
12
+ });
13
+ it('is always present on ShardContext (no permission required)', async () => {
14
+ let captured = null;
15
+ registerShard({
16
+ manifest: { id: 'a', label: 'A', version: '0.0.0', views: [] },
17
+ activate(ctx) { captured = ctx; },
18
+ });
19
+ await activateShard('a');
20
+ expect(typeof captured.contributions.register).toBe('function');
21
+ expect(typeof captured.contributions.list).toBe('function');
22
+ expect(typeof captured.contributions.listPoints).toBe('function');
23
+ expect(typeof captured.contributions.onChange).toBe('function');
24
+ });
25
+ it('register() makes the descriptor visible via the global list()', async () => {
26
+ registerShard({
27
+ manifest: { id: 'provider', label: 'P', version: '0.0.0', views: [] },
28
+ activate(ctx) {
29
+ ctx.contributions.register('my.point', { id: 'alpha' });
30
+ },
31
+ });
32
+ await activateShard('provider');
33
+ expect(list('my.point')).toEqual([{ id: 'alpha' }]);
34
+ });
35
+ it('deactivate auto-unregisters every descriptor the shard registered', async () => {
36
+ registerShard({
37
+ manifest: { id: 'provider', label: 'P', version: '0.0.0', views: [] },
38
+ activate(ctx) {
39
+ ctx.contributions.register('p1', { id: 'a' });
40
+ ctx.contributions.register('p1', { id: 'b' });
41
+ ctx.contributions.register('p2', { id: 'c' });
42
+ },
43
+ });
44
+ await activateShard('provider');
45
+ expect(list('p1')).toHaveLength(2);
46
+ expect(list('p2')).toHaveLength(1);
47
+ deactivateShard('provider');
48
+ expect(list('p1')).toEqual([]);
49
+ expect(list('p2')).toEqual([]);
50
+ expect(listPoints()).toEqual([]);
51
+ });
52
+ it('deactivate auto-unsubscribes every onChange listener the shard registered', async () => {
53
+ const cb = vi.fn();
54
+ registerShard({
55
+ manifest: { id: 'watcher', label: 'W', version: '0.0.0', views: [] },
56
+ activate(ctx) {
57
+ ctx.contributions.onChange('p', cb);
58
+ },
59
+ });
60
+ await activateShard('watcher');
61
+ registerShard({
62
+ manifest: { id: 'provider', label: 'P', version: '0.0.0', views: [] },
63
+ activate(ctx) {
64
+ ctx.contributions.register('p', { id: 'x' });
65
+ },
66
+ });
67
+ await activateShard('provider');
68
+ expect(cb).toHaveBeenCalledTimes(1);
69
+ deactivateShard('watcher');
70
+ // Further registrations must not reach the watcher's callback.
71
+ registerShard({
72
+ manifest: { id: 'provider-2', label: 'P2', version: '0.0.0', views: [] },
73
+ activate(ctx) {
74
+ ctx.contributions.register('p', { id: 'y' });
75
+ },
76
+ });
77
+ await activateShard('provider-2');
78
+ expect(cb).toHaveBeenCalledTimes(1);
79
+ });
80
+ it('two shards can contribute to the same point independently', async () => {
81
+ registerShard({
82
+ manifest: { id: 'a', label: 'A', version: '0.0.0', views: [] },
83
+ activate(ctx) { ctx.contributions.register('shared', { from: 'a' }); },
84
+ });
85
+ registerShard({
86
+ manifest: { id: 'b', label: 'B', version: '0.0.0', views: [] },
87
+ activate(ctx) { ctx.contributions.register('shared', { from: 'b' }); },
88
+ });
89
+ await activateShard('a');
90
+ await activateShard('b');
91
+ expect(list('shared')).toEqual([{ from: 'a' }, { from: 'b' }]);
92
+ deactivateShard('a');
93
+ expect(list('shared')).toEqual([{ from: 'b' }]);
94
+ });
95
+ it('explicit unregister returned by register() is safe alongside auto-cleanup', async () => {
96
+ let earlyUnregister;
97
+ registerShard({
98
+ manifest: { id: 'p', label: 'P', version: '0.0.0', views: [] },
99
+ activate(ctx) {
100
+ earlyUnregister = ctx.contributions.register('p', { id: 'a' });
101
+ },
102
+ });
103
+ await activateShard('p');
104
+ expect(list('p')).toHaveLength(1);
105
+ earlyUnregister();
106
+ expect(list('p')).toEqual([]);
107
+ // Deactivate must not throw when the entry is already gone.
108
+ expect(() => deactivateShard('p')).not.toThrow();
109
+ });
110
+ });
@@ -28,6 +28,7 @@ import { createBrowseCapability } from '../documents/browse';
28
28
  import { createShardKeysApi } from '../keys/client';
29
29
  import { PERMISSION_KEYS_MINT } from '../keys/types';
30
30
  import { subscribe } from '../keys/revocation-bus.svelte';
31
+ import { register as contributionsRegister, list as contributionsList, listPoints as contributionsListPoints, onChange as contributionsOnChange, } from '../contributions';
31
32
  /**
32
33
  * Reactive registry of every shard known to the host. Keys are shard ids.
33
34
  * Populated once at boot by the glob-discovery loop in main.ts (through
@@ -87,6 +88,27 @@ export async function activateShard(id) {
87
88
  proxy: null,
88
89
  defaults: null,
89
90
  });
91
+ // Per-shard wrapper: every register/onChange call goes into the
92
+ // global registry, and its disposer is pushed into entry.cleanupFns
93
+ // so deactivate auto-unregisters.
94
+ const contributions = {
95
+ register(pointId, descriptor) {
96
+ const dispose = contributionsRegister(pointId, descriptor);
97
+ entry.cleanupFns.push(async () => dispose());
98
+ return dispose;
99
+ },
100
+ list(pointId) {
101
+ return contributionsList(pointId);
102
+ },
103
+ listPoints() {
104
+ return contributionsListPoints();
105
+ },
106
+ onChange(pointId, cb) {
107
+ const off = contributionsOnChange(pointId, cb);
108
+ entry.cleanupFns.push(async () => off());
109
+ return off;
110
+ },
111
+ };
90
112
  const ctx = {
91
113
  state: (schema) => shell.state(id, schema),
92
114
  registerView: (viewId, factory) => {
@@ -148,6 +170,7 @@ export async function activateShard(id) {
148
170
  shardPermissions: (_d = shard.manifest.permissions) !== null && _d !== void 0 ? _d : [],
149
171
  })
150
172
  : undefined,
173
+ contributions,
151
174
  };
152
175
  entry.ctx = ctx;
153
176
  // Wire onKeyRevoked hook: subscribe to the revocation bus for this shard.
@@ -5,6 +5,7 @@ import type { BrowseCapability } from '../documents/browse';
5
5
  import type { EnvState } from '../env/types';
6
6
  import type { Verb } from '../verbs/types';
7
7
  import type { ShardContextKeys } from '../keys/types';
8
+ import type { ContributionsApi } from '../contributions/types';
8
9
  export { PERMISSION_KEYS_MINT, type ShardContextKeys, type ApiKeyPublic, type MintOpts, ScopeEscalationError, ConsentDeniedError } from '../keys/types';
9
10
  /**
10
11
  * The object returned by `ViewFactory.mount`. The framework calls
@@ -225,6 +226,12 @@ export interface ShardContext {
225
226
  * manifest declares the `keys:mint` permission.
226
227
  */
227
228
  keys?: ShardContextKeys;
229
+ /**
230
+ * Runtime registry for inter-shard contribution points. Every
231
+ * shard receives this — no permission gate in v1. See
232
+ * docs/sh3-rfcs/2026-04-20-shard-contribution-points.md.
233
+ */
234
+ contributions: ContributionsApi;
228
235
  }
229
236
  /**
230
237
  * A shard module. Shards are the fundamental unit of contribution in SH3.
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Auto-generated from package.json — do not edit manually. */
2
- export declare const VERSION = "0.9.1";
2
+ export declare const VERSION = "0.10.2";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Auto-generated from package.json — do not edit manually. */
2
- export const VERSION = '0.9.1';
2
+ export const VERSION = '0.10.2';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh3-core",
3
- "version": "0.9.1",
3
+ "version": "0.10.2",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"