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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picloud-dashboard",
|
||||
"version": "0.11.0",
|
||||
"version": "0.12.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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/<name></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>
|
||||
|
||||
Reference in New Issue
Block a user