Skip to content

Commit ebf1339

Browse files
mpstatonclaude
andcommitted
feat(shell, workspace): didi.sh identity badge + in-app sign-in in the header
The shell header now carries the didi.sh identity affordance across every micro-app. Signed-in state is server-verified: the workspace session frame's didi_id (set only when the WS upgrade carried a cookie the workspace verified via JWKS) drives the badge; /api/me supplies the email. The sign-in popover completes the whole magic-link flow without leaving augment-it per the headless contract — in dev the id service's token echo makes it one click; in prod the same panel reads "check your email". Sign out everywhere included. Workspace package: SessionFrame and UserContext gain didi_id so any remote can read the verified identity. svelte-check: no new errors (2 pre-existing remain untouched). Files changed: - shell/src/DidiBadge.svelte (new) - shell/src/App.svelte (header mount + import) - packages/workspace/src/{types,state.svelte}.ts (didi_id threading) - changelog/2026-07-06_02_The-Shell-Shows-Who-You-Are-Didi-Badge-And-In-App-Sign-In.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b642fba commit ebf1339

5 files changed

Lines changed: 309 additions & 1 deletion

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
date_created: 2026-07-06
3+
date_modified: 2026-07-06
4+
title: "The shell shows who you are — didi badge and in-app sign-in"
5+
lede: "The header gains a didi.sh identity badge that carries across every micro-app: server-verified didi_id state straight from the workspace session frame, and a sign-in popover that completes the whole magic-link flow without leaving augment-it — the headless GTM contract, working in dev."
6+
publish: true
7+
authors:
8+
- Michael Staton
9+
augmented_with:
10+
- Claude Code on Claude Fable 5
11+
files_changed:
12+
- shell/src/DidiBadge.svelte
13+
- shell/src/App.svelte
14+
- packages/workspace/src/types.ts
15+
- packages/workspace/src/state.svelte.ts
16+
tags:
17+
- Progress-Update
18+
- Auth
19+
- Didi-Platform
20+
- Shell
21+
- Header
22+
- Workspace-Package
23+
---
24+
25+
## Why Care?
26+
27+
This morning the workspace *verified* didi.sh sessions but nothing showed it —
28+
localhost:3100 gave no indication whether a didi.sh ID was connected. Now the
29+
header answers at a glance, shell-level so it rides above every mounted
30+
micro-app: a quiet "▣ sign in" pill when anonymous, a lit badge with your
31+
email when connected.
32+
33+
## What's New?
34+
35+
- **The badge trusts the server, not the client.** Signed-in state comes from
36+
the workspace session frame's `didi_id` — set only when the WS upgrade
37+
carried a cookie the workspace verified against the id service's JWKS. The
38+
email shown beside it comes from `/api/me`. The workspace package's
39+
`UserContext` now carries `didi_id` for any remote that wants it.
40+
- **Sign-in without leaving the app** (the headless contract): the popover
41+
posts to the id service's magic-link endpoints directly. In dev, the id
42+
service echoes the raw token, so sign-in completes in one click — email,
43+
send, reload, connected. In production the same panel becomes "check your
44+
email." Sign out everywhere kills the didi session domain-wide.
45+
- **CORS on the id side** (in the id-didi-sh repo): a config-driven
46+
exact-origin allowlist with credentials — dev lists
47+
`http://localhost:3100`; production will enumerate the `*.didi.sh`
48+
origins. Preflights answered before the router.
49+
50+
## What's Next
51+
52+
The last piece of spec increment 2: per-capability `didi_id` → org-role →
53+
workspace authorization. Then invites (id increment 3) and the
54+
`DIDI_AUTH=required` flip.

