feat(v1.1.6): realtime channels + v1.1.5 follow-ups + version bumps

Server-side realtime SSE on per-app pub/sub topics, plus the three
v1.1.5 follow-ups and the version bumps.

Realtime:
- topics registry (0021) + admin endpoints + Capability::AppTopicManage
  (-> app:admin; no new scope).
- GET /realtime/topics/{topic} SSE endpoint (orchestrator-core data
  plane): Host -> app, RealtimeAuthority gate (404 missing/internal,
  401 bad/absent token), broadcast::Receiver stream + heartbeat.
- RealtimeBroadcaster / RealtimeEvent / RealtimeAuthority traits
  (picloud-shared); InProcessBroadcaster + GC (orchestrator-core);
  DB-backed RealtimeAuthorityImpl (manager-core). Publish path fans out
  to in-process subscribers after the durable outbox commit (best-effort,
  panic-isolated).
- HMAC subscriber tokens (subscriber_token.rs) + app_secrets table (0022)
  + pubsub::subscriber_token SDK (schema 1.6 -> 1.7). TTL clamp + env
  overrides.
- Dashboard Topics tab (register/list/edit/delete, prominent external
  badge, flip confirmation).

v1.1.5 follow-ups:
- Empty blobs accepted (NewFile/FileUpdate::validate) + round-trip test.
- Orphan *.tmp.* sweeper (spawn_files_orphan_sweep).
- Dispatcher e2e tests, one per trigger kind (DATABASE_URL-gated).

Versions: workspace 1.1.6, SDK 1.7, dashboard 0.12.0. Schema-snapshot
golden re-blessed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-04 20:18:50 +02:00
parent d064681c49
commit fcbcc576a2
35 changed files with 4333 additions and 63 deletions

View File

