Skip to content

Commit 2f96ac5

Browse files
committed
feat(awareness-service): ontology picker, API docs link, admin gating
- Subscription form now selects ontologies from the ontology service (https://ontology.w3ds.metastate.foundation/schemas) instead of free text, and takes eVault filters as a tag input. - Dashboard links straight to the interactive API reference, built from the API base URL. - The session JWT now carries an isAdmin claim; the portal hides the Admin nav link from non-admins.
1 parent 8aee102 commit 2f96ac5

5 files changed

Lines changed: 217 additions & 34 deletions

File tree

services/awareness-service/api/src/services/W3dsAuthService.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,13 @@ export class W3dsAuthService {
7878
}
7979

8080
issueToken(ename: string): string {
81-
return jwt.sign({ ename }, config.jwtSecret, { expiresIn: "7d" });
81+
// isAdmin is embedded so the portal can show/hide admin UI without an
82+
// extra round-trip; the API still re-checks it server-side.
83+
return jwt.sign(
84+
{ ename, isAdmin: config.adminEnames.includes(ename) },
85+
config.jwtSecret,
86+
{ expiresIn: "7d" },
87+
);
8288
}
8389

8490
verifyToken(token: string): { ename: string } | null {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* Client for the ontology service, which serves the catalogue of MetaEnvelope
3+
* schemas. Used to make ontologies selectable in the subscription UI.
4+
*/
5+
export const ONTOLOGY_BASE = "https://ontology.w3ds.metastate.foundation";
6+
7+
export interface OntologySchema {
8+
/** The schemaId (UUID) — this is what AaaS stores as a packet's ontology. */
9+
id: string;
10+
title: string;
11+
}
12+
13+
/** Fetches the full list of available ontologies. */
14+
export async function fetchSchemas(): Promise<OntologySchema[]> {
15+
const res = await fetch(`${ONTOLOGY_BASE}/schemas`);
16+
if (!res.ok) {
17+
throw new Error(`ontology service returned ${res.status}`);
18+
}
19+
const list = (await res.json()) as OntologySchema[];
20+
return list.map((s) => ({ id: s.id, title: s.title ?? s.id }));
21+
}

services/awareness-service/portal/src/lib/session.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { browser } from "$app/environment";
2-
import { writable } from "svelte/store";
2+
import { derived, writable } from "svelte/store";
33

44
const STORAGE_KEY = "aaas_session_token";
55

@@ -15,6 +15,30 @@ if (browser) {
1515
});
1616
}
1717

18+
export interface SessionClaims {
19+
ename: string;
20+
isAdmin: boolean;
21+
}
22+
23+
/** Decodes the (unverified) JWT payload — fine for UI gating only. */
24+
function decodeClaims(token: string | null): SessionClaims | null {
25+
if (!token) return null;
26+
try {
27+
const payload = JSON.parse(
28+
atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")),
29+
);
30+
return {
31+
ename: String(payload.ename ?? ""),
32+
isAdmin: payload.isAdmin === true,
33+
};
34+
} catch {
35+
return null;
36+
}
37+
}
38+
39+
/** Claims of the logged-in user, or null. */
40+
export const session = derived(sessionToken, ($t) => decodeClaims($t));
41+
1842
export function logout(): void {
1943
sessionToken.set(null);
2044
}

services/awareness-service/portal/src/routes/+layout.svelte

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
<script lang="ts">
22
import "../app.css";
3-
import { sessionToken, logout } from "$lib/session";
3+
import { sessionToken, session, logout } from "$lib/session";
44
55
let { children } = $props();
66
let loggedIn = $derived($sessionToken !== null);
7+
let isAdmin = $derived($session?.isAdmin ?? false);
78
</script>
89

