sh3-core 0.8.1 → 0.9.0
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 +19 -0
- package/dist/api.d.ts +6 -6
- package/dist/api.js +6 -3
- package/dist/app/admin/ApiKeysView.svelte +16 -27
- package/dist/apps/types.d.ts +3 -5
- package/dist/documents/backends.d.ts +2 -0
- package/dist/documents/backends.js +6 -0
- package/dist/documents/handle.js +13 -5
- package/dist/documents/handle.test.js +55 -0
- package/dist/documents/http-backend.d.ts +11 -4
- package/dist/documents/http-backend.js +37 -11
- package/dist/documents/index.d.ts +2 -1
- package/dist/documents/index.js +1 -1
- package/dist/documents/sync-types.d.ts +45 -0
- package/dist/documents/sync-types.js +11 -0
- package/dist/documents/types.d.ts +40 -2
- package/dist/documents/types.js +3 -2
- package/dist/keys/ConsentDialog.svelte +176 -0
- package/dist/keys/ConsentDialog.svelte.d.ts +3 -0
- package/dist/keys/client.d.ts +13 -0
- package/dist/keys/client.js +65 -0
- package/dist/keys/client.test.js +44 -0
- package/dist/keys/consent.svelte.d.ts +16 -0
- package/dist/keys/consent.svelte.js +29 -0
- package/dist/keys/consent.test.js +54 -0
- package/dist/keys/revocation-bus.svelte.d.ts +35 -0
- package/dist/keys/revocation-bus.svelte.js +92 -0
- package/dist/keys/revocation-bus.test.js +95 -0
- package/dist/keys/types.d.ts +34 -0
- package/dist/keys/types.js +13 -0
- package/dist/server-shard/types.d.ts +68 -2
- package/dist/sh3core-shard/ShellHome.svelte +140 -63
- package/dist/sh3core-shard/sh3coreShard.svelte.js +12 -1
- package/dist/shards/activate-on-key-revoked.test.js +60 -0
- package/dist/shards/activate.svelte.js +21 -24
- package/dist/shards/types.d.ts +7 -13
- package/dist/shards/types.js +1 -1
- package/dist/shell/views/KeysAndPeers.svelte +110 -0
- package/dist/shell/views/KeysAndPeers.svelte.d.ts +3 -0
- package/dist/shell-shard/Terminal.svelte +0 -11
- package/dist/shell-shard/toolbar/Toolbar.svelte +11 -32
- package/dist/shell-shard/toolbar/Toolbar.svelte.d.ts +0 -2
- package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +29 -62
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/documents/journal-hook.d.ts +0 -6
- package/dist/documents/journal-hook.js +0 -16
- package/dist/documents/sync/activate-integration.test.js +0 -37
- package/dist/documents/sync/components/DocumentSyncExplorer.svelte +0 -99
- package/dist/documents/sync/components/DocumentSyncExplorer.svelte.d.ts +0 -15
- package/dist/documents/sync/components/SyncGrantPicker.svelte +0 -70
- package/dist/documents/sync/components/SyncGrantPicker.svelte.d.ts +0 -12
- package/dist/documents/sync/conflicts.d.ts +0 -30
- package/dist/documents/sync/conflicts.js +0 -77
- package/dist/documents/sync/conflicts.test.js +0 -71
- package/dist/documents/sync/engine.d.ts +0 -19
- package/dist/documents/sync/engine.js +0 -188
- package/dist/documents/sync/engine.test.js +0 -169
- package/dist/documents/sync/handle.d.ts +0 -11
- package/dist/documents/sync/handle.js +0 -79
- package/dist/documents/sync/handle.test.js +0 -56
- package/dist/documents/sync/hash.d.ts +0 -1
- package/dist/documents/sync/hash.js +0 -13
- package/dist/documents/sync/hash.test.js +0 -20
- package/dist/documents/sync/index.d.ts +0 -5
- package/dist/documents/sync/index.js +0 -10
- package/dist/documents/sync/journal.d.ts +0 -30
- package/dist/documents/sync/journal.js +0 -179
- package/dist/documents/sync/journal.test.d.ts +0 -1
- package/dist/documents/sync/journal.test.js +0 -87
- package/dist/documents/sync/observer.d.ts +0 -3
- package/dist/documents/sync/observer.js +0 -45
- package/dist/documents/sync/registry.d.ts +0 -13
- package/dist/documents/sync/registry.js +0 -73
- package/dist/documents/sync/registry.test.d.ts +0 -1
- package/dist/documents/sync/registry.test.js +0 -53
- package/dist/documents/sync/serialization.d.ts +0 -5
- package/dist/documents/sync/serialization.js +0 -24
- package/dist/documents/sync/serialization.test.d.ts +0 -1
- package/dist/documents/sync/serialization.test.js +0 -26
- package/dist/documents/sync/singleton.d.ts +0 -11
- package/dist/documents/sync/singleton.js +0 -26
- package/dist/documents/sync/tombstones.d.ts +0 -19
- package/dist/documents/sync/tombstones.js +0 -58
- package/dist/documents/sync/tombstones.test.d.ts +0 -1
- package/dist/documents/sync/tombstones.test.js +0 -37
- package/dist/documents/sync/types.d.ts +0 -116
- package/dist/documents/sync/types.js +0 -27
- package/dist/documents/sync/write-hook.test.d.ts +0 -1
- package/dist/documents/sync/write-hook.test.js +0 -36
- package/dist/shards/activate-sync-registry.test.d.ts +0 -1
- package/dist/shards/activate-sync-registry.test.js +0 -42
- /package/dist/documents/{sync/handle.test.d.ts → handle.test.d.ts} +0 -0
- /package/dist/{documents/sync/activate-integration.test.d.ts → keys/client.test.d.ts} +0 -0
- /package/dist/{documents/sync/conflicts.test.d.ts → keys/consent.test.d.ts} +0 -0
- /package/dist/{documents/sync/engine.test.d.ts → keys/revocation-bus.test.d.ts} +0 -0
- /package/dist/{documents/sync/hash.test.d.ts → shards/activate-on-key-revoked.test.d.ts} +0 -0
package/dist/Shell.svelte
CHANGED
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
import { isAuthenticated, isLocalOwner, getUser, logout } from './auth/index';
|
|
26
26
|
import iconsUrl from './assets/icons.svg';
|
|
27
27
|
import GuestBanner from './auth/GuestBanner.svelte';
|
|
28
|
+
import ConsentDialog from './keys/ConsentDialog.svelte';
|
|
29
|
+
import { startServerSideStream } from './keys/revocation-bus.svelte';
|
|
28
30
|
|
|
29
31
|
const authenticated = $derived(isAuthenticated());
|
|
30
32
|
const user = $derived(getUser());
|
|
@@ -97,6 +99,15 @@
|
|
|
97
99
|
}));
|
|
98
100
|
return () => unbindFloatStore();
|
|
99
101
|
});
|
|
102
|
+
|
|
103
|
+
// Open the server-sent events stream for key revocations.
|
|
104
|
+
// Forwards server-side revocations to the local revocation bus so that
|
|
105
|
+
// onKeyRevoked fires on the owning shard even when the user revokes from
|
|
106
|
+
// the Keys & Peers shell view (not via the shard's own ctx.keys.revoke).
|
|
107
|
+
$effect(() => {
|
|
108
|
+
const stop = startServerSideStream();
|
|
109
|
+
return stop;
|
|
110
|
+
});
|
|
100
111
|
</script>
|
|
101
112
|
|
|
102
113
|
<div class="shell">
|
|
@@ -165,6 +176,14 @@
|
|
|
165
176
|
</div>
|
|
166
177
|
{/each}
|
|
167
178
|
</div>
|
|
179
|
+
|
|
180
|
+
<!--
|
|
181
|
+
Shell-owned consent dialog for ctx.keys.mint().
|
|
182
|
+
Mounted unconditionally so the listener is always registered; it renders
|
|
183
|
+
nothing until a shard calls ctx.keys.mint().
|
|
184
|
+
z-index 9999 (inline in the component) keeps it above all overlay layers.
|
|
185
|
+
-->
|
|
186
|
+
<ConsentDialog />
|
|
168
187
|
</div>
|
|
169
188
|
|
|
170
189
|
<style>
|
package/dist/api.d.ts
CHANGED
|
@@ -19,16 +19,16 @@ export { inspectActiveLayout, spliceIntoActiveLayout, dockIntoActiveLayout, focu
|
|
|
19
19
|
export type { DocumentHandle, DocumentHandleOptions, DocumentFormat, DocumentMeta, DocumentChange, AutosaveController, } from './documents/types';
|
|
20
20
|
export { PERMISSION_DOCUMENTS_BROWSE } from './documents/types';
|
|
21
21
|
export type { BrowseCapability } from './documents/browse';
|
|
22
|
-
export type {
|
|
23
|
-
export {
|
|
24
|
-
export type { SyncRegistry } from './documents/sync/registry';
|
|
25
|
-
export { default as SyncGrantPicker } from './documents/sync/components/SyncGrantPicker.svelte';
|
|
26
|
-
export { default as DocumentSyncExplorer } from './documents/sync/components/DocumentSyncExplorer.svelte';
|
|
22
|
+
export type { SyncPolicy, SyncPolicyRule, DocStatus, ConflictFile, ConflictBranch, } from './documents/sync-types';
|
|
23
|
+
export { PERMISSION_SYNC_PEER, PERMISSION_SYNC_POLICY } from './documents/sync-types';
|
|
27
24
|
export { registeredShards, activeShards } from './shards/activate.svelte';
|
|
28
25
|
export type { RegistryIndex, PackageEntry, PackageVersion, RequiredDependency, InstalledPackage, InstallResult, PackageMeta, } from './registry/types';
|
|
29
26
|
export type { ResolvedPackage } from './registry/client';
|
|
30
27
|
export { fetchRegistries, fetchBundle, buildPackageMeta } from './registry/client';
|
|
31
28
|
export { validateRegistryIndex } from './registry/schema';
|
|
29
|
+
export { PERMISSION_KEYS_MINT, ScopeEscalationError, ConsentDeniedError, type ShardContextKeys, type ApiKeyPublic, type MintOpts, } from './keys/types';
|
|
30
|
+
export { registerConsentListener, resolveConsent, type ConsentRequest } from './keys/consent.svelte';
|
|
31
|
+
export { subscribe as subscribeKeyRevocation } from './keys/revocation-bus.svelte';
|
|
32
32
|
export { isAdmin, isAuthenticated, isGuest, getUser, getAuthHeader } from './auth/index';
|
|
33
33
|
export type { AuthUser, AuthSession, BootConfig } from './auth/types';
|
|
34
34
|
/** Runtime feature flags for target-dependent behavior. */
|
|
@@ -36,7 +36,7 @@ export declare const capabilities: {
|
|
|
36
36
|
/** Whether this target supports hot-installing packages via dynamic import from blob URL. */
|
|
37
37
|
readonly hotInstall: boolean;
|
|
38
38
|
};
|
|
39
|
-
export type { ServerShard, ServerShardContext } from './server-shard/types';
|
|
39
|
+
export type { ServerShard, ServerShardContext, TenantDocumentAPI } from './server-shard/types';
|
|
40
40
|
export type { Verb, VerbContext, ShellApi } from './verbs/types';
|
|
41
41
|
export type { Scrollback } from './shell-shard/scrollback.svelte';
|
|
42
42
|
export type { SessionClient } from './shell-shard/session-client.svelte';
|
package/dist/api.js
CHANGED
|
@@ -30,9 +30,7 @@ export { launchApp, returnToHome, unregisterApp } from './apps/lifecycle';
|
|
|
30
30
|
// Layout inspection / mutation for advanced shards (diagnostic, etc.).
|
|
31
31
|
export { inspectActiveLayout, spliceIntoActiveLayout, dockIntoActiveLayout, focusTab, focusView, collapseChild, expandChild, closeTab, } from './layout/inspection';
|
|
32
32
|
export { PERMISSION_DOCUMENTS_BROWSE } from './documents/types';
|
|
33
|
-
export {
|
|
34
|
-
export { default as SyncGrantPicker } from './documents/sync/components/SyncGrantPicker.svelte';
|
|
35
|
-
export { default as DocumentSyncExplorer } from './documents/sync/components/DocumentSyncExplorer.svelte';
|
|
33
|
+
export { PERMISSION_SYNC_PEER, PERMISSION_SYNC_POLICY } from './documents/sync-types';
|
|
36
34
|
// Shard introspection — read-only reactive maps exposing which shards are
|
|
37
35
|
// known to the host and which are currently active. Intended for diagnostic
|
|
38
36
|
// and tooling shards that need to visualize framework state. Phase 9
|
|
@@ -41,6 +39,11 @@ export { default as DocumentSyncExplorer } from './documents/sync/components/Doc
|
|
|
41
39
|
export { registeredShards, activeShards } from './shards/activate.svelte';
|
|
42
40
|
export { fetchRegistries, fetchBundle, buildPackageMeta } from './registry/client';
|
|
43
41
|
export { validateRegistryIndex } from './registry/schema';
|
|
42
|
+
// Key mint/revoke types — client shards that declare `keys:mint` get ctx.keys.
|
|
43
|
+
export { PERMISSION_KEYS_MINT, ScopeEscalationError, ConsentDeniedError, } from './keys/types';
|
|
44
|
+
export { registerConsentListener, resolveConsent } from './keys/consent.svelte';
|
|
45
|
+
// Revocation bus — subscribe to key revocation events (for advanced integrations).
|
|
46
|
+
export { subscribe as subscribeKeyRevocation } from './keys/revocation-bus.svelte';
|
|
44
47
|
// Admin mode (framework-internal components read admin status).
|
|
45
48
|
export { isAdmin, isAuthenticated, isGuest, getUser, getAuthHeader } from './auth/index';
|
|
46
49
|
/** Runtime feature flags for target-dependent behavior. */
|
|
@@ -3,14 +3,13 @@
|
|
|
3
3
|
* Admin API Keys view — list, create, reveal, revoke API keys.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
interface
|
|
6
|
+
interface ApiKeyPublic {
|
|
7
7
|
id: string;
|
|
8
|
-
key: string;
|
|
9
8
|
label: string;
|
|
10
9
|
createdAt: string;
|
|
11
10
|
}
|
|
12
11
|
|
|
13
|
-
let keys = $state<
|
|
12
|
+
let keys = $state<ApiKeyPublic[]>([]);
|
|
14
13
|
let loading = $state(true);
|
|
15
14
|
let error = $state<string | null>(null);
|
|
16
15
|
|
|
@@ -19,8 +18,9 @@
|
|
|
19
18
|
let newLabel = $state('');
|
|
20
19
|
let createError = $state<string | null>(null);
|
|
21
20
|
|
|
22
|
-
//
|
|
23
|
-
|
|
21
|
+
// Just-created key — the raw bearer value is returned once by the server.
|
|
22
|
+
// Displayed until the admin dismisses it, then never recoverable.
|
|
23
|
+
let justCreated = $state<{ id: string; key: string } | null>(null);
|
|
24
24
|
|
|
25
25
|
// Delete confirmation
|
|
26
26
|
let confirmingId = $state<string | null>(null);
|
|
@@ -53,9 +53,8 @@
|
|
|
53
53
|
createError = body.error || 'Failed to create key';
|
|
54
54
|
return;
|
|
55
55
|
}
|
|
56
|
-
const created:
|
|
57
|
-
|
|
58
|
-
revealed = new Set(revealed);
|
|
56
|
+
const created: { id: string; key: string } = await res.json();
|
|
57
|
+
justCreated = { id: created.id, key: created.key };
|
|
59
58
|
newLabel = '';
|
|
60
59
|
showCreate = false;
|
|
61
60
|
await fetchKeys();
|
|
@@ -64,15 +63,6 @@
|
|
|
64
63
|
}
|
|
65
64
|
}
|
|
66
65
|
|
|
67
|
-
function toggleReveal(id: string) {
|
|
68
|
-
if (revealed.has(id)) {
|
|
69
|
-
revealed.delete(id);
|
|
70
|
-
} else {
|
|
71
|
-
revealed.add(id);
|
|
72
|
-
}
|
|
73
|
-
revealed = new Set(revealed);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
66
|
async function revokeKey(id: string) {
|
|
77
67
|
confirmingId = null;
|
|
78
68
|
try {
|
|
@@ -80,8 +70,7 @@
|
|
|
80
70
|
method: 'DELETE',
|
|
81
71
|
credentials: 'include',
|
|
82
72
|
});
|
|
83
|
-
|
|
84
|
-
revealed = new Set(revealed);
|
|
73
|
+
if (justCreated?.id === id) justCreated = null;
|
|
85
74
|
await fetchKeys();
|
|
86
75
|
} catch { /* ignore */ }
|
|
87
76
|
}
|
|
@@ -92,10 +81,6 @@
|
|
|
92
81
|
});
|
|
93
82
|
}
|
|
94
83
|
|
|
95
|
-
function maskKey(key: string): string {
|
|
96
|
-
return key.slice(0, 8) + '\u2026' + key.slice(-4);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
84
|
fetchKeys();
|
|
100
85
|
</script>
|
|
101
86
|
|
|
@@ -107,6 +92,14 @@
|
|
|
107
92
|
</button>
|
|
108
93
|
</div>
|
|
109
94
|
|
|
95
|
+
{#if justCreated}
|
|
96
|
+
<div class="admin-key-created">
|
|
97
|
+
<div class="admin-key-created-label">New key — copy now, it won't be shown again:</div>
|
|
98
|
+
<code class="admin-key-value">{justCreated.key}</code>
|
|
99
|
+
<button type="button" class="admin-btn-secondary" onclick={() => { justCreated = null; }}>Dismiss</button>
|
|
100
|
+
</div>
|
|
101
|
+
{/if}
|
|
102
|
+
|
|
110
103
|
{#if showCreate}
|
|
111
104
|
<form class="admin-create-form" onsubmit={(e) => { e.preventDefault(); createKey(); }}>
|
|
112
105
|
<input class="admin-input" type="text" placeholder="Key label" bind:value={newLabel} />
|
|
@@ -128,12 +121,8 @@
|
|
|
128
121
|
<div class="admin-key-info">
|
|
129
122
|
<span class="admin-key-label">{k.label}</span>
|
|
130
123
|
<span class="admin-key-meta">{k.id} · {formatDate(k.createdAt)}</span>
|
|
131
|
-
<code class="admin-key-value">{revealed.has(k.id) ? k.key : maskKey(k.key)}</code>
|
|
132
124
|
</div>
|
|
133
125
|
<div class="admin-key-actions">
|
|
134
|
-
<button type="button" class="admin-btn-secondary" onclick={() => toggleReveal(k.id)}>
|
|
135
|
-
{revealed.has(k.id) ? 'Hide' : 'Reveal'}
|
|
136
|
-
</button>
|
|
137
126
|
{#if confirmingId === k.id}
|
|
138
127
|
<button type="button" class="admin-btn-danger" onclick={() => revokeKey(k.id)}>Confirm</button>
|
|
139
128
|
<button type="button" class="admin-btn-secondary" onclick={() => { confirmingId = null; }}>Cancel</button>
|
package/dist/apps/types.d.ts
CHANGED
|
@@ -41,7 +41,9 @@ export interface AppManifest {
|
|
|
41
41
|
* Declared in the manifest and surfaced to the user at install time
|
|
42
42
|
* by the store app. Currently recognized:
|
|
43
43
|
* - 'state:manage' — cross-shard zone access.
|
|
44
|
-
*
|
|
44
|
+
*
|
|
45
|
+
* Sync-related permissions (`sync:policy`, `sync:peer`) are introduced
|
|
46
|
+
* in a later plan alongside the server-side sync runtime.
|
|
45
47
|
*/
|
|
46
48
|
permissions?: string[];
|
|
47
49
|
}
|
|
@@ -62,10 +64,6 @@ export interface AppContext {
|
|
|
62
64
|
* Cross-shard zone management API. Only present when the app's
|
|
63
65
|
* manifest declares the `'state:manage'` permission. Check with
|
|
64
66
|
* `if (ctx.zones)` before use.
|
|
65
|
-
*
|
|
66
|
-
* Related permissions also recognized by the framework:
|
|
67
|
-
* - 'documents:sync' — cross-shard document sync API (exposed on
|
|
68
|
-
* shard contexts as `ctx.sync()`, not on app contexts).
|
|
69
67
|
*/
|
|
70
68
|
zones?: ZoneManager;
|
|
71
69
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DocumentBackend, DocumentMeta } from './types';
|
|
2
|
+
import type { DocStatus } from './sync-types';
|
|
2
3
|
export declare class MemoryDocumentBackend implements DocumentBackend {
|
|
3
4
|
#private;
|
|
4
5
|
read(tenantId: string, shardId: string, path: string): Promise<string | ArrayBuffer | null>;
|
|
@@ -10,6 +11,7 @@ export declare class MemoryDocumentBackend implements DocumentBackend {
|
|
|
10
11
|
listAllDocuments(tenantId: string): Promise<Array<DocumentMeta & {
|
|
11
12
|
shardId: string;
|
|
12
13
|
}>>;
|
|
14
|
+
readMeta(tenantId: string, shardId: string, path: string): Promise<DocStatus | null>;
|
|
13
15
|
}
|
|
14
16
|
export declare class IndexedDBDocumentBackend implements DocumentBackend {
|
|
15
17
|
#private;
|
|
@@ -92,6 +92,12 @@ export class MemoryDocumentBackend {
|
|
|
92
92
|
}
|
|
93
93
|
return out;
|
|
94
94
|
}
|
|
95
|
+
async readMeta(tenantId, shardId, path) {
|
|
96
|
+
const entry = __classPrivateFieldGet(this, _MemoryDocumentBackend_store, "f").get(compositeKey(tenantId, shardId, path));
|
|
97
|
+
if (!entry)
|
|
98
|
+
return null;
|
|
99
|
+
return { exists: true, version: 1, syncMode: 'sync', syncState: 'synced' };
|
|
100
|
+
}
|
|
95
101
|
}
|
|
96
102
|
_MemoryDocumentBackend_store = new WeakMap();
|
|
97
103
|
// ---------------------------------------------------------------------------
|
package/dist/documents/handle.js
CHANGED
|
@@ -18,8 +18,6 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
|
|
|
18
18
|
};
|
|
19
19
|
var _AutosaveControllerImpl_instances, _AutosaveControllerImpl_handle, _AutosaveControllerImpl_path, _AutosaveControllerImpl_debounceMs, _AutosaveControllerImpl_pending, _AutosaveControllerImpl_timer, _AutosaveControllerImpl_dirty, _AutosaveControllerImpl_disposed, _AutosaveControllerImpl_scheduleFlush, _AutosaveControllerImpl_clearTimer;
|
|
20
20
|
import { documentChanges } from './notifications';
|
|
21
|
-
import { notifyJournal } from './journal-hook';
|
|
22
|
-
import { hashContent } from './sync/hash';
|
|
23
21
|
const DEFAULT_DEBOUNCE_MS = 1000;
|
|
24
22
|
/**
|
|
25
23
|
* Create a document handle scoped to a tenant, shard, and file filter.
|
|
@@ -55,17 +53,27 @@ export function createDocumentHandle(tenantId, shardId, backend, options) {
|
|
|
55
53
|
const existed = await backend.exists(tenantId, shardId, path);
|
|
56
54
|
await backend.write(tenantId, shardId, path, content);
|
|
57
55
|
emitChange(existed ? 'update' : 'create', path);
|
|
58
|
-
const hash = await hashContent(content);
|
|
59
|
-
await notifyJournal({ shardId, path, op: 'upsert', hash });
|
|
60
56
|
},
|
|
61
57
|
async delete(path) {
|
|
62
58
|
await backend.delete(tenantId, shardId, path);
|
|
63
59
|
emitChange('delete', path);
|
|
64
|
-
await notifyJournal({ shardId, path, op: 'delete', hash: null });
|
|
65
60
|
},
|
|
66
61
|
async exists(path) {
|
|
67
62
|
return backend.exists(tenantId, shardId, path);
|
|
68
63
|
},
|
|
64
|
+
async status(path) {
|
|
65
|
+
if (!backend.readMeta)
|
|
66
|
+
throw new Error('Backend does not support status()');
|
|
67
|
+
return backend.readMeta(tenantId, shardId, path);
|
|
68
|
+
},
|
|
69
|
+
async resolveConflict(path, choice) {
|
|
70
|
+
if (!backend.resolve)
|
|
71
|
+
throw new Error('Backend does not support resolveConflict()');
|
|
72
|
+
if (typeof choice !== 'string' && !(typeof choice === 'object' && 'origin' in choice)) {
|
|
73
|
+
throw new Error('choice must be a string or { origin } object');
|
|
74
|
+
}
|
|
75
|
+
return backend.resolve(tenantId, shardId, path, choice);
|
|
76
|
+
},
|
|
69
77
|
watch(callback) {
|
|
70
78
|
// Subscribe to global emitter, filtered to this handle's scope.
|
|
71
79
|
const unsub = documentChanges.subscribe((change) => {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { MemoryDocumentBackend } from './backends';
|
|
3
|
+
import { createDocumentHandle } from './handle';
|
|
4
|
+
function harness() {
|
|
5
|
+
const backend = new MemoryDocumentBackend();
|
|
6
|
+
const handle = createDocumentHandle('tenant1', 'shard1', backend, { format: 'text' });
|
|
7
|
+
return { backend, handle };
|
|
8
|
+
}
|
|
9
|
+
describe('DocumentHandle.status()', () => {
|
|
10
|
+
it('returns null for a missing doc', async () => {
|
|
11
|
+
const { handle } = harness();
|
|
12
|
+
expect(await handle.status('nope.txt')).toBeNull();
|
|
13
|
+
});
|
|
14
|
+
it('returns DocStatus after a write', async () => {
|
|
15
|
+
const { handle } = harness();
|
|
16
|
+
await handle.write('a.txt', 'hi');
|
|
17
|
+
const s = await handle.status('a.txt');
|
|
18
|
+
expect(s).toMatchObject({ exists: true, version: 1, syncState: 'synced' });
|
|
19
|
+
});
|
|
20
|
+
it('throws if the backend does not implement readMeta', async () => {
|
|
21
|
+
const backend = {
|
|
22
|
+
async read() { return null; },
|
|
23
|
+
async write() { },
|
|
24
|
+
async delete() { },
|
|
25
|
+
async list() { return []; },
|
|
26
|
+
async exists() { return false; },
|
|
27
|
+
async listAllShards() { return []; },
|
|
28
|
+
async listAllDocuments() { return []; },
|
|
29
|
+
};
|
|
30
|
+
const handle = createDocumentHandle('t', 's', backend, { format: 'text' });
|
|
31
|
+
await expect(handle.status('a.txt')).rejects.toThrow(/status/);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe('DocumentHandle.resolveConflict()', () => {
|
|
35
|
+
it('delegates to backend.resolve with the chosen origin', async () => {
|
|
36
|
+
const resolved = [];
|
|
37
|
+
const backend = {
|
|
38
|
+
async read() { return null; },
|
|
39
|
+
async write() { },
|
|
40
|
+
async delete() { },
|
|
41
|
+
async list() { return []; },
|
|
42
|
+
async exists() { return false; },
|
|
43
|
+
async listAllShards() { return []; },
|
|
44
|
+
async listAllDocuments() { return []; },
|
|
45
|
+
async resolve(t, s, p, c) { resolved.push({ t, s, p, c }); },
|
|
46
|
+
};
|
|
47
|
+
const handle = createDocumentHandle('tenant1', 'shard1', backend, { format: 'text' });
|
|
48
|
+
await handle.resolveConflict('a.txt', 'local');
|
|
49
|
+
expect(resolved).toEqual([{ t: 'tenant1', s: 'shard1', p: 'a.txt', c: 'local' }]);
|
|
50
|
+
});
|
|
51
|
+
it('throws if the backend does not implement resolve', async () => {
|
|
52
|
+
const { handle } = harness();
|
|
53
|
+
await expect(handle.resolveConflict('a.txt', 'local')).rejects.toThrow(/resolveConflict/);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HTTP document backend — connects to sh3-server's document API.
|
|
3
3
|
*
|
|
4
|
-
* Implements the DocumentBackend interface over HTTP.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Implements the DocumentBackend interface over HTTP. Every request
|
|
5
|
+
* includes credentials (session cookie) so a logged-in user's tenant
|
|
6
|
+
* identity reaches sh3-server; write operations additionally send the
|
|
7
|
+
* API key as a Bearer token. This is the web-hosted equivalent of the
|
|
8
|
+
* Tauri filesystem backend. Server open-mode (auth.required=false)
|
|
9
|
+
* bypasses tenant checks regardless.
|
|
8
10
|
*/
|
|
9
11
|
import type { DocumentBackend, DocumentMeta } from './types';
|
|
12
|
+
import type { DocStatus } from './sync-types';
|
|
10
13
|
export declare class HttpDocumentBackend implements DocumentBackend {
|
|
11
14
|
#private;
|
|
12
15
|
/**
|
|
@@ -23,4 +26,8 @@ export declare class HttpDocumentBackend implements DocumentBackend {
|
|
|
23
26
|
listAllDocuments(tenantId: string): Promise<Array<DocumentMeta & {
|
|
24
27
|
shardId: string;
|
|
25
28
|
}>>;
|
|
29
|
+
readMeta(tenantId: string, shardId: string, path: string): Promise<DocStatus | null>;
|
|
30
|
+
resolve(tenantId: string, shardId: string, path: string, choice: 'local' | 'remote' | {
|
|
31
|
+
origin: string;
|
|
32
|
+
} | string): Promise<void>;
|
|
26
33
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HTTP document backend — connects to sh3-server's document API.
|
|
3
3
|
*
|
|
4
|
-
* Implements the DocumentBackend interface over HTTP.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Implements the DocumentBackend interface over HTTP. Every request
|
|
5
|
+
* includes credentials (session cookie) so a logged-in user's tenant
|
|
6
|
+
* identity reaches sh3-server; write operations additionally send the
|
|
7
|
+
* API key as a Bearer token. This is the web-hosted equivalent of the
|
|
8
|
+
* Tauri filesystem backend. Server open-mode (auth.required=false)
|
|
9
|
+
* bypasses tenant checks regardless.
|
|
8
10
|
*/
|
|
9
11
|
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
|
|
10
12
|
if (kind === "m") throw new TypeError("Private method is not writable");
|
|
@@ -34,7 +36,7 @@ export class HttpDocumentBackend {
|
|
|
34
36
|
async read(tenantId, shardId, path) {
|
|
35
37
|
var _a;
|
|
36
38
|
const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${path}`;
|
|
37
|
-
const res = await fetch(url);
|
|
39
|
+
const res = await fetch(url, { credentials: 'include' });
|
|
38
40
|
if (res.status === 404)
|
|
39
41
|
return null;
|
|
40
42
|
if (!res.ok)
|
|
@@ -48,42 +50,66 @@ export class HttpDocumentBackend {
|
|
|
48
50
|
async write(tenantId, shardId, path, content) {
|
|
49
51
|
const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${path}`;
|
|
50
52
|
const headers = Object.assign(Object.assign({}, __classPrivateFieldGet(this, _HttpDocumentBackend_instances, "m", _HttpDocumentBackend_authHeaders).call(this)), { 'Content-Type': typeof content === 'string' ? 'text/plain' : 'application/octet-stream' });
|
|
51
|
-
const res = await fetch(url, { method: 'PUT', headers, body: content });
|
|
53
|
+
const res = await fetch(url, { method: 'PUT', headers, body: content, credentials: 'include' });
|
|
52
54
|
if (!res.ok)
|
|
53
55
|
throw new Error(`Document write failed: ${res.status}`);
|
|
54
56
|
}
|
|
55
57
|
async delete(tenantId, shardId, path) {
|
|
56
58
|
const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${path}`;
|
|
57
|
-
const res = await fetch(url, { method: 'DELETE', headers: __classPrivateFieldGet(this, _HttpDocumentBackend_instances, "m", _HttpDocumentBackend_authHeaders).call(this) });
|
|
59
|
+
const res = await fetch(url, { method: 'DELETE', headers: __classPrivateFieldGet(this, _HttpDocumentBackend_instances, "m", _HttpDocumentBackend_authHeaders).call(this), credentials: 'include' });
|
|
58
60
|
if (!res.ok)
|
|
59
61
|
throw new Error(`Document delete failed: ${res.status}`);
|
|
60
62
|
}
|
|
61
63
|
async list(tenantId, shardId) {
|
|
62
64
|
const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}`;
|
|
63
|
-
const res = await fetch(url);
|
|
65
|
+
const res = await fetch(url, { credentials: 'include' });
|
|
64
66
|
if (!res.ok)
|
|
65
67
|
throw new Error(`Document list failed: ${res.status}`);
|
|
66
68
|
return res.json();
|
|
67
69
|
}
|
|
68
70
|
async exists(tenantId, shardId, path) {
|
|
69
71
|
const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${path}`;
|
|
70
|
-
const res = await fetch(url, { method: 'HEAD' });
|
|
72
|
+
const res = await fetch(url, { method: 'HEAD', credentials: 'include' });
|
|
71
73
|
return res.ok;
|
|
72
74
|
}
|
|
73
75
|
async listAllShards(tenantId) {
|
|
74
76
|
const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/_shards`;
|
|
75
|
-
const res = await fetch(url);
|
|
77
|
+
const res = await fetch(url, { credentials: 'include' });
|
|
76
78
|
if (!res.ok)
|
|
77
79
|
throw new Error(`listAllShards failed: ${res.status}`);
|
|
78
80
|
return res.json();
|
|
79
81
|
}
|
|
80
82
|
async listAllDocuments(tenantId) {
|
|
81
83
|
const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/_all`;
|
|
82
|
-
const res = await fetch(url);
|
|
84
|
+
const res = await fetch(url, { credentials: 'include' });
|
|
83
85
|
if (!res.ok)
|
|
84
86
|
throw new Error(`listAllDocuments failed: ${res.status}`);
|
|
85
87
|
return res.json();
|
|
86
88
|
}
|
|
89
|
+
async readMeta(tenantId, shardId, path) {
|
|
90
|
+
const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${path}?meta=1`;
|
|
91
|
+
const res = await fetch(url, { credentials: 'include' });
|
|
92
|
+
if (!res.ok)
|
|
93
|
+
throw new Error(`readMeta failed: ${res.status}`);
|
|
94
|
+
const body = await res.json();
|
|
95
|
+
if (!body.exists)
|
|
96
|
+
return null;
|
|
97
|
+
return body;
|
|
98
|
+
}
|
|
99
|
+
async resolve(tenantId, shardId, path, choice) {
|
|
100
|
+
const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${path}/resolve`;
|
|
101
|
+
const body = typeof choice === 'string'
|
|
102
|
+
? { choice }
|
|
103
|
+
: { choice: choice.origin };
|
|
104
|
+
const res = await fetch(url, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
credentials: 'include',
|
|
107
|
+
headers: Object.assign(Object.assign({}, __classPrivateFieldGet(this, _HttpDocumentBackend_instances, "m", _HttpDocumentBackend_authHeaders).call(this)), { 'Content-Type': 'application/json' }),
|
|
108
|
+
body: JSON.stringify(body),
|
|
109
|
+
});
|
|
110
|
+
if (!res.ok)
|
|
111
|
+
throw new Error(`resolve failed: ${res.status}`);
|
|
112
|
+
}
|
|
87
113
|
}
|
|
88
114
|
_HttpDocumentBackend_baseUrl = new WeakMap(), _HttpDocumentBackend_apiKey = new WeakMap(), _HttpDocumentBackend_instances = new WeakSet(), _HttpDocumentBackend_authHeaders = function _HttpDocumentBackend_authHeaders() {
|
|
89
115
|
if (!__classPrivateFieldGet(this, _HttpDocumentBackend_apiKey, "f"))
|
|
@@ -4,4 +4,5 @@ export { HttpDocumentBackend } from './http-backend';
|
|
|
4
4
|
export { createDocumentHandle } from './handle';
|
|
5
5
|
export { documentChanges } from './notifications';
|
|
6
6
|
export { getTenantId, getDocumentBackend, __setTenantId, __setDocumentBackend, } from './config';
|
|
7
|
-
export
|
|
7
|
+
export type { SyncPolicy, SyncPolicyRule, DocStatus, ConflictFile, ConflictBranch, } from './sync-types';
|
|
8
|
+
export { PERMISSION_SYNC_PEER, PERMISSION_SYNC_POLICY, } from './sync-types';
|
package/dist/documents/index.js
CHANGED
|
@@ -6,4 +6,4 @@ export { HttpDocumentBackend } from './http-backend';
|
|
|
6
6
|
export { createDocumentHandle } from './handle';
|
|
7
7
|
export { documentChanges } from './notifications';
|
|
8
8
|
export { getTenantId, getDocumentBackend, __setTenantId, __setDocumentBackend, } from './config';
|
|
9
|
-
export
|
|
9
|
+
export { PERMISSION_SYNC_PEER, PERMISSION_SYNC_POLICY, } from './sync-types';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Server-side sync policy (ADR-019 §9). Lives at {tenant}/__sync__/policy.json. */
|
|
2
|
+
export interface SyncPolicy {
|
|
3
|
+
/** Schema version. Increment when the rule format changes. */
|
|
4
|
+
version: number;
|
|
5
|
+
/** Mode applied when no rule matches. */
|
|
6
|
+
default: 'sync' | 'local-only';
|
|
7
|
+
/** First-match-wins rules. Glob syntax: `*`, `**`, `?`. No regex. */
|
|
8
|
+
rules: SyncPolicyRule[];
|
|
9
|
+
}
|
|
10
|
+
export interface SyncPolicyRule {
|
|
11
|
+
path: string;
|
|
12
|
+
mode: 'sync' | 'local-only';
|
|
13
|
+
}
|
|
14
|
+
/** Per-document status, returned by DocumentHandle.status(). */
|
|
15
|
+
export interface DocStatus {
|
|
16
|
+
exists: boolean;
|
|
17
|
+
version: number;
|
|
18
|
+
syncMode: 'sync' | 'local-only';
|
|
19
|
+
syncState: 'synced' | 'pending' | 'conflict';
|
|
20
|
+
lastSyncedAt?: number;
|
|
21
|
+
origin?: string;
|
|
22
|
+
deleted?: boolean;
|
|
23
|
+
/** Conflict branches, only populated when syncState === 'conflict'. */
|
|
24
|
+
branches?: Array<{
|
|
25
|
+
origin: string;
|
|
26
|
+
version: number;
|
|
27
|
+
at: number;
|
|
28
|
+
}>;
|
|
29
|
+
}
|
|
30
|
+
/** Conflict file shape under {tenant}/__sync__/conflicts/<shardId>/<path>.conflict.json. */
|
|
31
|
+
export interface ConflictFile {
|
|
32
|
+
path: string;
|
|
33
|
+
shardId: string;
|
|
34
|
+
branches: ConflictBranch[];
|
|
35
|
+
}
|
|
36
|
+
export interface ConflictBranch {
|
|
37
|
+
origin: string;
|
|
38
|
+
version: number;
|
|
39
|
+
content: string;
|
|
40
|
+
at: number;
|
|
41
|
+
}
|
|
42
|
+
/** Server-shard permission: Mode B writes + reserved __sync__ read access. */
|
|
43
|
+
export declare const PERMISSION_SYNC_PEER = "sync:peer";
|
|
44
|
+
/** Read/write __sync__/policy.json. Grantable to client or server shards. */
|
|
45
|
+
export declare const PERMISSION_SYNC_POLICY = "sync:policy";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Sync vocabulary — type-only contract surface (ADR-019 revised 2026-04-19).
|
|
3
|
+
*
|
|
4
|
+
* The sync runtime lives in the installable sh3-sync shard. sh3-core exposes
|
|
5
|
+
* only the types that client shards and the shard-facing ServerShardContext
|
|
6
|
+
* share with the runtime. No runtime code in this file.
|
|
7
|
+
*/
|
|
8
|
+
/** Server-shard permission: Mode B writes + reserved __sync__ read access. */
|
|
9
|
+
export const PERMISSION_SYNC_PEER = 'sync:peer';
|
|
10
|
+
/** Read/write __sync__/policy.json. Grantable to client or server shards. */
|
|
11
|
+
export const PERMISSION_SYNC_POLICY = 'sync:policy';
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Manifest permission string: grants tenant-wide document observation
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* via `ctx.browse` (read-only enumeration and change subscription across
|
|
4
|
+
* every shard's documents for the active tenant). Writes still flow
|
|
5
|
+
* through the owning shard's own `ctx.documents()` handle.
|
|
5
6
|
*/
|
|
6
7
|
export declare const PERMISSION_DOCUMENTS_BROWSE = "documents:browse";
|
|
7
8
|
/**
|
|
@@ -25,6 +26,20 @@ export interface DocumentMeta {
|
|
|
25
26
|
size: number;
|
|
26
27
|
/** Last modified timestamp in epoch milliseconds. */
|
|
27
28
|
lastModified: number;
|
|
29
|
+
/** Monotonic per-document version; primary-authoritative. */
|
|
30
|
+
version?: number;
|
|
31
|
+
/** 'sync' or 'local-only', cached from policy.json at write time. */
|
|
32
|
+
syncMode?: 'sync' | 'local-only';
|
|
33
|
+
/** 'synced' | 'pending' | 'conflict'. */
|
|
34
|
+
syncState?: 'synced' | 'pending' | 'conflict';
|
|
35
|
+
/** Replica's view of primary's version at last confirmed sync. */
|
|
36
|
+
lastKnownVersion?: number;
|
|
37
|
+
/** Epoch ms; when replica/primary last agreed on content. */
|
|
38
|
+
lastSyncedAt?: number;
|
|
39
|
+
/** Peer id that produced this content (set by Mode B). */
|
|
40
|
+
origin?: string;
|
|
41
|
+
/** Tombstone marker for deleted docs. */
|
|
42
|
+
deleted?: boolean;
|
|
28
43
|
}
|
|
29
44
|
/** Change notification payload delivered to watch callbacks. */
|
|
30
45
|
export interface DocumentChange {
|
|
@@ -33,6 +48,7 @@ export interface DocumentChange {
|
|
|
33
48
|
tenantId: string;
|
|
34
49
|
shardId: string;
|
|
35
50
|
}
|
|
51
|
+
import type { DocStatus } from './sync-types';
|
|
36
52
|
/**
|
|
37
53
|
* File-oriented backend for the document zone.
|
|
38
54
|
*
|
|
@@ -68,6 +84,19 @@ export interface DocumentBackend {
|
|
|
68
84
|
listAllDocuments(tenantId: string): Promise<Array<DocumentMeta & {
|
|
69
85
|
shardId: string;
|
|
70
86
|
}>>;
|
|
87
|
+
/**
|
|
88
|
+
* Read sync-state metadata for a doc without fetching content.
|
|
89
|
+
* Returns null if the doc does not exist. Optional because only
|
|
90
|
+
* sync-aware backends (HttpDocumentBackend) support it.
|
|
91
|
+
*/
|
|
92
|
+
readMeta?(tenantId: string, shardId: string, path: string): Promise<DocStatus | null>;
|
|
93
|
+
/**
|
|
94
|
+
* Resolve a conflict by picking a branch or supplying fresh content.
|
|
95
|
+
* Optional; only supported by HttpDocumentBackend in v1.
|
|
96
|
+
*/
|
|
97
|
+
resolve?(tenantId: string, shardId: string, path: string, choice: 'local' | 'remote' | {
|
|
98
|
+
origin: string;
|
|
99
|
+
} | string): Promise<void>;
|
|
71
100
|
}
|
|
72
101
|
/**
|
|
73
102
|
* Shard-facing document handle returned by `ctx.documents()`. Binds
|
|
@@ -84,6 +113,15 @@ export interface DocumentHandle {
|
|
|
84
113
|
delete(path: string): Promise<void>;
|
|
85
114
|
/** Check existence without reading content. */
|
|
86
115
|
exists(path: string): Promise<boolean>;
|
|
116
|
+
/** Fetch sync-state metadata for a path. Null if the doc does not exist. */
|
|
117
|
+
status(path: string): Promise<DocStatus | null>;
|
|
118
|
+
/**
|
|
119
|
+
* Resolve a conflict. `'local'` keeps the local branch; any other
|
|
120
|
+
* origin string picks the named branch from the conflict bucket.
|
|
121
|
+
*/
|
|
122
|
+
resolveConflict(path: string, choice: 'local' | 'remote' | {
|
|
123
|
+
origin: string;
|
|
124
|
+
} | string): Promise<void>;
|
|
87
125
|
/**
|
|
88
126
|
* Subscribe to change notifications within this handle's scope.
|
|
89
127
|
* Returns an unsubscribe function.
|
package/dist/documents/types.js
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
/**
|
|
13
13
|
* Manifest permission string: grants tenant-wide document observation
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* via `ctx.browse` (read-only enumeration and change subscription across
|
|
15
|
+
* every shard's documents for the active tenant). Writes still flow
|
|
16
|
+
* through the owning shard's own `ctx.documents()` handle.
|
|
16
17
|
*/
|
|
17
18
|
export const PERMISSION_DOCUMENTS_BROWSE = 'documents:browse';
|