@openparachute/hub 0.5.7 → 0.5.9-rc.6
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/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +70 -323
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/expose-2fa-warning.test.ts +3 -5
- package/src/__tests__/expose-cloudflare.test.ts +1 -1
- package/src/__tests__/expose.test.ts +2 -2
- package/src/__tests__/hub-server.test.ts +338 -65
- package/src/__tests__/hub.test.ts +108 -55
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/oauth-handlers.test.ts +266 -5
- package/src/__tests__/operator-token.test.ts +379 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +32 -254
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +6 -6
- package/src/commands/status.ts +74 -10
- package/src/csrf.ts +6 -3
- package/src/help.ts +10 -4
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +426 -97
- package/src/hub.ts +272 -149
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/oauth-handlers.ts +183 -54
- package/src/oauth-ui.ts +23 -2
- package/src/operator-token.ts +272 -18
- package/src/origin-check.ts +127 -0
- package/src/rate-limit.ts +5 -2
- package/src/scope-explanations.ts +33 -2
- package/src/sessions.ts +1 -1
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
- package/web/ui/dist/assets/index-D54otIhv.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
package/src/hub.ts
CHANGED
|
@@ -1,18 +1,34 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { CONFIG_DIR } from "./config.ts";
|
|
4
|
+
import { CSRF_FIELD_NAME } from "./csrf.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Hub page served at `/` when the node is exposed.
|
|
7
8
|
*
|
|
8
|
-
* The page is
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
9
|
+
* The page is split into two sections, organized by **ownership**:
|
|
10
|
+
*
|
|
11
|
+
* - **Services** — surfaces provided by the modules running on this
|
|
12
|
+
* hub. Browse notes (the Notes PWA); transcribe audio (Scribe); run
|
|
13
|
+
* agents (Agent). Each entry points at the service's own UI; the
|
|
14
|
+
* service owns what's behind the link (use, config, admin —
|
|
15
|
+
* whatever it chooses to surface). Entries are dynamic, derived
|
|
16
|
+
* from `/.well-known/parachute.json`; only installed services show
|
|
17
|
+
* up. Vault deliberately doesn't have its own Services entry — its
|
|
18
|
+
* content is browsed via Notes, so a separate "Vault" tile would
|
|
19
|
+
* just send the operator to the admin SPA, which is exactly the
|
|
20
|
+
* friction Aaron flagged ("clicked Vault, took me to hub management").
|
|
21
|
+
*
|
|
22
|
+
* - **Admin** — hub-owned admin surfaces for cross-cutting host
|
|
23
|
+
* concerns. Always visible: even with zero vaults installed, an
|
|
24
|
+
* operator may want to provision the first one. Three entries:
|
|
25
|
+
* Vaults (provisioning), Permissions (OAuth consent grants), Tokens
|
|
26
|
+
* (registry mint/list/revoke).
|
|
27
|
+
*
|
|
28
|
+
* The Services-vs-Admin axis is ownership, not function: services-owned
|
|
29
|
+
* UIs vs hub-owned UIs. The first cut framed it as "Use vs Admin" but
|
|
30
|
+
* that broke down once you noticed real services have UIs that mix use,
|
|
31
|
+
* config, and admin together — the cleaner cut is who's hosting the UI.
|
|
16
32
|
*
|
|
17
33
|
* The file stays self-contained (inline CSS + JS, no external assets) so
|
|
18
34
|
* `tailscale serve` can mount it directly from disk with `--set-path=/`.
|
|
@@ -21,21 +37,85 @@ import { CONFIG_DIR } from "./config.ts";
|
|
|
21
37
|
export const HUB_PATH = join(CONFIG_DIR, "well-known", "hub.html");
|
|
22
38
|
export const HUB_MOUNT = "/";
|
|
23
39
|
|
|
24
|
-
export
|
|
25
|
-
|
|
40
|
+
export interface RenderHubSession {
|
|
41
|
+
/** displayName from /api/me semantics — username today, profile field later. */
|
|
42
|
+
displayName: string;
|
|
43
|
+
/** Per-session CSRF token; embedded in the inline sign-out form. */
|
|
44
|
+
csrfToken: string;
|
|
26
45
|
}
|
|
27
46
|
|
|
47
|
+
export interface RenderHubOpts {
|
|
48
|
+
/**
|
|
49
|
+
* When set, renders "Signed in as <displayName>" + an inline sign-out
|
|
50
|
+
* form. When omitted (or null), renders a "Sign in" link. Pass through
|
|
51
|
+
* from `findActiveSession` + `ensureCsrfToken` in the hub-server `/`
|
|
52
|
+
* handler — the static-disk write path (`writeHubFile`) emits the
|
|
53
|
+
* signed-out shape, since that file gets served only when the
|
|
54
|
+
* dynamic path can't (`!getDb`).
|
|
55
|
+
*/
|
|
56
|
+
session?: RenderHubSession | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function renderHub(opts: RenderHubOpts = {}): string {
|
|
60
|
+
return buildHtml(opts);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Write the static signed-out HTML to disk. Used by `parachute expose` so
|
|
65
|
+
* `tailscale serve --set-path=/` has a file to back. The dynamic
|
|
66
|
+
* session-aware path runs through `hub-server.ts`'s `/` handler whenever
|
|
67
|
+
* a DB is configured; this disk file is the fallback when it isn't.
|
|
68
|
+
*/
|
|
28
69
|
export function writeHubFile(path: string = HUB_PATH): string {
|
|
29
70
|
if (!existsSync(dirname(path))) {
|
|
30
71
|
mkdirSync(dirname(path), { recursive: true });
|
|
31
72
|
}
|
|
73
|
+
const html = renderHub();
|
|
32
74
|
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
33
|
-
writeFileSync(tmp,
|
|
75
|
+
writeFileSync(tmp, html);
|
|
34
76
|
renameSync(tmp, path);
|
|
35
77
|
return path;
|
|
36
78
|
}
|
|
37
79
|
|
|
38
|
-
|
|
80
|
+
function buildHtml({ session }: RenderHubOpts): string {
|
|
81
|
+
const authBlock = session
|
|
82
|
+
? renderSignedIn(session.displayName, session.csrfToken)
|
|
83
|
+
: renderSignedOut();
|
|
84
|
+
return HTML_TEMPLATE.replace("<!--AUTH-INDICATOR-->", authBlock);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function renderSignedIn(displayName: string, csrfToken: string): string {
|
|
88
|
+
// Inline POST form so sign-out works without JS. Submit button is
|
|
89
|
+
// styled as a text link via `.auth-signout` so the visual weight
|
|
90
|
+
// matches the surrounding "Signed in as <name>" text.
|
|
91
|
+
return `<div class="auth-indicator">
|
|
92
|
+
<span class="muted">Signed in as <strong>${escapeHtml(displayName)}</strong></span>
|
|
93
|
+
<form method="POST" action="/logout" class="auth-signout-form">
|
|
94
|
+
<input type="hidden" name="${CSRF_FIELD_NAME}" value="${escapeAttr(csrfToken)}" />
|
|
95
|
+
<button type="submit" class="auth-signout">Sign out</button>
|
|
96
|
+
</form>
|
|
97
|
+
</div>`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function renderSignedOut(): string {
|
|
101
|
+
return `<div class="auth-indicator">
|
|
102
|
+
<a href="/login?next=/" class="auth-signin">Sign in</a>
|
|
103
|
+
</div>`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function escapeHtml(s: string): string {
|
|
107
|
+
return s
|
|
108
|
+
.replace(/&/g, "&")
|
|
109
|
+
.replace(/</g, "<")
|
|
110
|
+
.replace(/>/g, ">")
|
|
111
|
+
.replace(/"/g, """);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function escapeAttr(s: string): string {
|
|
115
|
+
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const HTML_TEMPLATE = `<!doctype html>
|
|
39
119
|
<html lang="en">
|
|
40
120
|
<head>
|
|
41
121
|
<meta charset="utf-8" />
|
|
@@ -94,6 +174,50 @@ const HTML = `<!doctype html>
|
|
|
94
174
|
header {
|
|
95
175
|
text-align: center;
|
|
96
176
|
margin-bottom: 3.5rem;
|
|
177
|
+
position: relative;
|
|
178
|
+
}
|
|
179
|
+
/* Auth indicator: small text + sign-in/out affordance, top-right of the
|
|
180
|
+
header. Doesn't crowd the centered title; falls below the title on
|
|
181
|
+
narrow viewports via the media query at the bottom of this stylesheet. */
|
|
182
|
+
.auth-indicator {
|
|
183
|
+
position: absolute;
|
|
184
|
+
top: 0;
|
|
185
|
+
right: 0;
|
|
186
|
+
display: inline-flex;
|
|
187
|
+
align-items: baseline;
|
|
188
|
+
gap: 0.5rem;
|
|
189
|
+
font-size: 0.85rem;
|
|
190
|
+
color: var(--fg-muted);
|
|
191
|
+
}
|
|
192
|
+
.auth-indicator .muted {
|
|
193
|
+
color: var(--fg-muted);
|
|
194
|
+
}
|
|
195
|
+
.auth-indicator strong {
|
|
196
|
+
font-weight: 600;
|
|
197
|
+
color: var(--fg);
|
|
198
|
+
}
|
|
199
|
+
.auth-signout-form {
|
|
200
|
+
margin: 0;
|
|
201
|
+
display: inline;
|
|
202
|
+
}
|
|
203
|
+
.auth-signout, .auth-signin {
|
|
204
|
+
background: none;
|
|
205
|
+
border: none;
|
|
206
|
+
padding: 0;
|
|
207
|
+
color: var(--accent);
|
|
208
|
+
font: inherit;
|
|
209
|
+
cursor: pointer;
|
|
210
|
+
text-decoration: underline;
|
|
211
|
+
text-decoration-thickness: 1px;
|
|
212
|
+
text-underline-offset: 2px;
|
|
213
|
+
}
|
|
214
|
+
.auth-signout:hover, .auth-signin:hover {
|
|
215
|
+
color: var(--accent-hover);
|
|
216
|
+
}
|
|
217
|
+
a.auth-signin {
|
|
218
|
+
/* Anchor needs explicit reset since the a element has its own
|
|
219
|
+
color/decoration. */
|
|
220
|
+
border-bottom: none;
|
|
97
221
|
}
|
|
98
222
|
h1 {
|
|
99
223
|
font-family: var(--serif);
|
|
@@ -108,6 +232,22 @@ const HTML = `<!doctype html>
|
|
|
108
232
|
font-size: 1.1rem;
|
|
109
233
|
margin: 0;
|
|
110
234
|
}
|
|
235
|
+
.section {
|
|
236
|
+
margin-bottom: 3rem;
|
|
237
|
+
}
|
|
238
|
+
.section h2 {
|
|
239
|
+
font-family: var(--serif);
|
|
240
|
+
font-weight: 400;
|
|
241
|
+
font-size: 1.5rem;
|
|
242
|
+
color: var(--fg);
|
|
243
|
+
margin: 0 0 0.4rem;
|
|
244
|
+
letter-spacing: -0.005em;
|
|
245
|
+
}
|
|
246
|
+
.section .section-sub {
|
|
247
|
+
color: var(--fg-muted);
|
|
248
|
+
font-size: 0.92rem;
|
|
249
|
+
margin: 0 0 1.25rem;
|
|
250
|
+
}
|
|
111
251
|
.grid {
|
|
112
252
|
display: grid;
|
|
113
253
|
gap: 1.25rem;
|
|
@@ -117,12 +257,12 @@ const HTML = `<!doctype html>
|
|
|
117
257
|
background: var(--card-bg);
|
|
118
258
|
border: 1px solid var(--border);
|
|
119
259
|
border-radius: 12px;
|
|
120
|
-
padding: 1.
|
|
260
|
+
padding: 1.5rem;
|
|
121
261
|
text-decoration: none;
|
|
122
262
|
color: inherit;
|
|
123
263
|
display: flex;
|
|
124
264
|
flex-direction: column;
|
|
125
|
-
gap: 0.
|
|
265
|
+
gap: 0.5rem;
|
|
126
266
|
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
|
|
127
267
|
opacity: 0;
|
|
128
268
|
animation: fadeUp 0.4s ease forwards;
|
|
@@ -131,46 +271,22 @@ const HTML = `<!doctype html>
|
|
|
131
271
|
.card:nth-child(2) { animation-delay: 0.06s; }
|
|
132
272
|
.card:nth-child(3) { animation-delay: 0.1s; }
|
|
133
273
|
.card:nth-child(4) { animation-delay: 0.14s; }
|
|
134
|
-
.card:nth-child(5) { animation-delay: 0.18s; }
|
|
135
|
-
.card:nth-child(n+6) { animation-delay: 0.22s; }
|
|
136
274
|
.card:hover {
|
|
137
275
|
border-color: var(--accent-light);
|
|
138
276
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
|
|
139
277
|
transform: translateY(-2px);
|
|
140
278
|
}
|
|
141
|
-
.card-head {
|
|
142
|
-
display: flex;
|
|
143
|
-
align-items: center;
|
|
144
|
-
gap: 0.75rem;
|
|
145
|
-
}
|
|
146
|
-
.icon {
|
|
147
|
-
width: 2.25rem;
|
|
148
|
-
height: 2.25rem;
|
|
149
|
-
display: flex;
|
|
150
|
-
align-items: center;
|
|
151
|
-
justify-content: center;
|
|
152
|
-
background: var(--accent-soft);
|
|
153
|
-
border-radius: 8px;
|
|
154
|
-
color: var(--accent);
|
|
155
|
-
font-size: 1.25rem;
|
|
156
|
-
flex-shrink: 0;
|
|
157
|
-
overflow: hidden;
|
|
158
|
-
}
|
|
159
|
-
.icon img, .icon svg {
|
|
160
|
-
width: 100%;
|
|
161
|
-
height: 100%;
|
|
162
|
-
object-fit: contain;
|
|
163
|
-
}
|
|
164
279
|
.card-title {
|
|
165
280
|
font-family: var(--serif);
|
|
166
|
-
font-size: 1.
|
|
281
|
+
font-size: 1.4rem;
|
|
167
282
|
font-weight: 400;
|
|
168
283
|
margin: 0;
|
|
169
284
|
line-height: 1.1;
|
|
285
|
+
color: var(--fg);
|
|
170
286
|
}
|
|
171
|
-
.card-
|
|
287
|
+
.card-desc {
|
|
172
288
|
color: var(--fg-muted);
|
|
173
|
-
font-size: 0.
|
|
289
|
+
font-size: 0.92rem;
|
|
174
290
|
margin: 0;
|
|
175
291
|
flex-grow: 1;
|
|
176
292
|
}
|
|
@@ -178,7 +294,7 @@ const HTML = `<!doctype html>
|
|
|
178
294
|
display: flex;
|
|
179
295
|
align-items: center;
|
|
180
296
|
justify-content: space-between;
|
|
181
|
-
margin-top: 0.
|
|
297
|
+
margin-top: 0.5rem;
|
|
182
298
|
font-size: 0.8rem;
|
|
183
299
|
color: var(--fg-dim);
|
|
184
300
|
}
|
|
@@ -186,14 +302,14 @@ const HTML = `<!doctype html>
|
|
|
186
302
|
font-family: ui-monospace, 'SF Mono', Monaco, monospace;
|
|
187
303
|
color: var(--fg-muted);
|
|
188
304
|
}
|
|
189
|
-
.
|
|
305
|
+
.arrow {
|
|
190
306
|
color: var(--accent);
|
|
191
307
|
font-weight: 500;
|
|
192
308
|
}
|
|
193
309
|
.empty, .error {
|
|
194
310
|
text-align: center;
|
|
195
311
|
color: var(--fg-muted);
|
|
196
|
-
padding:
|
|
312
|
+
padding: 1.5rem 1rem;
|
|
197
313
|
border: 1px dashed var(--border);
|
|
198
314
|
border-radius: 12px;
|
|
199
315
|
}
|
|
@@ -225,154 +341,161 @@ const HTML = `<!doctype html>
|
|
|
225
341
|
}
|
|
226
342
|
@media (max-width: 640px) {
|
|
227
343
|
main { padding: 2.5rem 1rem 4rem; }
|
|
228
|
-
.card { padding: 1.
|
|
344
|
+
.card { padding: 1.25rem; }
|
|
345
|
+
/* Auth indicator drops below the title on narrow viewports so the
|
|
346
|
+
header doesn't crowd. */
|
|
347
|
+
header { padding-top: 1.75rem; }
|
|
348
|
+
.auth-indicator { font-size: 0.8rem; }
|
|
229
349
|
}
|
|
230
350
|
</style>
|
|
231
351
|
</head>
|
|
232
352
|
<body>
|
|
233
353
|
<main>
|
|
234
354
|
<header>
|
|
355
|
+
<!--AUTH-INDICATOR-->
|
|
235
356
|
<h1>Parachute</h1>
|
|
236
357
|
<p class="tagline">Your personal-computing modules.</p>
|
|
237
358
|
</header>
|
|
238
|
-
|
|
239
|
-
|
|
359
|
+
|
|
360
|
+
<section class="section" id="services-section">
|
|
361
|
+
<h2>Services</h2>
|
|
362
|
+
<p class="section-sub">Surfaces provided by services running on this hub.</p>
|
|
363
|
+
<div class="grid" id="services-grid" aria-live="polite">
|
|
364
|
+
<div class="empty" id="services-loading">Loading…</div>
|
|
365
|
+
</div>
|
|
240
366
|
</section>
|
|
367
|
+
|
|
368
|
+
<section class="section" id="admin-section">
|
|
369
|
+
<h2>Admin</h2>
|
|
370
|
+
<p class="section-sub">Manage this hub — vaults, permissions, tokens.</p>
|
|
371
|
+
<div class="grid" id="admin-grid"></div>
|
|
372
|
+
</section>
|
|
373
|
+
|
|
241
374
|
<footer>
|
|
242
375
|
<a href="/.well-known/parachute.json">discovery</a>
|
|
243
376
|
</footer>
|
|
244
377
|
</main>
|
|
245
378
|
<script>
|
|
246
379
|
(async () => {
|
|
247
|
-
const
|
|
248
|
-
const
|
|
380
|
+
const servicesGrid = document.getElementById('services-grid');
|
|
381
|
+
const adminGrid = document.getElementById('admin-grid');
|
|
249
382
|
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
383
|
+
// Services entries are now data-driven from /.well-known/parachute.json.
|
|
384
|
+
// Each services[] row carries (since hub#... — Phase D consumer side):
|
|
385
|
+
// - displayName: human label (sourced from module.json:displayName).
|
|
386
|
+
// - uiUrl: where the user-facing UI lives (sourced from module.json:uiUrl).
|
|
387
|
+
// A row WITHOUT uiUrl declines to render a tile — the module is
|
|
388
|
+
// either API-only (vault, scribe-without-UI) or surfaces its UI
|
|
389
|
+
// through a sibling (vault → Notes).
|
|
390
|
+
// The previous SERVICE_LABELS / SERVICE_ORDER / isVaultName hardcoding
|
|
391
|
+
// is retired: vault has no uiUrl, so the "skip vault" rule emerges
|
|
392
|
+
// from data rather than a name check; ordering is alphabetical-by-
|
|
393
|
+
// displayName per the module-json-extensibility pattern doc.
|
|
259
394
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
395
|
+
// Admin entries: always visible. Even a fresh hub with zero vaults wants
|
|
396
|
+
// the operator to find /admin/vaults. Hardcoded — they live in the
|
|
397
|
+
// hub-served SPA, not in services.json.
|
|
398
|
+
const ADMIN_ENTRIES = [
|
|
399
|
+
{ title: 'Vaults', desc: 'Create and manage vaults on this hub.', href: '/admin/vaults' },
|
|
400
|
+
{ title: 'Permissions', desc: 'OAuth consent grants per app.', href: '/admin/permissions' },
|
|
401
|
+
{ title: 'Tokens', desc: 'Mint and revoke access tokens.', href: '/admin/tokens' },
|
|
402
|
+
];
|
|
263
403
|
|
|
264
404
|
function shortName(manifestName) {
|
|
265
405
|
return manifestName.replace(/^parachute-/, '');
|
|
266
406
|
}
|
|
267
407
|
|
|
268
|
-
function
|
|
269
|
-
if (MODULE_LABELS[type]) return MODULE_LABELS[type];
|
|
270
|
-
return type.charAt(0).toUpperCase() + type.slice(1);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// Aggregate services + vaults into one entry per module type. Vault is
|
|
274
|
-
// special-cased because its count comes from doc.vaults[] (one entry per
|
|
275
|
-
// vault instance / mount path) and its manage link goes to the hub's
|
|
276
|
-
// per-vault SPA at /vault — not to any single vault backend.
|
|
277
|
-
function aggregate(services, vaults) {
|
|
278
|
-
const groups = new Map();
|
|
279
|
-
if (vaults.length > 0) {
|
|
280
|
-
groups.set('vault', {
|
|
281
|
-
type: 'vault',
|
|
282
|
-
label: 'Vault',
|
|
283
|
-
count: vaults.length,
|
|
284
|
-
manageUrl: '/vault',
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
for (const svc of services) {
|
|
288
|
-
if (isVaultName(svc.name)) continue;
|
|
289
|
-
const t = shortName(svc.name);
|
|
290
|
-
const existing = groups.get(t);
|
|
291
|
-
if (existing) {
|
|
292
|
-
existing.count += 1;
|
|
293
|
-
} else {
|
|
294
|
-
groups.set(t, {
|
|
295
|
-
type: t,
|
|
296
|
-
label: labelFor(t),
|
|
297
|
-
count: 1,
|
|
298
|
-
manageUrl: svc.path,
|
|
299
|
-
});
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
return groups;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function tilesInOrder(groups) {
|
|
306
|
-
const out = [];
|
|
307
|
-
for (const t of MODULE_ORDER) {
|
|
308
|
-
const g = groups.get(t);
|
|
309
|
-
if (g) out.push(g);
|
|
310
|
-
}
|
|
311
|
-
const known = new Set(MODULE_ORDER);
|
|
312
|
-
const extras = [...groups.values()]
|
|
313
|
-
.filter((g) => !known.has(g.type))
|
|
314
|
-
.sort((a, b) => a.type.localeCompare(b.type));
|
|
315
|
-
return out.concat(extras);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
function renderTile(group) {
|
|
408
|
+
function renderTile({ title, desc, href }) {
|
|
319
409
|
const a = document.createElement('a');
|
|
320
410
|
a.className = 'card';
|
|
321
|
-
a.href =
|
|
322
|
-
|
|
323
|
-
const head = document.createElement('div');
|
|
324
|
-
head.className = 'card-head';
|
|
411
|
+
a.href = href;
|
|
325
412
|
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
413
|
+
const t = document.createElement('h3');
|
|
414
|
+
t.className = 'card-title';
|
|
415
|
+
t.textContent = title;
|
|
416
|
+
a.appendChild(t);
|
|
329
417
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
a.appendChild(head);
|
|
337
|
-
|
|
338
|
-
const count = document.createElement('p');
|
|
339
|
-
count.className = 'card-count';
|
|
340
|
-
count.textContent = group.count === 1 ? '1 registered' : group.count + ' registered';
|
|
341
|
-
a.appendChild(count);
|
|
418
|
+
if (desc) {
|
|
419
|
+
const d = document.createElement('p');
|
|
420
|
+
d.className = 'card-desc';
|
|
421
|
+
d.textContent = desc;
|
|
422
|
+
a.appendChild(d);
|
|
423
|
+
}
|
|
342
424
|
|
|
343
425
|
const meta = document.createElement('div');
|
|
344
426
|
meta.className = 'card-meta';
|
|
345
427
|
const path = document.createElement('span');
|
|
346
428
|
path.className = 'path';
|
|
347
|
-
path.textContent =
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
429
|
+
path.textContent = href;
|
|
430
|
+
const arrow = document.createElement('span');
|
|
431
|
+
arrow.className = 'arrow';
|
|
432
|
+
arrow.textContent = '→';
|
|
351
433
|
meta.appendChild(path);
|
|
352
|
-
meta.appendChild(
|
|
434
|
+
meta.appendChild(arrow);
|
|
353
435
|
a.appendChild(meta);
|
|
354
436
|
|
|
355
437
|
return a;
|
|
356
438
|
}
|
|
357
439
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
440
|
+
function renderAdmin() {
|
|
441
|
+
adminGrid.innerHTML = '';
|
|
442
|
+
for (const entry of ADMIN_ENTRIES) {
|
|
443
|
+
adminGrid.appendChild(renderTile(entry));
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function renderServices(services) {
|
|
448
|
+
// Render one tile per service that declares a uiUrl. Entries without
|
|
449
|
+
// uiUrl are intentionally omitted — vault is the canonical example
|
|
450
|
+
// (its content is browsed via Notes, which has its own uiUrl row).
|
|
451
|
+
// Multiple entries with the same shortName collapse into one tile;
|
|
452
|
+
// operators with two scribe instances pick the first arbitrarily,
|
|
453
|
+
// and they'd know which they meant.
|
|
454
|
+
const byShort = new Map();
|
|
455
|
+
for (const svc of services) {
|
|
456
|
+
if (!svc || !svc.uiUrl) continue;
|
|
457
|
+
const key = shortName(svc.name);
|
|
458
|
+
if (byShort.has(key)) continue;
|
|
459
|
+
byShort.set(key, {
|
|
460
|
+
title: svc.displayName || key,
|
|
461
|
+
desc: svc.tagline || '',
|
|
462
|
+
href: svc.uiUrl,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
364
465
|
|
|
365
|
-
|
|
366
|
-
|
|
466
|
+
// Alphabetical-by-displayName per the module-json-extensibility pattern.
|
|
467
|
+
// Stable for shared-prefix labels (Notes, Notes-Lite would sort that way).
|
|
468
|
+
const tiles = Array.from(byShort.values()).sort((a, b) =>
|
|
469
|
+
a.title.localeCompare(b.title),
|
|
470
|
+
);
|
|
367
471
|
|
|
472
|
+
servicesGrid.innerHTML = '';
|
|
368
473
|
if (tiles.length === 0) {
|
|
369
|
-
|
|
474
|
+
const empty = document.createElement('div');
|
|
475
|
+
empty.className = 'empty';
|
|
476
|
+
empty.innerHTML =
|
|
477
|
+
'No services with a UI declared yet. Modules surface their UIs by ' +
|
|
478
|
+
'declaring <code>uiUrl</code> in <code>module.json</code> ' +
|
|
479
|
+
'(see the module-json-extensibility pattern).';
|
|
480
|
+
servicesGrid.appendChild(empty);
|
|
370
481
|
return;
|
|
371
482
|
}
|
|
372
|
-
|
|
373
|
-
|
|
483
|
+
for (const tile of tiles) servicesGrid.appendChild(renderTile(tile));
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Admin section is static — render synchronously so the operator sees it
|
|
487
|
+
// even if the well-known fetch is slow or fails.
|
|
488
|
+
renderAdmin();
|
|
489
|
+
|
|
490
|
+
try {
|
|
491
|
+
const wk = await fetch('/.well-known/parachute.json', { credentials: 'omit' });
|
|
492
|
+
if (!wk.ok) throw new Error('well-known fetch failed: ' + wk.status);
|
|
493
|
+
const doc = await wk.json();
|
|
494
|
+
const services = Array.isArray(doc.services) ? doc.services : [];
|
|
495
|
+
renderServices(services);
|
|
374
496
|
} catch (err) {
|
|
375
|
-
|
|
497
|
+
servicesGrid.innerHTML = '<div class="error">Could not load services: ' +
|
|
498
|
+
(err && err.message ? err.message : String(err)) + '</div>';
|
|
376
499
|
}
|
|
377
500
|
})();
|
|
378
501
|
</script>
|