910
<div class="min-h-screen">
@@ -16,7 +17,9 @@
1617
{#if loggedIn}
1718
<a href="/dashboard" class="text-sm text-gray-600 hover:text-gray-900">Dashboard</a>
1819
<a href="/apply" class="text-sm text-gray-600 hover:text-gray-900">Apply</a>
19-
<a href="/admin" class="text-sm text-gray-600 hover:text-gray-900">Admin</a>
20+
{#if isAdmin}
21+
<a href="/admin" class="text-sm text-gray-600 hover:text-gray-900">Admin</a>
22+
{/if}
2023
<button
2124
class="text-sm text-gray-600 hover:text-gray-900"
2225
onclick={logout}

services/awareness-service/portal/src/routes/dashboard/+page.svelte

Lines changed: 159 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22
import { onMount } from "svelte";
33
import { goto } from "$app/navigation";
44
import { get } from "svelte/store";
5-
import { api } from "$lib/api";
5+
import { api, API_BASE } from "$lib/api";
6+
import { fetchSchemas, type OntologySchema } from "$lib/ontology";
67
import { sessionToken } from "$lib/session";
78
9+
/** Link straight to the interactive API reference, derived from API_BASE. */
10+
const apiDocsUrl = `${API_BASE}/docs`;
11+
812
interface Consumer {
913
id: string;
1014
ename: string;
@@ -37,11 +41,41 @@
3741
3842
// new subscription form
3943
let subTarget = $state("");
40-
let subOntologies = $state("");
41-
let subEvaults = $state("");
44+
let schemas = $state<OntologySchema[]>([]);
45+
let selectedOntologies = $state<string[]>([]);
46+
let ontologyPick = $state("");
47+
let evaultTags = $state<string[]>([]);
48+
let evaultInput = $state("");
4249
4350
const token = () => get(sessionToken);
4451
52+
function ontologyTitle(id: string): string {
53+
return schemas.find((s) => s.id === id)?.title ?? id;
54+
}
55+
56+
function addOntology() {
57+
if (ontologyPick && !selectedOntologies.includes(ontologyPick)) {
58+
selectedOntologies = [...selectedOntologies, ontologyPick];
59+
}
60+
ontologyPick = "";
61+
}
62+
63+
function removeOntology(id: string) {
64+
selectedOntologies = selectedOntologies.filter((o) => o !== id);
65+
}
66+
67+
function addEvaultTag() {
68+
const tag = evaultInput.trim();
69+
if (tag && !evaultTags.includes(tag)) {
70+
evaultTags = [...evaultTags, tag];
71+
}
72+
evaultInput = "";
73+
}
74+
75+
function removeEvaultTag(tag: string) {
76+
evaultTags = evaultTags.filter((t) => t !== tag);
77+
}
78+
4579
async function loadApproved() {
4680
const [subs, dels, keys] = await Promise.all([
4781
api<{ subscriptions: Subscription[] }>("/api/subscriptions", {
@@ -105,17 +139,13 @@
105139
token: token(),
106140
body: {
107141
targetUrl: subTarget || undefined,
108-
ontologyFilter: subOntologies
109-
.split(",")
110-
.map((o) => o.trim())
111-
.filter(Boolean),
112-
evaultFilter: subEvaults
113-
.split(",")
114-
.map((o) => o.trim())
115-
.filter(Boolean),
142+
ontologyFilter: selectedOntologies,
143+
evaultFilter: evaultTags,
116144
},
117145
});
118-
subTarget = subOntologies = subEvaults = "";
146+
subTarget = "";
147+
selectedOntologies = [];
148+
evaultTags = [];
119149
await loadApproved();
120150
} catch (e) {
121151
error = e instanceof Error ? e.message : "failed to create subscription";
@@ -136,11 +166,25 @@
136166
return;
137167
}
138168
void load();
169+
// Best-effort: the form still works if the ontology service is down.
170+
fetchSchemas()
171+
.then((s) => (schemas = s))
172+
.catch(() => (schemas = []));
139173
});
140174
</script>
141175

142176
<section>
143-
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
177+
<div class="flex items-center justify-between">
178+
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
179+
<a
180+
href={apiDocsUrl}
181+
target="_blank"
182+
rel="noopener"
183+
class="rounded border border-indigo-200 bg-indigo-50 px-3 py-1.5 text-sm font-medium text-indigo-700 hover:bg-indigo-100"
184+
>
185+
API docs ↗
186+
</a>
187+
</div>
144188

145189
{#if error}
146190
<p class="mt-4 rounded bg-red-50 px-4 py-2 text-sm text-red-700">{error}</p>
@@ -213,23 +257,106 @@
213257
<!-- Subscriptions -->
214258
<div class="mt-6 rounded-lg border border-gray-200 bg-white p-6">
215259
<h2 class="font-semibold text-gray-900">Webhook subscriptions</h2>
216-
<div class="mt-3 grid gap-2 sm:grid-cols-3">
217-
<input
218-
bind:value={subTarget}
219-
placeholder="Target URL (optional)"
220-
class="rounded border border-gray-300 px-3 py-2 text-sm"
221-
/>
222-
<input
223-
bind:value={subOntologies}
224-
placeholder="Ontologies (comma, blank = all)"
225-
class="rounded border border-gray-300 px-3 py-2 text-sm"
226-
/>
227-
<input
228-
bind:value={subEvaults}
229-
placeholder="eVaults (comma, blank = all)"
230-
class="rounded border border-gray-300 px-3 py-2 text-sm"
231-
/>
260+
261+
<div class="mt-3 space-y-3">
262+
<label class="block">
263+
<span class="text-xs font-medium text-gray-600">
264+
Target URL (optional — defaults to your webhook base)
265+
</span>
266+
<input
267+
bind:value={subTarget}
268+
placeholder="https://my-platform.example/api/webhook"
269+
class="mt-1 w-full rounded border border-gray-300 px-3 py-2 text-sm"
270+
/>
271+
</label>
272+
273+
<!-- Ontology picker, fed by the ontology service -->
274+
<div>
275+
<span class="text-xs font-medium text-gray-600">
276+
Ontologies (none = all)
277+
</span>
278+
<div class="mt-1 flex gap-2">
279+
<select
280+
bind:value={ontologyPick}
281+
class="flex-1 rounded border border-gray-300 px-3 py-2 text-sm"
282+
>
283+
<option value="">Select an ontology…</option>
284+
{#each schemas as schema (schema.id)}
285+
<option value={schema.id}>
286+
{schema.title} ({schema.id})
287+
</option>
288+
{/each}
289+
</select>
290+
<button
291+
type="button"
292+
class="rounded border border-gray-300 px-3 py-2 text-sm"
293+
onclick={addOntology}
294+
>
295+
Add
296+
</button>
297+
</div>
298+
{#if schemas.length === 0}
299+
<p class="mt-1 text-xs text-gray-400">
300+
Ontology catalogue unavailable.
301+
</p>
302+
{/if}
303+
{#if selectedOntologies.length}
304+
<div class="mt-2 flex flex-wrap gap-2">
305+
{#each selectedOntologies as id (id)}
306+
<span
307+
class="inline-flex items-center gap-1 rounded-full bg-indigo-50 px-2.5 py-1 text-xs text-indigo-700"
308+
>
309+
{ontologyTitle(id)}
310+
<button
311+
type="button"
312+
class="text-indigo-400 hover:text-indigo-700"
313+
onclick={() => removeOntology(id)}
314+
>
315+
316+
</button>
317+
</span>
318+
{/each}
319+
</div>
320+
{/if}
321+
</div>
322+
323+
<!-- eVault filter as a tag input -->
324+
<div>
325+
<span class="text-xs font-medium text-gray-600">
326+
eVaults (none = all)
327+
</span>
328+
<input
329+
bind:value={evaultInput}
330+
onkeydown={(e) => {
331+
if (e.key === "Enter") {
332+
e.preventDefault();
333+
addEvaultTag();
334+
}
335+
}}
336+
placeholder="Type an eVault (w3id or public key) and press Enter"
337+
class="mt-1 w-full rounded border border-gray-300 px-3 py-2 text-sm"
338+
/>
339+
{#if evaultTags.length}
340+
<div class="mt-2 flex flex-wrap gap-2">
341+
{#each evaultTags as tag (tag)}
342+
<span
343+
class="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-1 text-xs text-gray-700"
344+
>
345+
{tag}
346+
<button
347+
type="button"
348+
class="text-gray-400 hover:text-gray-700"
349+
onclick={() => removeEvaultTag(tag)}
350+
>
351+
352+
</button>
353+
</span>
354+
{/each}
355+
</div>
356+
{/if}
357+
</div>
232358
</div>
359+
233360
<button
234361
class="mt-3 rounded bg-indigo-600 px-3 py-1.5 text-sm text-white"
235362
onclick={createSubscription}
@@ -250,7 +377,9 @@
250377
</div>
251378
<p class="mt-1 text-gray-500">
252379
ontologies: {sub.ontologyFilter.length
253-
? sub.ontologyFilter.join(", ")
380+
? sub.ontologyFilter
381+
.map((o) => ontologyTitle(o))
382+
.join(", ")
254383
: "all"} · eVaults: {sub.evaultFilter.length
255384
? sub.evaultFilter.join(", ")
256385
: "all"} · {sub.active ? "active" : "inactive"}

0 commit comments

Comments
 (0)