packages/workspace/src/state.svelte.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,11 @@ class AugmentItWorkspace {
243243

244244
private handleFrame(frame: ServerFrame): void {
245245
if (frame.kind === 'session') {
246-
this.user = { session_token: frame.token, user_id: this.user?.user_id };
246+
this.user = {
247+
session_token: frame.token,
248+
user_id: this.user?.user_id,
249+
didi_id: frame.didi_id ?? null,
250+
};
247251
} else if (frame.kind === 'event') {
248252
this.ingestEvent({
249253
seq: frame.seq,

packages/workspace/src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,9 @@ export type JobEvent = {
190190
export type UserContext = {
191191
session_token: string;
192192
user_id?: string;
193+
/** didi.sh stable person id — present when the WS upgrade carried a
194+
* verified didi_session cookie (server-verified, not client-asserted). */
195+
didi_id?: string | null;
193196
};
194197

195198
export type InvokeFrame = {
@@ -218,6 +221,8 @@ export type EventFrame = {
218221
export type SessionFrame = {
219222
kind: 'session';
220223
token: string;
224+
/** Verified didi.sh identity, or null when the upgrade had no valid cookie. */
225+
didi_id?: string | null;
221226
};
222227

223228
// --- Chat surface frames ---

shell/src/App.svelte

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import MountHost from './MountHost.svelte';
55
import FlowWidget from './FlowWidget.svelte';
66
import WorkspaceSwitcher from './WorkspaceSwitcher.svelte';
7+
import DidiBadge from './DidiBadge.svelte';
78
import ToggleHeader from '@augment-it/shared-ui/ToggleHeader__PromptOrPackage--Icons.svelte';
89
import { workspace } from '@augment-it/workspace';
910
import {
@@ -437,6 +438,7 @@
437438
💬 chat
438439
</button>
439440
<span class="muted">tiling host · :3100</span>
441+
<DidiBadge />
440442
<ModeToggle />
441443
<WorkspaceSwitcher />
442444
</div>

shell/src/DidiBadge.svelte

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
<script lang="ts">
2+
// DidiBadge — the shell's didi.sh identity affordance (spec increment 2,
3+
// shell half). Lives in the header so it carries across every mounted
4+
// micro-app: the identity is shell-level, not per-remote.
5+
//
6+
// Signed-in state comes from the WORKSPACE session frame (didi_id is
7+
// server-verified on the WS upgrade — never client-asserted); the email
8+
// shown alongside comes from the id service's /api/me. Sign-in follows
9+
// the headless contract: this panel owns the pixels and calls the
10+
// magic-link endpoints directly. In dev the id service echoes the raw
11+
// token (echo_login_tokens), so sign-in completes without a mailbox;
12+
// in prod the same panel becomes "check your email".
13+
14+
import { workspace } from '@augment-it/workspace';
15+
16+
// Rsbuild injects PUBLIC_* env at build; shell tsconfig has no env
17+
// typings, hence the cast. Defaults to the local dev id service.
18+
const ID_BASE =
19+
((import.meta as { env?: Record<string, string> }).env?.PUBLIC_ID_BASE as
20+
| string
21+
| undefined) ?? 'http://localhost:4000';
22+
23+
let email = $state('');
24+
let busy = $state(false);
25+
let notice = $state('');
26+
let me = $state<{ email?: string; name?: string } | null>(null);
27+
28+
const didiId = $derived(workspace.user?.didi_id ?? null);
29+
30+
$effect(() => {
31+
if (didiId && !me) {
32+
fetch(`${ID_BASE}/api/me`, { credentials: 'include' })
33+
.then((r) => (r.ok ? r.json() : null))
34+
.then((j) => {
35+
me = j;
36+
})
37+
.catch(() => {});
38+
}
39+
});
40+
41+
async function signIn(e: SubmitEvent) {
42+
e.preventDefault();
43+
if (!email || busy) return;
44+
busy = true;
45+
notice = '';
46+
try {
47+
const issue = await fetch(`${ID_BASE}/api/magic-links`, {
48+
method: 'POST',
49+
credentials: 'include',
50+
headers: { 'content-type': 'application/json' },
51+
body: JSON.stringify({ email, app: 'augment-it' }),
52+
}).then((r) => r.json());
53+
54+
if (issue.dev_token) {
55+
const redeem = await fetch(`${ID_BASE}/api/magic-links/redeem`, {
56+
method: 'POST',
57+
credentials: 'include',
58+
headers: { 'content-type': 'application/json' },
59+
body: JSON.stringify({ token: issue.dev_token }),
60+
});
61+
if (redeem.ok) {
62+
// Cookie is set — reload so the WS upgrade carries it and the
63+
// workspace re-verifies. (A transport reconnect would also work;
64+
// reload is the honest v0.)
65+
window.location.reload();
66+
return;
67+
}
68+
notice = 'Redeem failed — token expired?';
69+
} else {
70+
// Invite-only: unknown emails get the same 202 (no enumeration).
71+
notice =
72+
'If that address has a didi.sh ID, a sign-in link was sent. ' +
73+
'(dev: seed with `mix id.seed` — no dev token came back)';
74+
}
75+
} catch {
76+
notice = `id service unreachable at ${ID_BASE}`;
77+
}
78+
busy = false;
79+
}
80+
81+
async function signOut() {
82+
try {
83+
await fetch(`${ID_BASE}/api/session`, { method: 'DELETE', credentials: 'include' });
84+
} catch {
85+
// cookie may outlive an unreachable id service; reload clears state
86+
}
87+
window.location.reload();
88+
}
89+
</script>
90+
91+
<details class="didi">
92+
<summary
93+
class="didi-badge"
94+
class:on={didiId !== null}
95+
title={didiId ? `didi.sh · ${didiId}` : 'No didi.sh ID connected'}
96+
>
97+
<span class="didi-mark" aria-hidden="true">▣</span>
98+
{#if didiId}
99+
<span class="didi-label">{me?.email ?? didiId.slice(0, 8)}</span>
100+
{:else}
101+
<span class="didi-label didi-label--muted">sign in</span>
102+
{/if}
103+
</summary>
104+
105+
<div class="didi-pop">
106+
{#if didiId}
107+
<p class="didi-pop__head">didi.sh ID · connected</p>
108+
{#if me?.name}<p class="didi-row"><span>name</span>{me.name}</p>{/if}
109+
{#if me?.email}<p class="didi-row"><span>email</span>{me.email}</p>{/if}
110+
<p class="didi-row"><span>sub</span>{didiId}</p>
111+
<p class="didi-row"><span>verified</span>on WS upgrade · JWKS</p>
112+
<button class="didi-btn" onclick={signOut}>Sign out everywhere</button>
113+
{:else}
114+
<p class="didi-pop__head">Connect your didi.sh ID</p>
115+
<form onsubmit={signIn}>
116+
<input
117+
class="didi-input"
118+
type="email"
119+
placeholder="you@example.com"
120+
bind:value={email}
121+
required
122+
/>
123+
<button class="didi-btn didi-btn--primary" disabled={busy}>
124+
{busy ? 'signing in…' : 'Send magic link'}
125+
</button>
126+
</form>
127+
{#if notice}<p class="didi-notice">{notice}</p>{/if}
128+
<p class="didi-fine">Invite-only · no passwords · one login across didi.sh</p>
129+
{/if}
130+
</div>
131+
</details>
132+
133+
<style>
134+
.didi {
135+
position: relative;
136+
}
137+
.didi-badge {
138+
display: inline-flex;
139+
align-items: center;
140+
gap: 5px;
141+
padding: 3px 9px;
142+
border: 1px solid var(--color-border-strong, rgba(255, 255, 255, 0.2));
143+
border-radius: 999px;
144+
cursor: pointer;
145+
list-style: none;
146+
font-size: 11px;
147+
color: var(--color-text-dim, #9a9a9a);
148+
background: transparent;
149+
user-select: none;
150+
}
151+
.didi-badge::-webkit-details-marker {
152+
display: none;
153+
}
154+
.didi-badge.on {
155+
color: var(--color-thread, #55e0d2);
156+
border-color: color-mix(in oklab, var(--color-thread, #55e0d2) 45%, transparent);
157+
background: color-mix(in oklab, var(--color-thread, #55e0d2) 10%, transparent);
158+
}
159+
.didi-mark {
160+
font-size: 12px;
161+
}
162+
.didi-label--muted {
163+
opacity: 0.8;
164+
}
165+
166+
.didi-pop {
167+
position: absolute;
168+
right: 0;
169+
top: calc(100% + 8px);
170+
z-index: 90;
171+
width: 280px;
172+
padding: 12px 14px;
173+
border: 1px solid var(--color-border-strong, rgba(255, 255, 255, 0.2));
174+
border-radius: 8px;
175+
background: var(--color-bg-elevated, #1b1b22);
176+
box-shadow: 0 18px 40px -18px rgba(0, 0, 0, 0.6);
177+
font-size: 12px;
178+
}
179+
.didi-pop__head {
180+
margin: 0 0 8px;
181+
font-family: var(--font__mono, monospace);
182+
font-size: 10px;
183+
letter-spacing: 0.14em;
184+
text-transform: uppercase;
185+
color: var(--color-text-dim, #9a9a9a);
186+
}
187+
.didi-row {
188+
display: flex;
189+
gap: 8px;
190+
margin: 3px 0;
191+
font-family: var(--font__mono, monospace);
192+
font-size: 11px;
193+
overflow-wrap: anywhere;
194+
}
195+
.didi-row span {
196+
flex: 0 0 56px;
197+
color: var(--color-text-dim, #9a9a9a);
198+
text-transform: uppercase;
199+
font-size: 9px;
200+
letter-spacing: 0.12em;
201+
padding-top: 2px;
202+
}
203+
.didi-input {
204+
width: 100%;
205+
padding: 7px 9px;
206+
margin-bottom: 8px;
207+
border: 1px solid var(--color-border-strong, rgba(255, 255, 255, 0.2));
208+
border-radius: 5px;
209+
background: var(--color-bg, #101014);
210+
color: var(--color-text, #eee);
211+
font-size: 12px;
212+
}
213+
.didi-btn {
214+
width: 100%;
215+
padding: 7px 9px;
216+
border: 1px solid var(--color-border-strong, rgba(255, 255, 255, 0.25));
217+
border-radius: 5px;
218+
background: transparent;
219+
color: var(--color-text, #eee);
220+
font-size: 12px;
221+
cursor: pointer;
222+
}
223+
.didi-btn--primary {
224+
background: var(--color-accent, #4ecf95);
225+
border-color: var(--color-accent, #4ecf95);
226+
color: var(--color-bg, #101014);
227+
font-weight: 600;
228+
}
229+
.didi-btn:disabled {
230+
opacity: 0.6;
231+
cursor: wait;
232+
}
233+
.didi-notice {
234+
margin: 8px 0 0;
235+
color: var(--color-accent-warm, #d29a62);
236+
font-size: 11px;
237+
}
238+
.didi-fine {
239+
margin: 10px 0 0;
240+
color: var(--color-text-dim, #9a9a9a);
241+
font-size: 10px;
242+
}
243+
</style>

0 commit comments

Comments
 (0)