sh3-core 0.8.1 → 0.8.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 +19 -0
- package/dist/api.d.ts +3 -0
- package/dist/api.js +5 -0
- package/dist/app/admin/ApiKeysView.svelte +16 -27
- 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.d.ts +1 -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.d.ts +1 -0
- package/dist/keys/consent.test.js +53 -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.d.ts +1 -0
- package/dist/keys/revocation-bus.test.js +95 -0
- package/dist/keys/types.d.ts +32 -0
- package/dist/keys/types.js +13 -0
- package/dist/server-shard/types.d.ts +21 -2
- package/dist/server-sync.d.ts +6 -0
- package/dist/server-sync.js +634 -0
- package/dist/server-sync.js.map +7 -0
- 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.d.ts +1 -0
- package/dist/shards/activate-on-key-revoked.test.js +60 -0
- package/dist/shards/activate.svelte.js +24 -2
- package/dist/shards/types.d.ts +9 -0
- 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/testing.d.ts +3 -0
- package/dist/testing.js +77 -0
- package/dist/testing.js.map +7 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +10 -2
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
|
@@ -29,6 +29,9 @@ export type { RegistryIndex, PackageEntry, PackageVersion, RequiredDependency, I
|
|
|
29
29
|
export type { ResolvedPackage } from './registry/client';
|
|
30
30
|
export { fetchRegistries, fetchBundle, buildPackageMeta } from './registry/client';
|
|
31
31
|
export { validateRegistryIndex } from './registry/schema';
|
|
32
|
+
export { PERMISSION_KEYS_MINT, ScopeEscalationError, ConsentDeniedError, type ShardContextKeys, type ApiKeyPublic, type MintOpts, } from './keys/types';
|
|
33
|
+
export { registerConsentListener, resolveConsent, type ConsentRequest } from './keys/consent.svelte';
|
|
34
|
+
export { subscribe as subscribeKeyRevocation } from './keys/revocation-bus.svelte';
|
|
32
35
|
export { isAdmin, isAuthenticated, isGuest, getUser, getAuthHeader } from './auth/index';
|
|
33
36
|
export type { AuthUser, AuthSession, BootConfig } from './auth/types';
|
|
34
37
|
/** Runtime feature flags for target-dependent behavior. */
|
package/dist/api.js
CHANGED
|
@@ -41,6 +41,11 @@ export { default as DocumentSyncExplorer } from './documents/sync/components/Doc
|
|
|
41
41
|
export { registeredShards, activeShards } from './shards/activate.svelte';
|
|
42
42
|
export { fetchRegistries, fetchBundle, buildPackageMeta } from './registry/client';
|
|
43
43
|
export { validateRegistryIndex } from './registry/schema';
|
|
44
|
+
// Key mint/revoke types — client shards that declare `keys:mint` get ctx.keys.
|
|
45
|
+
export { PERMISSION_KEYS_MINT, ScopeEscalationError, ConsentDeniedError, } from './keys/types';
|
|
46
|
+
export { registerConsentListener, resolveConsent } from './keys/consent.svelte';
|
|
47
|
+
// Revocation bus — subscribe to key revocation events (for advanced integrations).
|
|
48
|
+
export { subscribe as subscribeKeyRevocation } from './keys/revocation-bus.svelte';
|
|
44
49
|
// Admin mode (framework-internal components read admin status).
|
|
45
50
|
export { isAdmin, isAuthenticated, isGuest, getUser, getAuthHeader } from './auth/index';
|
|
46
51
|
/** 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>
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Shell-owned consent dialog. Listens for consent requests and routes the
|
|
3
|
+
user's Approve/Deny back into the runtime via resolveConsent().
|
|
4
|
+
|
|
5
|
+
Mounted once by the shell at boot; never by shards.
|
|
6
|
+
|
|
7
|
+
Security: all shard-provided strings (shardId, label, scope, connectorId)
|
|
8
|
+
are rendered as plain text via Svelte's default interpolation — no @html.
|
|
9
|
+
-->
|
|
10
|
+
<script lang="ts">
|
|
11
|
+
import { registerConsentListener, resolveConsent, type ConsentRequest } from './consent.svelte';
|
|
12
|
+
|
|
13
|
+
let current = $state<ConsentRequest | null>(null);
|
|
14
|
+
|
|
15
|
+
$effect(() => {
|
|
16
|
+
const off = registerConsentListener((req) => { current = req; });
|
|
17
|
+
return off;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function approve(): void {
|
|
21
|
+
if (!current) return;
|
|
22
|
+
resolveConsent(current.requestId, true);
|
|
23
|
+
current = null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function deny(): void {
|
|
27
|
+
if (!current) return;
|
|
28
|
+
resolveConsent(current.requestId, false);
|
|
29
|
+
current = null;
|
|
30
|
+
}
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
{#if current}
|
|
34
|
+
<div class="sh3-consent-backdrop" role="dialog" aria-modal="true" aria-label="Key creation consent">
|
|
35
|
+
<div class="sh3-consent-card">
|
|
36
|
+
<h2 class="sh3-consent-title">
|
|
37
|
+
<span>{current.shardId}</span> wants to create a key
|
|
38
|
+
</h2>
|
|
39
|
+
<dl class="sh3-consent-fields">
|
|
40
|
+
<dt>Label</dt>
|
|
41
|
+
<dd>{current.label}</dd>
|
|
42
|
+
<dt>Scopes</dt>
|
|
43
|
+
<dd class="sh3-consent-scopes">
|
|
44
|
+
{#each current.scopes as s (s)}
|
|
45
|
+
<code class="sh3-consent-scope">{s}</code>
|
|
46
|
+
{/each}
|
|
47
|
+
</dd>
|
|
48
|
+
{#if current.connectorId}
|
|
49
|
+
<dt>Connector</dt>
|
|
50
|
+
<dd>{current.connectorId}</dd>
|
|
51
|
+
{/if}
|
|
52
|
+
{#if current.expiresIn}
|
|
53
|
+
<dt>Expires in</dt>
|
|
54
|
+
<dd>{Math.round(current.expiresIn / 1000)}s</dd>
|
|
55
|
+
{/if}
|
|
56
|
+
</dl>
|
|
57
|
+
<div class="sh3-consent-actions">
|
|
58
|
+
<!-- Deny has autofocus — safe default per spec -->
|
|
59
|
+
<button type="button" class="sh3-consent-deny" onclick={deny} autofocus>Deny</button>
|
|
60
|
+
<button type="button" class="sh3-consent-approve" onclick={approve}>Approve</button>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
{/if}
|
|
65
|
+
|
|
66
|
+
<style>
|
|
67
|
+
.sh3-consent-backdrop {
|
|
68
|
+
position: fixed;
|
|
69
|
+
inset: 0;
|
|
70
|
+
background: rgba(0, 0, 0, 0.55);
|
|
71
|
+
display: flex;
|
|
72
|
+
align-items: center;
|
|
73
|
+
justify-content: center;
|
|
74
|
+
z-index: 9999;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.sh3-consent-card {
|
|
78
|
+
background: var(--shell-bg-elevated, #222);
|
|
79
|
+
color: var(--shell-fg, #eee);
|
|
80
|
+
border: 1px solid var(--shell-border, #444);
|
|
81
|
+
padding: 1.5rem;
|
|
82
|
+
border-radius: 8px;
|
|
83
|
+
min-width: 360px;
|
|
84
|
+
max-width: 520px;
|
|
85
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.sh3-consent-title {
|
|
89
|
+
margin: 0 0 1rem;
|
|
90
|
+
font-size: 1rem;
|
|
91
|
+
font-weight: 600;
|
|
92
|
+
color: var(--shell-fg, #eee);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.sh3-consent-title span {
|
|
96
|
+
color: var(--shell-accent, #7eb8f7);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.sh3-consent-fields {
|
|
100
|
+
margin: 0 0 1rem;
|
|
101
|
+
display: grid;
|
|
102
|
+
grid-template-columns: auto 1fr;
|
|
103
|
+
column-gap: 0.75rem;
|
|
104
|
+
row-gap: 0.25rem;
|
|
105
|
+
align-items: baseline;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.sh3-consent-fields dt {
|
|
109
|
+
font-weight: 600;
|
|
110
|
+
color: var(--shell-fg-muted, #aaa);
|
|
111
|
+
font-size: 0.8rem;
|
|
112
|
+
text-transform: uppercase;
|
|
113
|
+
letter-spacing: 0.05em;
|
|
114
|
+
padding-top: 0.35rem;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.sh3-consent-fields dd {
|
|
118
|
+
margin: 0;
|
|
119
|
+
padding-top: 0.35rem;
|
|
120
|
+
word-break: break-word;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.sh3-consent-scopes {
|
|
124
|
+
display: flex;
|
|
125
|
+
flex-wrap: wrap;
|
|
126
|
+
gap: 0.25rem;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.sh3-consent-scope {
|
|
130
|
+
display: inline-block;
|
|
131
|
+
padding: 0.1rem 0.35rem;
|
|
132
|
+
background: rgba(255, 255, 255, 0.08);
|
|
133
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
134
|
+
border-radius: 3px;
|
|
135
|
+
font-size: 0.82em;
|
|
136
|
+
font-family: var(--shell-font-mono, monospace);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.sh3-consent-actions {
|
|
140
|
+
display: flex;
|
|
141
|
+
justify-content: flex-end;
|
|
142
|
+
gap: 0.5rem;
|
|
143
|
+
margin-top: 1.25rem;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.sh3-consent-deny,
|
|
147
|
+
.sh3-consent-approve {
|
|
148
|
+
padding: 0.4rem 1rem;
|
|
149
|
+
border-radius: 4px;
|
|
150
|
+
font-size: 0.875rem;
|
|
151
|
+
cursor: pointer;
|
|
152
|
+
border: 1px solid transparent;
|
|
153
|
+
transition: opacity 0.1s;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.sh3-consent-deny {
|
|
157
|
+
background: transparent;
|
|
158
|
+
color: var(--shell-fg-muted, #aaa);
|
|
159
|
+
border-color: var(--shell-border, #444);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.sh3-consent-deny:hover {
|
|
163
|
+
color: var(--shell-fg, #eee);
|
|
164
|
+
border-color: var(--shell-fg-muted, #aaa);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.sh3-consent-approve {
|
|
168
|
+
background: var(--shell-accent, #7eb8f7);
|
|
169
|
+
color: #000;
|
|
170
|
+
border-color: var(--shell-accent, #7eb8f7);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.sh3-consent-approve:hover {
|
|
174
|
+
opacity: 0.85;
|
|
175
|
+
}
|
|
176
|
+
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side ctx.keys factory. Calls /api/keys endpoints with the current
|
|
3
|
+
* session cookie (no bearer token — session auth is the only supported
|
|
4
|
+
* principal for this API).
|
|
5
|
+
*
|
|
6
|
+
* The shard's manifest permissions are enforced here (scope subset check)
|
|
7
|
+
* before the consent dialog is even shown.
|
|
8
|
+
*/
|
|
9
|
+
import type { ShardContextKeys } from './types';
|
|
10
|
+
export declare function createShardKeysApi(params: {
|
|
11
|
+
shardId: string;
|
|
12
|
+
shardPermissions: string[];
|
|
13
|
+
}): ShardContextKeys;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side ctx.keys factory. Calls /api/keys endpoints with the current
|
|
3
|
+
* session cookie (no bearer token — session auth is the only supported
|
|
4
|
+
* principal for this API).
|
|
5
|
+
*
|
|
6
|
+
* The shard's manifest permissions are enforced here (scope subset check)
|
|
7
|
+
* before the consent dialog is even shown.
|
|
8
|
+
*/
|
|
9
|
+
import { ConsentDeniedError, ScopeEscalationError } from './types';
|
|
10
|
+
import { requestConsent } from './consent.svelte';
|
|
11
|
+
import { emit } from './revocation-bus.svelte';
|
|
12
|
+
export function createShardKeysApi(params) {
|
|
13
|
+
const { shardId, shardPermissions } = params;
|
|
14
|
+
const assertScopesSubset = (scopes) => {
|
|
15
|
+
for (const s of scopes) {
|
|
16
|
+
if (!shardPermissions.includes(s) && !shardPermissions.includes('admin:*')) {
|
|
17
|
+
throw new ScopeEscalationError(s);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
return {
|
|
22
|
+
async mint(opts) {
|
|
23
|
+
assertScopesSubset(opts.scopes);
|
|
24
|
+
const approved = await requestConsent(shardId, opts);
|
|
25
|
+
if (!approved)
|
|
26
|
+
throw new ConsentDeniedError();
|
|
27
|
+
const ticketRes = await fetch('/api/keys/consent', {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
credentials: 'include',
|
|
30
|
+
headers: { 'content-type': 'application/json' },
|
|
31
|
+
body: JSON.stringify(Object.assign({ shardId }, opts)),
|
|
32
|
+
});
|
|
33
|
+
if (!ticketRes.ok)
|
|
34
|
+
throw new Error(`Consent ticket failed: ${ticketRes.status}`);
|
|
35
|
+
const { ticket } = await ticketRes.json();
|
|
36
|
+
const mintRes = await fetch('/api/keys', {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
credentials: 'include',
|
|
39
|
+
headers: { 'content-type': 'application/json' },
|
|
40
|
+
body: JSON.stringify({ ticket }),
|
|
41
|
+
});
|
|
42
|
+
if (!mintRes.ok)
|
|
43
|
+
throw new Error(`Mint failed: ${mintRes.status}`);
|
|
44
|
+
return mintRes.json();
|
|
45
|
+
},
|
|
46
|
+
async list() {
|
|
47
|
+
const res = await fetch('/api/keys', { credentials: 'include' });
|
|
48
|
+
if (!res.ok)
|
|
49
|
+
throw new Error(`List failed: ${res.status}`);
|
|
50
|
+
const all = (await res.json());
|
|
51
|
+
return all.filter((k) => k.mintedByShardId === shardId);
|
|
52
|
+
},
|
|
53
|
+
async revoke(id) {
|
|
54
|
+
const res = await fetch(`/api/keys/${encodeURIComponent(id)}`, {
|
|
55
|
+
method: 'DELETE',
|
|
56
|
+
credentials: 'include',
|
|
57
|
+
});
|
|
58
|
+
if (!res.ok && res.status !== 404)
|
|
59
|
+
throw new Error(`Revoke failed: ${res.status}`);
|
|
60
|
+
// Notify local bus immediately so the owning shard's onKeyRevoked fires
|
|
61
|
+
// even if the SSE stream hasn't delivered the server-side echo yet.
|
|
62
|
+
emit(shardId, id);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
|
+
import { createShardKeysApi } from './client';
|
|
3
|
+
import { ScopeEscalationError, ConsentDeniedError } from './types';
|
|
4
|
+
import { registerConsentListener, resolveConsent } from './consent.svelte';
|
|
5
|
+
describe('createShardKeysApi', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.stubGlobal('fetch', vi.fn());
|
|
8
|
+
});
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.restoreAllMocks();
|
|
11
|
+
});
|
|
12
|
+
it('throws ScopeEscalationError when minting outside declared scopes', async () => {
|
|
13
|
+
const api = createShardKeysApi({ shardId: 'x', shardPermissions: ['state:manage'] });
|
|
14
|
+
await expect(api.mint({ label: 'l', scopes: ['documents:sync'] })).rejects.toBeInstanceOf(ScopeEscalationError);
|
|
15
|
+
});
|
|
16
|
+
it('throws ConsentDeniedError when the user denies', async () => {
|
|
17
|
+
const off = registerConsentListener((req) => {
|
|
18
|
+
queueMicrotask(() => resolveConsent(req.requestId, false));
|
|
19
|
+
});
|
|
20
|
+
try {
|
|
21
|
+
const api = createShardKeysApi({ shardId: 'x', shardPermissions: ['documents:sync'] });
|
|
22
|
+
await expect(api.mint({ label: 'l', scopes: ['documents:sync'] })).rejects.toBeInstanceOf(ConsentDeniedError);
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
off();
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
it('mints when consent approves and server responds', async () => {
|
|
29
|
+
const off = registerConsentListener((req) => {
|
|
30
|
+
queueMicrotask(() => resolveConsent(req.requestId, true));
|
|
31
|
+
});
|
|
32
|
+
const fetchMock = vi.mocked(fetch);
|
|
33
|
+
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ ticket: 'tk_1' }), { status: 200 }));
|
|
34
|
+
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ id: 'abc', key: 'sh3_xyz' }), { status: 200 }));
|
|
35
|
+
try {
|
|
36
|
+
const api = createShardKeysApi({ shardId: 'sh', shardPermissions: ['documents:sync'] });
|
|
37
|
+
const out = await api.mint({ label: 'l', scopes: ['documents:sync'] });
|
|
38
|
+
expect(out.key).toBe('sh3_xyz');
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
off();
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell-owned consent runtime — only place that can approve or deny a mint.
|
|
3
|
+
*
|
|
4
|
+
* The client shard calls requestConsent(); the shell shows ConsentDialog.svelte
|
|
5
|
+
* and calls resolveConsent(...) on user action.
|
|
6
|
+
*/
|
|
7
|
+
import type { MintOpts } from './types';
|
|
8
|
+
export interface ConsentRequest extends MintOpts {
|
|
9
|
+
requestId: string;
|
|
10
|
+
shardId: string;
|
|
11
|
+
}
|
|
12
|
+
type Listener = (req: ConsentRequest) => void;
|
|
13
|
+
export declare function registerConsentListener(fn: Listener): () => void;
|
|
14
|
+
export declare function requestConsent(shardId: string, opts: MintOpts): Promise<boolean>;
|
|
15
|
+
export declare function resolveConsent(requestId: string, approved: boolean): void;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell-owned consent runtime — only place that can approve or deny a mint.
|
|
3
|
+
*
|
|
4
|
+
* The client shard calls requestConsent(); the shell shows ConsentDialog.svelte
|
|
5
|
+
* and calls resolveConsent(...) on user action.
|
|
6
|
+
*/
|
|
7
|
+
const pending = new Map();
|
|
8
|
+
let listener = null;
|
|
9
|
+
export function registerConsentListener(fn) {
|
|
10
|
+
listener = fn;
|
|
11
|
+
return () => { if (listener === fn)
|
|
12
|
+
listener = null; };
|
|
13
|
+
}
|
|
14
|
+
export async function requestConsent(shardId, opts) {
|
|
15
|
+
if (!listener)
|
|
16
|
+
throw new Error('No consent listener registered — the shell must mount ConsentDialog.');
|
|
17
|
+
const requestId = `c_${Math.random().toString(36).slice(2)}${Date.now()}`;
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
pending.set(requestId, { resolve });
|
|
20
|
+
listener(Object.assign(Object.assign({}, opts), { shardId, requestId }));
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export function resolveConsent(requestId, approved) {
|
|
24
|
+
const entry = pending.get(requestId);
|
|
25
|
+
if (!entry)
|
|
26
|
+
return;
|
|
27
|
+
pending.delete(requestId);
|
|
28
|
+
entry.resolve(approved);
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { registerConsentListener, requestConsent, resolveConsent, } from './consent.svelte';
|
|
3
|
+
describe('consent runtime', () => {
|
|
4
|
+
// Always clean up the listener after each test to avoid cross-test leakage.
|
|
5
|
+
const cleanups = [];
|
|
6
|
+
afterEach(() => { cleanups.forEach((fn) => fn()); cleanups.length = 0; });
|
|
7
|
+
it('throws when no listener is registered', async () => {
|
|
8
|
+
await expect(requestConsent('my-shard', { label: 'test', scopes: ['state:manage'] })).rejects.toThrow('No consent listener registered');
|
|
9
|
+
});
|
|
10
|
+
it('delivers the request to the listener with correct shape', async () => {
|
|
11
|
+
const received = [];
|
|
12
|
+
const off = registerConsentListener((req) => {
|
|
13
|
+
received.push(req);
|
|
14
|
+
queueMicrotask(() => resolveConsent(req.requestId, true));
|
|
15
|
+
});
|
|
16
|
+
cleanups.push(off);
|
|
17
|
+
await requestConsent('shard-a', { label: 'My key', scopes: ['documents:sync'], connectorId: 'peer-x' });
|
|
18
|
+
expect(received).toHaveLength(1);
|
|
19
|
+
const req = received[0];
|
|
20
|
+
expect(req.shardId).toBe('shard-a');
|
|
21
|
+
expect(req.label).toBe('My key');
|
|
22
|
+
expect(req.scopes).toEqual(['documents:sync']);
|
|
23
|
+
expect(req.connectorId).toBe('peer-x');
|
|
24
|
+
expect(typeof req.requestId).toBe('string');
|
|
25
|
+
});
|
|
26
|
+
it('resolves true when approved', async () => {
|
|
27
|
+
const off = registerConsentListener((req) => {
|
|
28
|
+
queueMicrotask(() => resolveConsent(req.requestId, true));
|
|
29
|
+
});
|
|
30
|
+
cleanups.push(off);
|
|
31
|
+
const result = await requestConsent('shard-b', { label: 'l', scopes: ['state:manage'] });
|
|
32
|
+
expect(result).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
it('resolves false when denied', async () => {
|
|
35
|
+
const off = registerConsentListener((req) => {
|
|
36
|
+
queueMicrotask(() => resolveConsent(req.requestId, false));
|
|
37
|
+
});
|
|
38
|
+
cleanups.push(off);
|
|
39
|
+
const result = await requestConsent('shard-c', { label: 'l', scopes: ['state:manage'] });
|
|
40
|
+
expect(result).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
it('ignores resolveConsent calls with unknown requestId', () => {
|
|
43
|
+
// Should not throw — just a no-op.
|
|
44
|
+
expect(() => resolveConsent('does-not-exist', true)).not.toThrow();
|
|
45
|
+
});
|
|
46
|
+
it('the deregistration returned by registerConsentListener removes the listener', async () => {
|
|
47
|
+
const off = registerConsentListener(() => {
|
|
48
|
+
throw new Error('Should not be called');
|
|
49
|
+
});
|
|
50
|
+
off(); // deregister immediately
|
|
51
|
+
await expect(requestConsent('shard-d', { label: 'l', scopes: ['state:manage'] })).rejects.toThrow('No consent listener registered');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notifies active shards when one of their minted keys is revoked —
|
|
3
|
+
* regardless of whether the revocation was initiated by the shell UI,
|
|
4
|
+
* the shard itself, or another tab.
|
|
5
|
+
*
|
|
6
|
+
* The bus is populated by a server-sent events stream on /api/keys/events
|
|
7
|
+
* (wired by the shell runtime at boot) and/or by local revoke() calls.
|
|
8
|
+
*/
|
|
9
|
+
type Handler = (keyId: string) => void;
|
|
10
|
+
/**
|
|
11
|
+
* Subscribe to revocation events for a specific shard, or for all shards
|
|
12
|
+
* by passing `'*'` as the shardId.
|
|
13
|
+
* Returns an unsubscribe function.
|
|
14
|
+
*/
|
|
15
|
+
export declare function subscribe(shardId: string | '*', handler: Handler): () => void;
|
|
16
|
+
/**
|
|
17
|
+
* Emit a revocation event for the given shardId.
|
|
18
|
+
* No-op if shardId is null or has no subscribers.
|
|
19
|
+
* Deduplicates events: if the same (shardId, keyId) pair was already emitted
|
|
20
|
+
* within the last 5 s it is silently dropped (covers local-then-SSE and
|
|
21
|
+
* SSE-then-local orderings).
|
|
22
|
+
* Internal use only — called by client.ts and the SSE stream bootstrap.
|
|
23
|
+
*/
|
|
24
|
+
export declare function emit(shardId: string | null, keyId: string): void;
|
|
25
|
+
/**
|
|
26
|
+
* Open a server-sent events stream at /api/keys/events and forward
|
|
27
|
+
* revocation events into the local bus.
|
|
28
|
+
*
|
|
29
|
+
* Should be called once at shell boot. Returns a cleanup function that
|
|
30
|
+
* closes the EventSource.
|
|
31
|
+
*
|
|
32
|
+
* No-op in environments without EventSource (e.g. Node test runner).
|
|
33
|
+
*/
|
|
34
|
+
export declare function startServerSideStream(): () => void;
|
|
35
|
+
export {};
|