@@ -270,6 +270,28 @@ export interface CreatePubsubTriggerInput {
retry_base_ms?: number;
}
// v1.1.6 — externally-subscribable realtime topics.
export type TopicAuthMode = 'public' | 'token';
export interface Topic {
name: string;
external_subscribable: boolean;
auth_mode: TopicAuthMode;
created_at: string;
updated_at: string;
}
export interface CreateTopicInput {
name: string;
external_subscribable: boolean;
auth_mode: TopicAuthMode;
}
export interface UpdateTopicInput {
external_subscribable?: boolean;
auth_mode?: TopicAuthMode;
}
export interface ExecutionResult {
status: number;
headers: Record<string, string>;
@@ -653,6 +675,28 @@ export const api = {
)
},
topics: {
list: (idOrSlug: string) =>
adminRequest<{ topics: Topic[] }>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/topics`
),
create: (idOrSlug: string, input: CreateTopicInput) =>
adminRequest<Topic>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/topics`, {
method: 'POST',
body: JSON.stringify(input)
}),
update: (idOrSlug: string, name: string, input: UpdateTopicInput) =>
adminRequest<Topic>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/topics/${encodeURIComponent(name)}`,
{ method: 'PATCH', body: JSON.stringify(input) }
),
remove: (idOrSlug: string, name: string) =>
adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/topics/${encodeURIComponent(name)}`,
{ method: 'DELETE' }
)
},
files: {
list: (idOrSlug: string, collection: string, opts: { cursor?: string; limit?: number } = {}) => {
const params = new URLSearchParams();

View File

@@ -11,7 +11,9 @@
type AppMemberDto,
type AppRole,
type Script,
type Trigger
type Trigger,
type Topic,
type TopicAuthMode
} from '$lib/api';
import CodeEditor from '$lib/CodeEditor.svelte';
import ConfirmModal from '$lib/ConfirmModal.svelte';
@@ -25,7 +27,7 @@
const SAMPLE_SOURCE =
'#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
type Tab = 'scripts' | 'domains' | 'members' | 'settings' | 'triggers';
type Tab = 'scripts' | 'domains' | 'members' | 'settings' | 'triggers' | 'topics';
// Common IANA timezones offered in the cron form dropdown. Not
// exhaustive — the backend validates any IANA name via chrono-tz.
@@ -194,6 +196,100 @@
}
}
// Topics tab (v1.1.6 — externally-subscribable realtime topics). Admin-gated.
let topics = $state<Topic[]>([]);
let createTopicName = $state('');
let createTopicExternal = $state(false);
let createTopicAuthMode = $state<TopicAuthMode>('public');
let creatingTopic = $state(false);
let createTopicError = $state<string | null>(null);
// Edit modal.
let topicToEdit = $state<Topic | null>(null);
let editTopicExternal = $state(false);
let editTopicAuthMode = $state<TopicAuthMode>('public');
let savingTopic = $state(false);
let editTopicError = $state<string | null>(null);
// Flipping internal → external is the security-sensitive change.
const editFlipToExternal = $derived(
!!topicToEdit && !topicToEdit.external_subscribable && editTopicExternal
);
// Delete confirm.
let topicToRemove = $state<Topic | null>(null);
let removingTopic = $state(false);
async function loadTopics(idOrSlug: string) {
try {
const r = await api.topics.list(idOrSlug);
topics = r.topics;
} catch {
topics = [];
}
}
async function submitCreateTopic(e: SubmitEvent) {
e.preventDefault();
if (!app) return;
creatingTopic = true;
createTopicError = null;
try {
await api.topics.create(app.id, {
name: createTopicName.trim(),
external_subscribable: createTopicExternal,
auth_mode: createTopicAuthMode
});
createTopicName = '';
createTopicExternal = false;
createTopicAuthMode = 'public';
await loadTopics(app.id);
} catch (err) {
createTopicError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
creatingTopic = false;
}
}
function openEditTopic(t: Topic) {
topicToEdit = t;
editTopicExternal = t.external_subscribable;
editTopicAuthMode = t.auth_mode;
editTopicError = null;
}
async function confirmEditTopic() {
if (!app || !topicToEdit) return;
savingTopic = true;
editTopicError = null;
try {
await api.topics.update(app.id, topicToEdit.name, {
external_subscribable: editTopicExternal,
auth_mode: editTopicAuthMode
});
topicToEdit = null;
await loadTopics(app.id);
} catch (err) {
editTopicError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
savingTopic = false;
}
}
async function confirmRemoveTopic() {
if (!app || !topicToRemove) return;
removingTopic = true;
try {
await api.topics.remove(app.id, topicToRemove.name);
topicToRemove = null;
await loadTopics(app.id);
} catch (err) {
createTopicError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
removingTopic = false;
}
}
// Members tab
let eligibleUsers = $state<AdminDto[]>([]);
let eligibleLoadError = $state<string | null>(null);
@@ -234,7 +330,12 @@
loadDeadLetterCount(app.id)
];
if (canAdmin) {
loaders.push(loadMembers(app.id), loadEligibleUsers(), loadTriggers(app.id));
loaders.push(
loadMembers(app.id),
loadEligibleUsers(),
loadTriggers(app.id),
loadTopics(app.id)
);
}
await Promise.all(loaders);
} catch (e) {
@@ -503,7 +604,10 @@
$effect(() => {
if (
!canAdmin &&
(activeTab === 'settings' || activeTab === 'members' || activeTab === 'triggers')
(activeTab === 'settings' ||
activeTab === 'members' ||
activeTab === 'triggers' ||
activeTab === 'topics')
) {
activeTab = 'scripts';
}
@@ -551,6 +655,11 @@
class:active={activeTab === 'triggers'}
onclick={() => (activeTab = 'triggers')}>Triggers ({triggers.length})</button
>
<button
type="button"
class:active={activeTab === 'topics'}
onclick={() => (activeTab = 'topics')}>Topics ({topics.length})</button
>
<button
type="button"
class:active={activeTab === 'settings'}
@@ -939,6 +1048,89 @@
</ul>
{/if}
</section>
{:else if activeTab === 'topics' && canAdmin}
<section>
<h2>Realtime topics</h2>
<p class="muted">
Pub/sub topics are <strong>internal-only</strong> by default — scripts
subscribe via triggers, browsers can't. Register a topic here and mark it
<strong>externally subscribable</strong> to let frontend clients connect over
SSE at <code>/realtime/topics/&lt;name&gt;</code>. <code>public</code> topics
need no auth; <code>token</code> topics require a subscriber token minted by a
script via <code>pubsub::subscriber_token</code>.
</p>
<form class="create-form" onsubmit={submitCreateTopic}>
<div class="row">
<label class="grow">
<span>Topic name</span>
<input bind:value={createTopicName} required placeholder="chat-room-updates" />
</label>
</div>
<label class="checkbox-row">
<input type="checkbox" bind:checked={createTopicExternal} />
<span>Externally subscribable (allow browser SSE clients to subscribe)</span>
</label>
{#if createTopicExternal}
<fieldset class="auth-mode">
<legend>Auth mode</legend>
<label class="radio-row">
<input type="radio" value="public" bind:group={createTopicAuthMode} />
<span><strong>public</strong> — anyone with the URL can subscribe</span>
</label>
<label class="radio-row">
<input type="radio" value="token" bind:group={createTopicAuthMode} />
<span><strong>token</strong> — requires a valid subscriber token</span>
</label>
</fieldset>
{/if}
{#if createTopicError}
<div class="error">{createTopicError}</div>
{/if}
<div class="actions">
<button type="submit" disabled={creatingTopic || !createTopicName.trim()}>
{creatingTopic ? 'Creating…' : 'Register topic'}
</button>
</div>
</form>
{#if topics.length === 0}
<p class="muted">No registered topics in this app yet.</p>
{:else}
<ul class="list">
{#each topics as t (t.name)}
<li class="domain-row">
<div>
<code>{t.name}</code>
{#if t.external_subscribable}
<span class="badge badge-external" title="Browser clients can subscribe over SSE">
external
</span>
<span class="badge badge-auth">{t.auth_mode}</span>
{:else}
<span class="badge badge-internal" title="Internal-only: scripts subscribe via triggers">
internal
</span>
{/if}
<span class="muted small">· {shortDate(t.created_at)}</span>
</div>
<div class="topic-actions">
<button type="button" class="secondary" onclick={() => openEditTopic(t)}>
Edit
</button>
<button
type="button"
class="secondary danger"
onclick={() => (topicToRemove = t)}
>
Delete
</button>
</div>
</li>
{/each}
</ul>
{/if}
</section>
{:else if activeTab === 'settings' && canAdmin}
<section>
<h2>Settings</h2>
@@ -1113,6 +1305,65 @@
</p>
</ConfirmModal>
{/if}
{#if topicToEdit}
<ConfirmModal
title="Edit topic “{topicToEdit.name}”"
confirmLabel="Save changes"
busyLabel="Saving…"
busy={savingTopic}
onConfirm={confirmEditTopic}
onCancel={() => (topicToEdit = null)}
>
<label class="checkbox-row">
<input type="checkbox" bind:checked={editTopicExternal} />
<span>Externally subscribable</span>
</label>
{#if editTopicExternal}
<fieldset class="auth-mode">
<legend>Auth mode</legend>
<label class="radio-row">
<input type="radio" value="public" bind:group={editTopicAuthMode} />
<span><strong>public</strong> — anyone with the URL can subscribe</span>
</label>
<label class="radio-row">
<input type="radio" value="token" bind:group={editTopicAuthMode} />
<span><strong>token</strong> — requires a valid subscriber token</span>
</label>
</fieldset>
{/if}
{#if editFlipToExternal}
<div class="warning">
Marking <strong>{topicToEdit.name}</strong> externally-subscribable means
anyone with the URL can subscribe to this topic (if auth_mode is
<code>public</code>) or anyone with a valid token can subscribe (if
auth_mode is <code>token</code>). Are you sure?
</div>
{/if}
{#if editTopicError}
<p class="modal-error">{editTopicError}</p>
{/if}
</ConfirmModal>
{/if}
{#if topicToRemove}
<ConfirmModal
title="Delete topic “{topicToRemove.name}”"
variant="danger"
confirmLabel="Delete topic"
busyLabel="Deleting…"
busy={removingTopic}
onConfirm={confirmRemoveTopic}
onCancel={() => (topicToRemove = null)}
>
<p>
Unregistering <code>{topicToRemove.name}</code> disconnects any live SSE
subscribers immediately. Scripts can still <code>publish_durable</code> to
it (internal triggers keep working) — it just won't be externally
subscribable.
</p>
</ConfirmModal>
{/if}
{/if}
<style>
@@ -1463,4 +1714,64 @@
.small {
font-size: 0.85rem;
}
.checkbox-row,
.radio-row {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.checkbox-row input,
.radio-row input {
flex: none;
width: auto;
}
.auth-mode {
border: 1px solid #334155;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.auth-mode legend {
font-size: 0.8rem;
color: #94a3b8;
padding: 0 0.3rem;
}
.topic-actions {
display: flex;
gap: 0.5rem;
}
.badge {
display: inline-block;
padding: 0 0.45rem;
margin-left: 0.4rem;
border-radius: 0.25rem;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
line-height: 1.5;
}
.badge-external {
background: #064e3b;
color: #6ee7b7;
}
.badge-internal {
background: #334155;
color: #cbd5e1;
}
.badge-auth {
background: #1e3a5f;
color: #93c5fd;
}
</style>