apps
example-sender
homepage
relay
migrations
src
db
delivery
lib
rpc
telegram
xrpc
test
web
docs
packages
···
1
1
# atmo.pub
2
2
3
3
Notifications for the AT Protocol. Any atproto app can ask a user for permission
4
4
-
to notify them; the user approves which apps may notify them in a web dashboard,
5
5
-
and deliveries go out (v1) through a Telegram bot.
4
4
+
to notify them; the user approves which apps may notify them in a web dashboard
5
5
+
and chooses where alerts go — web push, Telegram, email, Bluesky DM, or a webhook
6
6
+
— while everything lands in an inbox.
6
7
7
7
-
- **Dashboard** — sign in, approve apps, link Telegram: https://atmo.pub
8
8
-
- **Developer docs**: https://atmo.pub/docs
8
8
+
- **Dashboard** — sign in, approve apps, connect channels: https://atmo.pub
9
9
+
- **Developer docs**: https://docs.atmo.pub/docs
9
10
- **Live example sender**: https://example.atmo.pub · [source](apps/example-sender)
10
11
- **Relay API + `did:web`**: https://relay.atmo.pub
11
12
···
21
22
- **`send`** — authenticated by **your app's own DID key**. Delivers to a user who
22
23
has granted you permission.
23
24
24
24
-
Full walkthrough with code is at **https://atmo.pub/docs**, and
25
25
+
Full walkthrough with code is at **https://docs.atmo.pub/docs**, and
25
26
[`apps/example-sender`](apps/example-sender) is a complete, ~300-line reference
26
27
implementation of both flows (live at https://example.atmo.pub).
27
28
28
29
## This repo
29
30
30
31
- **`apps/relay`** — the Cloudflare Worker at `relay.atmo.pub`. Pure XRPC: it
31
31
-
verifies inbound service-auth JWTs, stores grants / pending requests / channels
32
32
-
in D1, rate-limits with KV, and dispatches deliveries through a Cloudflare Queue.
33
33
-
No firehose, no repo records, no signing.
34
34
-
- **`apps/web`** — the SvelteKit dashboard at `atmo.pub` (+ `/docs`).
32
32
+
verifies inbound service-auth JWTs; stores grants, pending requests, delivery
33
33
+
targets, the inbox, and routing in D1; rate-limits with KV; and dispatches
34
34
+
deliveries (web push, Telegram, email, Bluesky DM, webhook) through a Cloudflare
35
35
+
Queue. No firehose, no repo records, no signing.
36
36
+
- **`apps/web`** — the SvelteKit dashboard at `atmo.pub`.
37
37
+
- **`apps/homepage`** — the marketing landing + developer docs at `docs.atmo.pub`.
35
38
- **`apps/example-sender`** — one-page sender demo at `example.atmo.pub`.
36
36
-
- **`packages/lexicons`** — the shared `pub.atmo.notify.*` lexicons (13) and
37
37
-
generated types.
39
39
+
- **`packages/lexicons`** — the shared `pub.atmo.notify.*` lexicons and generated types.
38
40
39
41
Running, configuring, and deploying everything: **[DEVELOPMENT.md](docs/DEVELOPMENT.md)**.
40
42
Want notifications for your own app without depending on atmo.pub? Run the relay
···
1
1
+
<script lang="ts">
2
2
+
// A compact channel-set route picker, modelled on atmo.pub's own picker but
3
3
+
// self-contained and channel-level (no per-instance pinning). Modes:
4
4
+
// inherit (optional) · Inbox only · Off · Custom (channel checkboxes)
5
5
+
import { CHANNELS, channelLabel, channelsRoute, routeChannels } from '$lib/routes';
6
6
+
7
7
+
let {
8
8
+
value,
9
9
+
inherit,
10
10
+
available,
11
11
+
disabled = false,
12
12
+
onchange
13
13
+
}: {
14
14
+
/** Current route: an inherit sentinel, 'off', 'inbox', or a channel set. */
15
15
+
value: string;
16
16
+
/** Optional inherit choice (account default for the app, app default for categories). */
17
17
+
inherit?: { token: 'default' | 'app'; label: string };
18
18
+
/** Channel types the user has a target for; omitted = show all. */
19
19
+
available?: string[];
20
20
+
disabled?: boolean;
21
21
+
onchange: (route: string) => void;
22
22
+
} = $props();
23
23
+
24
24
+
// Did the user explicitly open Custom? An empty custom selection stores 'inbox',
25
25
+
// so without this the tree would collapse back to "Inbox only".
26
26
+
let customOpen = $state(false);
27
27
+
28
28
+
const inheriting = $derived(inherit !== undefined && value === inherit.token);
29
29
+
const selected = $derived(routeChannels(value));
30
30
+
const hasChannels = $derived(selected.length > 0);
31
31
+
32
32
+
// Only offer channels the user has a target for — but keep showing one that's
33
33
+
// already in the route (so it can still be turned off after the target is gone).
34
34
+
const visibleChannels = $derived(
35
35
+
CHANNELS.filter((c) => available === undefined || available.includes(c) || selected.includes(c))
36
36
+
);
37
37
+
38
38
+
type Mode = 'inherit' | 'inbox' | 'off' | 'custom';
39
39
+
const mode = $derived<Mode>(
40
40
+
inheriting ? 'inherit' : value === 'off' ? 'off' : hasChannels || customOpen ? 'custom' : 'inbox'
41
41
+
);
42
42
+
43
43
+
function pickInherit() {
44
44
+
customOpen = false;
45
45
+
if (inherit) onchange(inherit.token);
46
46
+
}
47
47
+
function pickInbox() {
48
48
+
customOpen = false;
49
49
+
onchange('inbox');
50
50
+
}
51
51
+
function pickOff() {
52
52
+
customOpen = false;
53
53
+
onchange('off');
54
54
+
}
55
55
+
function pickCustom() {
56
56
+
customOpen = true;
57
57
+
if (!hasChannels) onchange('inbox');
58
58
+
}
59
59
+
60
60
+
function toggle(channel: string) {
61
61
+
const next = selected.includes(channel)
62
62
+
? selected.filter((c) => c !== channel)
63
63
+
: [...selected, channel];
64
64
+
// channelsRoute returns 'off' for an empty set; in Custom mode we mean
65
65
+
// "inbox only" (recorded, no alerts) instead.
66
66
+
const route = channelsRoute(next);
67
67
+
onchange(route === 'off' ? 'inbox' : route);
68
68
+
}
69
69
+
70
70
+
const modeBtn =
71
71
+
'rounded-md border px-2.5 py-1 text-sm font-medium transition-colors disabled:opacity-50';
72
72
+
const on = 'border-accent bg-accent-soft text-accent';
73
73
+
const off = 'border-line text-muted hover:bg-surface-2 hover:text-fg';
74
74
+
</script>
75
75
+
76
76
+
<div class="flex flex-col gap-2">
77
77
+
<div class="flex flex-wrap gap-1.5">
78
78
+
{#if inherit}
79
79
+
<button
80
80
+
type="button"
81
81
+
{disabled}
82
82
+
onclick={pickInherit}
83
83
+
aria-pressed={mode === 'inherit'}
84
84
+
class="{modeBtn} {mode === 'inherit' ? on : off}"
85
85
+
>
86
86
+
{inherit.label}
87
87
+
</button>
88
88
+
{/if}
89
89
+
<button
90
90
+
type="button"
91
91
+
{disabled}
92
92
+
onclick={pickInbox}
93
93
+
aria-pressed={mode === 'inbox'}
94
94
+
class="{modeBtn} {mode === 'inbox' ? on : off}"
95
95
+
>
96
96
+
Inbox only
97
97
+
</button>
98
98
+
<button
99
99
+
type="button"
100
100
+
{disabled}
101
101
+
onclick={pickOff}
102
102
+
aria-pressed={mode === 'off'}
103
103
+
class="{modeBtn} {mode === 'off' ? on : off}"
104
104
+
>
105
105
+
Off
106
106
+
</button>
107
107
+
<button
108
108
+
type="button"
109
109
+
{disabled}
110
110
+
onclick={pickCustom}
111
111
+
aria-pressed={mode === 'custom'}
112
112
+
class="{modeBtn} {mode === 'custom' ? on : off}"
113
113
+
>
114
114
+
Custom
115
115
+
</button>
116
116
+
</div>
117
117
+
118
118
+
{#if mode === 'custom'}
119
119
+
<div class="flex flex-col gap-0.5 rounded-md border border-line bg-surface-2 p-2">
120
120
+
{#each visibleChannels as c (c)}
121
121
+
{@const checked = selected.includes(c)}
122
122
+
<button
123
123
+
type="button"
124
124
+
{disabled}
125
125
+
role="checkbox"
126
126
+
aria-checked={checked}
127
127
+
onclick={() => toggle(c)}
128
128
+
class="flex w-full items-center gap-2 rounded px-1.5 py-1 text-left text-sm text-fg transition-colors hover:bg-surface disabled:opacity-50"
129
129
+
>
130
130
+
<span
131
131
+
class="flex size-4 shrink-0 items-center justify-center rounded border {checked
132
132
+
? 'border-accent bg-accent'
133
133
+
: 'border-line'}"
134
134
+
>
135
135
+
{#if checked}
136
136
+
<svg
137
137
+
width="11"
138
138
+
height="11"
139
139
+
viewBox="0 0 24 24"
140
140
+
fill="none"
141
141
+
stroke="currentColor"
142
142
+
stroke-width="3"
143
143
+
stroke-linecap="round"
144
144
+
stroke-linejoin="round"
145
145
+
class="text-accent-fg"
146
146
+
aria-hidden="true"
147
147
+
>
148
148
+
<path d="M5 12l5 5L20 7" />
149
149
+
</svg>
150
150
+
{/if}
151
151
+
</span>
152
152
+
{channelLabel(c)}
153
153
+
</button>
154
154
+
{:else}
155
155
+
<p class="px-1.5 py-1 text-xs text-muted-2">No channels connected.</p>
156
156
+
{/each}
157
157
+
</div>
158
158
+
{/if}
159
159
+
</div>
···
7
7
/** This app's sender DID — what the user approves and what `send` authenticates as. */
8
8
export const SENDER_DID = `did:web:${APP_DOMAIN}`;
9
9
10
10
-
/** Display name + description shown to the user when requesting permission. */
10
10
+
/** Display name + description + icon shown to the user when requesting permission. */
11
11
export const APP_TITLE = 'Example Sender';
12
12
export const APP_DESCRIPTION = 'Demo of how to integrate with atmo.pub.';
13
13
+
/** Absolute URL — the relay stores it on the grant and atmo.pub shows it in the app grid. */
14
14
+
export const APP_ICON_URL = `https://${APP_DOMAIN}/icon.svg`;
13
15
14
16
/**
15
17
* The relay's XRPC API. In dev (`vite dev`) talk to the local relay running via
···
46
48
'rpc?lxm=pub.atmo.auth&aud=*',
47
49
'rpc?lxm=pub.atmo.notify.setRouting&aud=*',
48
50
'rpc?lxm=pub.atmo.notify.getRouting&aud=*',
51
51
+
'rpc?lxm=pub.atmo.notify.setCategories&aud=*',
49
52
'rpc?lxm=pub.atmo.notify.listNotifications&aud=*',
50
53
'rpc?lxm=pub.atmo.notify.markRead&aud=*'
51
54
].join(' ');
55
55
+
56
56
+
/**
57
57
+
* The categories this app declares to atmo.pub (via `setCategories`). A real app
58
58
+
* lists the kinds of notifications it sends so the user can route each one
59
59
+
* separately; we declare two so the per-category routing UI has something to show.
60
60
+
*/
61
61
+
export const DEMO_CATEGORIES = [
62
62
+
{ id: 'mentions', title: 'Mentions', description: 'When someone mentions you' },
63
63
+
{ id: 'digest', title: 'Weekly digest', description: 'A periodic summary' }
64
64
+
] as const;
52
65
53
66
/** Branding. */
54
67
export const PROJECT_NAME = 'atmo.pub · example sender';
···
1
1
-
// Remote command functions the page calls. requestNotifications goes through the
2
2
-
// user's OAuth session; sendTest signs with this app's own key; the routing/inbox
3
3
-
// commands carry both tokens (dual-auth).
1
1
+
// Remote command functions the page calls. `connect` (requestPermission) goes
2
2
+
// through the user's OAuth session; `sendTest` signs with this app's own key; the
3
3
+
// routing commands carry both tokens (dual-auth). After a mutation the page calls
4
4
+
// invalidateAll() so the server `load` re-reads the live state.
4
5
import { command, getRequestEvent } from '$app/server';
5
6
import { error } from '@sveltejs/kit';
6
7
import * as v from 'valibot';
7
8
8
9
import { APP_DOMAIN } from '$lib/config';
9
10
import {
10
10
-
getRoutingForUser,
11
11
listNotificationsForUser,
12
12
markAllReadForUser,
13
13
mintAppLoginUrl,
···
15
15
sendAsSender,
16
16
setRoutingForUser
17
17
} from '$lib/server/relay';
18
18
-
import type { AppRoute, NotificationView, RoutingView } from '$lib/types';
18
18
+
import type { NotificationView } from '$lib/types';
19
19
20
20
/** The user's OAuth client, or a 401 if they're not signed in. */
21
21
function requireClient() {
···
26
26
return locals.client;
27
27
}
28
28
29
29
-
export const requestNotifications = command(() => requestPermissionForUser(requireClient()));
29
29
+
/** Ask the user (via their PDS-minted token) to grant this app notify permission. */
30
30
+
export const connect = command(
31
31
+
(): Promise<{ id: string; status: 'pending' | 'alreadyGranted' }> =>
32
32
+
requestPermissionForUser(requireClient())
33
33
+
);
30
34
31
31
-
/** Build a one-time link that signs the user into atmo.pub (cross-app login). */
32
32
-
export const openInAtmo = command(async (): Promise<{ url: string }> => {
33
33
-
return { url: await mintAppLoginUrl(requireClient()) };
34
34
-
});
35
35
+
/** Build a one-time link that signs the user into atmo.pub, deep-linked to `redirect`. */
36
36
+
export const openInAtmo = command(
37
37
+
v.object({ redirect: v.optional(v.string()) }),
38
38
+
async ({ redirect }): Promise<{ url: string }> => {
39
39
+
return { url: await mintAppLoginUrl(requireClient(), redirect) };
40
40
+
}
41
41
+
);
35
42
36
43
export type SendResult =
37
44
| { ok: true; id: string; delivered: number }
38
45
| { ok: false; reason: 'notApproved' };
39
46
40
47
export const sendTest = command(
41
41
-
v.object({ title: v.optional(v.string()), body: v.optional(v.string()) }),
42
42
-
async ({ title, body }): Promise<SendResult> => {
48
48
+
v.object({
49
49
+
title: v.optional(v.string()),
50
50
+
body: v.optional(v.string()),
51
51
+
category: v.optional(v.string())
52
52
+
}),
53
53
+
async ({ title, body, category }): Promise<SendResult> => {
43
54
const { locals } = getRequestEvent();
44
55
if (!locals.did) {
45
56
error(401, 'Not signed in');
···
49
60
recipient: locals.did,
50
61
title: title?.trim() || 'Hello from the example sender',
51
62
body: body?.trim() || 'If you can read this, the integration works end to end.',
52
52
-
uri: `https://${APP_DOMAIN}`
63
63
+
uri: `https://${APP_DOMAIN}`,
64
64
+
category: category?.trim() || undefined
53
65
});
54
66
return { ok: true, ...result };
55
67
} catch (e) {
···
64
76
65
77
// --- Dual-auth: manage this app's own routing + inbox for the signed-in user ---
66
78
67
67
-
export const getRouting = command((): Promise<RoutingView> => getRoutingForUser(requireClient()));
79
79
+
/** Set the app-wide route ('default' inherits the account default). */
80
80
+
export const setAppRoute = command(
81
81
+
v.object({ route: v.string() }),
82
82
+
({ route }): Promise<{ ok: boolean }> => setRoutingForUser(requireClient(), { route })
83
83
+
);
68
84
69
69
-
export const setRouting = command(
70
70
-
v.object({ route: v.picklist(['push', 'telegram', 'push+telegram', 'off', 'default']) }),
71
71
-
({ route }): Promise<{ ok: boolean }> =>
72
72
-
setRoutingForUser(requireClient(), { route: route as AppRoute })
85
85
+
/** Set one category's route ('app' inherits the app-wide route). */
86
86
+
export const setCategoryRoute = command(
87
87
+
v.object({ id: v.string(), route: v.string() }),
88
88
+
({ id, route }): Promise<{ ok: boolean }> =>
89
89
+
setRoutingForUser(requireClient(), { categories: [{ id, route }] })
73
90
);
74
91
75
92
export const listNotifications = command(
76
93
(): Promise<{ notifications: NotificationView[]; cursor?: string }> =>
77
77
-
listNotificationsForUser(requireClient(), { limit: 25 })
94
94
+
listNotificationsForUser(requireClient(), { limit: 10 })
78
95
);
79
96
80
97
export const markAllRead = command((): Promise<{ marked: number }> =>
···
1
1
+
// Minimal, hand-written route helpers — the same channel-set format atmo.pub
2
2
+
// uses, kept dependency-light for the demo. A route is a `+`-joined set of
3
3
+
// channel tokens; a token may be a bare channel ('push') or pin one instance
4
4
+
// ('push:<id>'). This demo routes at the channel level, so it reads any
5
5
+
// instance-pinned tokens as "that whole channel is on" and writes bare channels.
6
6
+
export const CHANNELS = ['push', 'telegram', 'email', 'dm', 'webhook'] as const;
7
7
+
export type Channel = (typeof CHANNELS)[number];
8
8
+
9
9
+
const CHANNEL_LABEL: Record<string, string> = {
10
10
+
push: 'Push',
11
11
+
telegram: 'Telegram',
12
12
+
email: 'Email',
13
13
+
dm: 'Bluesky DM',
14
14
+
webhook: 'Webhook'
15
15
+
};
16
16
+
17
17
+
/** Display label for a single channel (capitalized id as a fallback). */
18
18
+
export function channelLabel(c: string): string {
19
19
+
return CHANNEL_LABEL[c] ?? c.charAt(0).toUpperCase() + c.slice(1);
20
20
+
}
21
21
+
22
22
+
const SENTINELS = new Set(['off', 'inbox', 'default', 'app', '']);
23
23
+
24
24
+
/** The distinct channels a route fires (sentinels → none), in canonical order. */
25
25
+
export function routeChannels(route: string): string[] {
26
26
+
if (SENTINELS.has(route)) return [];
27
27
+
const present = new Set<string>();
28
28
+
for (const part of route.split('+')) {
29
29
+
const channel = part.split(':')[0];
30
30
+
if ((CHANNELS as readonly string[]).includes(channel)) present.add(channel);
31
31
+
}
32
32
+
return CHANNELS.filter((c) => present.has(c));
33
33
+
}
34
34
+
35
35
+
/** Encode whole channels as a canonical route string ('off' when empty). */
36
36
+
export function channelsRoute(channels: string[]): string {
37
37
+
const set = CHANNELS.filter((c) => channels.includes(c));
38
38
+
return set.length > 0 ? set.join('+') : 'off';
39
39
+
}
40
40
+
41
41
+
/** Human label for a route value (sentinel, 'off'/'inbox', or a channel set). */
42
42
+
export function routeLabel(route: string): string {
43
43
+
if (route === 'default') return 'Account default';
44
44
+
if (route === 'app') return 'App default';
45
45
+
if (route === 'off') return 'Off';
46
46
+
if (route === 'inbox') return 'Inbox only';
47
47
+
const ch = routeChannels(route);
48
48
+
return ch.length > 0 ? ch.map(channelLabel).join(' + ') : 'Inbox only';
49
49
+
}
···
7
7
8
8
import {
9
9
APP_DESCRIPTION,
10
10
+
APP_ICON_URL,
10
11
APP_TITLE,
11
12
APPLOGIN_LXM,
12
13
DASHBOARD_ORIGIN,
···
75
76
return postRelay(token, lxm, {
76
77
senderDid: SENDER_DID,
77
78
title: APP_TITLE,
78
78
-
description: APP_DESCRIPTION
79
79
+
description: APP_DESCRIPTION,
80
80
+
iconUrl: APP_ICON_URL
79
81
}) as Promise<{ id: string; status: 'pending' | 'alreadyGranted' }>;
80
82
}
81
83
···
85
87
* deep-linked to THIS app's settings page. No login form, no PDS round-trip on
86
88
* atmo.pub's side. See CROSS-APP-AUTH.md.
87
89
*/
88
88
-
export async function mintAppLoginUrl(client: NonNullable<AppClient>): Promise<string> {
90
90
+
export async function mintAppLoginUrl(
91
91
+
client: NonNullable<AppClient>,
92
92
+
redirect = `/apps/${SENDER_DID}`
93
93
+
): Promise<string> {
89
94
const token = encodeURIComponent(await mintUserToken(client, APPLOGIN_LXM));
90
90
-
const redirect = `/apps/${SENDER_DID}`;
91
95
return `${DASHBOARD_ORIGIN}/applogin?token=${token}&redirect=${encodeURIComponent(redirect)}`;
92
96
}
93
97
···
100
104
title: string;
101
105
body: string;
102
106
uri?: string;
107
107
+
/** Optional routing category (one of the app's declared categories). */
108
108
+
category?: string;
103
109
}): Promise<{ id: string; delivered: number }> {
104
110
const lxm = 'pub.atmo.notify.send';
105
111
const jwt = await mintSenderJwt(lxm);
···
134
140
input: { route?: AppRoute; categories?: { id: string; route: CategoryRoute }[] }
135
141
): Promise<{ ok: boolean }> {
136
142
return dualAuthCall(client, 'pub.atmo.notify.setRouting', input) as Promise<{ ok: boolean }>;
143
143
+
}
144
144
+
145
145
+
/**
146
146
+
* Declare this app's category catalog for the user (full sync). A real app calls
147
147
+
* this once it knows which kinds of notifications it sends, so the user gets a
148
148
+
* per-category routing UI. Omitting `route` just declares the category (it keeps
149
149
+
* any route the user already chose).
150
150
+
*/
151
151
+
export function setCategoriesForUser(
152
152
+
client: NonNullable<AppClient>,
153
153
+
categories: { id: string; title?: string; description?: string; route?: CategoryRoute }[]
154
154
+
): Promise<{ ok: boolean }> {
155
155
+
return dualAuthCall(client, 'pub.atmo.notify.setCategories', { categories }) as Promise<{
156
156
+
ok: boolean;
157
157
+
}>;
137
158
}
138
159
139
160
/** List the notifications this app has sent the user (read state + delivery). */
···
2
2
// `$lib/server` so the component can import them too. A real integrator would
3
3
// likely codegen these from the published lexicons; hand-typing keeps the demo
4
4
// dependency-light.
5
5
+
//
6
6
+
// A route is a `+`-joined set of channel tokens (e.g. 'push+telegram'), 'off'
7
7
+
// (drop), or 'inbox' (record only). App-wide routes add the inherit sentinel
8
8
+
// 'default'; per-category routes add 'app'. See $lib/routes for helpers.
5
9
6
6
-
export type AlertRoute = 'push' | 'telegram' | 'push+telegram' | 'off';
10
10
+
/** A concrete route: a channel-token set, or 'off' / 'inbox'. */
11
11
+
export type AlertRoute = string;
7
12
/** App-wide route; 'default' inherits the user's account default. */
8
8
-
export type AppRoute = AlertRoute | 'default';
13
13
+
export type AppRoute = string;
9
14
/** Per-category route; 'app' inherits the app-wide route. */
10
10
-
export type CategoryRoute = AlertRoute | 'app';
15
15
+
export type CategoryRoute = string;
16
16
+
17
17
+
/** A user delivery target, with a privacy-safe label (no raw email/handle). */
18
18
+
export interface RoutingTarget {
19
19
+
type: string;
20
20
+
id: string;
21
21
+
label: string;
22
22
+
}
23
23
+
24
24
+
export interface RoutingCategory {
25
25
+
id: string;
26
26
+
title?: string;
27
27
+
description?: string;
28
28
+
route: CategoryRoute;
29
29
+
}
11
30
12
31
export interface RoutingView {
32
32
+
/** App-wide route for everything from this app. */
13
33
route: AppRoute;
34
34
+
/** The user's account default (what 'default' resolves to). */
14
35
defaultRoute: AlertRoute;
15
15
-
categories: { id: string; description?: string; route: CategoryRoute }[];
36
36
+
categories: RoutingCategory[];
37
37
+
/** The user's deliverable targets, so we can show which channels are usable. */
38
38
+
targets: RoutingTarget[];
16
39
}
17
40
18
41
export interface NotificationView {
···
1
1
import { loadHandle } from '@svelte-atproto/oauth/helper';
2
2
import { memory } from '@svelte-atproto/oauth/server/stores/memory';
3
3
4
4
+
import { DEMO_CATEGORIES } from '$lib/config';
5
5
+
import {
6
6
+
getRoutingForUser,
7
7
+
listNotificationsForUser,
8
8
+
setCategoriesForUser
9
9
+
} from '$lib/server/relay';
4
10
import { listSubscribers } from '$lib/server/subscribers';
11
11
+
import type { NotificationView, RoutingView } from '$lib/types';
5
12
6
13
import type { PageServerLoad } from './$types';
7
14
8
15
// Tiny in-memory cache for handle lookups (fine for a demo).
9
16
const handleCache = memory();
10
17
18
18
+
/**
19
19
+
* On open, figure out whether this user has connected the app: `getRouting` is a
20
20
+
* self-scoped call that only succeeds once a grant exists, so a success means
21
21
+
* "connected" and hands us the routing + targets in one shot. We also declare the
22
22
+
* app's demo categories (once) so the per-category UI has something to show.
23
23
+
*/
11
24
export const load: PageServerLoad = async ({ locals }) => {
12
25
const handle = locals.did ? await loadHandle(locals.did, { cache: handleCache }) : null;
13
13
-
// Users the relay told us enabled us via atmo.pub's "enable apps" flow.
14
14
-
return { did: locals.did, handle, subscribers: listSubscribers() };
26
26
+
const base = {
27
27
+
did: locals.did,
28
28
+
handle,
29
29
+
subscribers: listSubscribers(),
30
30
+
categoriesError: null as string | null
31
31
+
};
32
32
+
const disconnected = {
33
33
+
...base,
34
34
+
connected: false,
35
35
+
routing: null as RoutingView | null,
36
36
+
notifications: [] as NotificationView[]
37
37
+
};
38
38
+
39
39
+
if (!locals.client) return disconnected;
40
40
+
41
41
+
try {
42
42
+
let routing = await getRoutingForUser(locals.client);
43
43
+
44
44
+
// Declare our categories the first time (idempotent; preserves any route the
45
45
+
// user already chose). Isolated so a failure here can't flip us to
46
46
+
// "not connected"; we surface the reason instead of hiding it.
47
47
+
let categoriesError: string | null = null;
48
48
+
const have = new Set(routing.categories.map((c) => c.id));
49
49
+
if (!DEMO_CATEGORIES.every((c) => have.has(c.id))) {
50
50
+
try {
51
51
+
await setCategoriesForUser(
52
52
+
locals.client,
53
53
+
DEMO_CATEGORIES.map((c) => ({ id: c.id, title: c.title, description: c.description }))
54
54
+
);
55
55
+
routing = await getRoutingForUser(locals.client);
56
56
+
} catch (err) {
57
57
+
categoriesError = err instanceof Error ? err.message : 'Could not declare categories';
58
58
+
console.warn('[example] could not declare categories', err);
59
59
+
}
60
60
+
}
61
61
+
62
62
+
const { notifications } = await listNotificationsForUser(locals.client, { limit: 10 });
63
63
+
return { ...base, connected: true, routing, notifications, categoriesError };
64
64
+
} catch {
65
65
+
// No grant yet (or a token couldn't be minted) → not connected.
66
66
+
return disconnected;
67
67
+
}
15
68
};
···
1
1
<script lang="ts">
2
2
+
import { invalidateAll } from '$app/navigation';
2
3
import { oauthLogin, oauthLogout } from '$lib/atproto/oauth.remote';
4
4
+
import RoutePicker from '$lib/components/RoutePicker.svelte';
3
5
import {
4
6
APP_DESCRIPTION,
5
7
APP_TITLE,
6
8
DASHBOARD_ORIGIN,
9
9
+
DEMO_CATEGORIES,
7
10
GITHUB_URL,
8
11
PROJECT_NAME,
9
12
SENDER_DID
10
13
} from '$lib/config';
11
14
import {
12
12
-
getRouting,
13
13
-
listNotifications,
15
15
+
connect,
14
16
markAllRead,
15
17
openInAtmo,
16
16
-
requestNotifications,
17
18
sendTest,
18
18
-
setRouting,
19
19
+
setAppRoute,
20
20
+
setCategoryRoute,
19
21
type SendResult
20
22
} from '$lib/relay.remote';
21
21
-
import type { AppRoute, NotificationView, RoutingView } from '$lib/types';
23
23
+
import { routeLabel } from '$lib/routes';
22
24
import type { PageServerData } from './$types';
23
25
24
26
let { data }: { data: PageServerData } = $props();
25
27
28
28
+
let busy = $state<Record<string, boolean>>({});
29
29
+
let errorMsg = $state('');
30
30
+
31
31
+
// Remote-command errors arrive as { body: { message } }, not Error instances.
32
32
+
function errMsg(err: unknown, fallback: string): string {
33
33
+
const e = err as { body?: { message?: string }; message?: string };
34
34
+
return e?.body?.message ?? e?.message ?? fallback;
35
35
+
}
36
36
+
37
37
+
/** Run a mutation, refresh server data, surface errors. */
38
38
+
async function run(key: string, fn: () => Promise<unknown>) {
39
39
+
busy[key] = true;
40
40
+
errorMsg = '';
41
41
+
try {
42
42
+
await fn();
43
43
+
await invalidateAll();
44
44
+
} catch (err) {
45
45
+
errorMsg = errMsg(err, 'Something went wrong');
46
46
+
} finally {
47
47
+
busy[key] = false;
48
48
+
}
49
49
+
}
50
50
+
26
51
// --- sign in / out -----------------------------------------------------
27
52
let handle = $state('');
28
53
let authBusy = $state(false);
···
38
63
const { url } = await oauthLogin({ handle: value, returnTo: '/' });
39
64
window.location.href = url;
40
65
} catch (err) {
41
41
-
// Remote-function errors arrive as { body: { message } }, not Error instances.
42
42
-
const e = err as { body?: { message?: string }; message?: string };
43
43
-
authError = e?.body?.message ?? e?.message ?? 'Sign-in failed';
66
66
+
authError = errMsg(err, 'Sign-in failed');
44
67
authBusy = false;
45
68
}
46
69
}
···
50
73
window.location.href = '/';
51
74
}
52
75
53
53
-
// --- step 1: request permission ---------------------------------------
54
54
-
let reqBusy = $state(false);
55
55
-
let reqResult = $state<{ id: string; status: string } | null>(null);
56
56
-
let reqError = $state('');
76
76
+
// --- connect (request permission) -------------------------------------
77
77
+
let pendingApproval = $state(false);
57
78
58
58
-
async function request() {
59
59
-
reqBusy = true;
60
60
-
reqError = '';
79
79
+
async function connectApp() {
80
80
+
busy['connect'] = true;
81
81
+
errorMsg = '';
61
82
try {
62
62
-
reqResult = await requestNotifications();
83
83
+
const { status } = await connect();
84
84
+
if (status === 'alreadyGranted') await invalidateAll();
85
85
+
else pendingApproval = true;
63
86
} catch (err) {
64
64
-
reqError = err instanceof Error ? err.message : 'Request failed';
87
87
+
errorMsg = errMsg(err, 'Could not start connecting');
65
88
} finally {
66
66
-
reqBusy = false;
89
89
+
busy['connect'] = false;
67
90
}
68
91
}
69
92
70
70
-
// --- cross-app login: jump into atmo.pub already signed in -------------
71
71
-
let openBusy = $state(false);
72
72
-
let openError = $state('');
73
73
-
74
74
-
async function openAtmo() {
75
75
-
openBusy = true;
76
76
-
openError = '';
77
77
-
// Open the tab synchronously on the click so the browser keeps the user
78
78
-
// gesture (no popup blocker), then point it at the link once minted.
93
93
+
// --- cross-app login: open atmo.pub already signed in ------------------
94
94
+
async function openAtmo(redirect: string, key: string) {
95
95
+
busy[key] = true;
96
96
+
errorMsg = '';
97
97
+
// Open synchronously on the click so the browser keeps the user gesture
98
98
+
// (no popup blocker), then point the tab at the minted link.
79
99
const w = window.open('about:blank');
80
100
try {
81
81
-
const { url } = await openInAtmo();
101
101
+
const { url } = await openInAtmo({ redirect });
82
102
if (w) w.location.href = url;
83
103
else window.location.href = url;
84
104
} catch (err) {
85
105
w?.close();
86
86
-
openError = err instanceof Error ? err.message : 'Failed to open atmo.pub';
106
106
+
errorMsg = errMsg(err, 'Failed to open atmo.pub');
87
107
} finally {
88
88
-
openBusy = false;
108
108
+
busy[key] = false;
89
109
}
90
110
}
91
111
92
92
-
// --- step 2: send a test ----------------------------------------------
112
112
+
// --- send a test ------------------------------------------------------
93
113
let title = $state('');
94
114
let body = $state('');
95
95
-
let sendBusy = $state(false);
115
115
+
let testCategory = $state('');
96
116
let sendResult = $state<SendResult | null>(null);
97
97
-
let sendError = $state('');
98
117
99
118
async function send(event: SubmitEvent) {
100
119
event.preventDefault();
101
101
-
sendBusy = true;
102
102
-
sendError = '';
120
120
+
busy['send'] = true;
121
121
+
errorMsg = '';
122
122
+
sendResult = null;
103
123
try {
104
104
-
sendResult = await sendTest({ title, body });
124
124
+
sendResult = await sendTest({ title, body, category: testCategory || undefined });
125
125
+
await invalidateAll();
105
126
} catch (err) {
106
106
-
sendError = err instanceof Error ? err.message : 'Send failed';
127
127
+
errorMsg = errMsg(err, 'Send failed');
107
128
} finally {
108
108
-
sendBusy = false;
129
129
+
busy['send'] = false;
109
130
}
110
131
}
111
132
112
112
-
// --- step 3: manage routing + inbox (dual-auth) -----------------------
113
113
-
let manageBusy = $state(false);
114
114
-
let manageError = $state('');
115
115
-
let routing = $state<RoutingView | null>(null);
116
116
-
let notifs = $state<NotificationView[]>([]);
117
117
-
let routeChoice = $state<AppRoute>('default');
118
118
-
let loaded = $state(false);
119
119
-
120
120
-
// Remote-command errors arrive as { body: { message } }, not Error instances.
121
121
-
function errMsg(err: unknown, fallback: string): string {
122
122
-
const e = err as { body?: { message?: string }; message?: string };
123
123
-
return e?.body?.message ?? e?.message ?? fallback;
124
124
-
}
125
125
-
126
126
-
async function loadSettings() {
127
127
-
manageBusy = true;
128
128
-
manageError = '';
129
129
-
try {
130
130
-
const [r, list] = await Promise.all([getRouting(), listNotifications()]);
131
131
-
routing = r;
132
132
-
routeChoice = r.route;
133
133
-
notifs = list.notifications;
134
134
-
loaded = true;
135
135
-
} catch (err) {
136
136
-
manageError = errMsg(err, 'Failed to load settings');
137
137
-
} finally {
138
138
-
manageBusy = false;
139
139
-
}
140
140
-
}
141
141
-
142
142
-
async function changeRoute() {
143
143
-
manageBusy = true;
144
144
-
manageError = '';
145
145
-
try {
146
146
-
await setRouting({ route: routeChoice });
147
147
-
routing = await getRouting();
148
148
-
} catch (err) {
149
149
-
manageError = errMsg(err, 'Failed to update routing');
150
150
-
} finally {
151
151
-
manageBusy = false;
152
152
-
}
153
153
-
}
154
154
-
155
155
-
async function markRead() {
156
156
-
manageBusy = true;
157
157
-
manageError = '';
158
158
-
try {
159
159
-
await markAllRead();
160
160
-
notifs = (await listNotifications()).notifications;
161
161
-
} catch (err) {
162
162
-
manageError = errMsg(err, 'Failed to mark read');
163
163
-
} finally {
164
164
-
manageBusy = false;
165
165
-
}
166
166
-
}
133
133
+
const noTargets = $derived(data.connected && (data.routing?.targets.length ?? 0) === 0);
134
134
+
// Distinct channel types the user actually has a target for (for the pickers).
135
135
+
const availableTypes = $derived([...new Set((data.routing?.targets ?? []).map((t) => t.type))]);
167
136
168
137
const card = 'rounded-card border border-line bg-surface p-5';
169
138
const btnPrimary =
170
139
'rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-fg transition-opacity hover:opacity-90 disabled:opacity-50';
140
140
+
const btnGhost =
141
141
+
'rounded-md border border-line px-3 py-1.5 text-sm font-medium text-fg hover:bg-surface-2 disabled:opacity-50';
171
142
const inputCls =
172
143
'w-full rounded-md border border-line bg-surface px-3 py-2 text-sm text-fg placeholder:text-muted-2 focus:border-accent';
173
173
-
const selectCls =
174
174
-
'w-full rounded-md border border-line bg-surface px-3 py-2 text-sm text-fg focus:border-accent';
175
144
const noticeOk = 'mt-3 rounded-md border border-line bg-accent-soft px-3 py-2 text-sm text-fg';
176
145
const noticeWarn = 'mt-3 rounded-md border border-line px-3 py-2 text-sm text-warn';
177
146
</script>
178
147
179
148
<svelte:head><title>{PROJECT_NAME}</title></svelte:head>
180
149
181
181
-
<main class="mx-auto w-full max-w-[640px] space-y-8 px-4 py-10">
150
150
+
<main class="mx-auto w-full max-w-[640px] space-y-6 px-4 py-10">
182
151
<header>
183
183
-
<h1 class="text-2xl font-semibold tracking-tight text-fg">{PROJECT_NAME}</h1>
152
152
+
<h1 class="text-2xl font-semibold tracking-tight text-fg">{APP_TITLE}</h1>
184
153
<p class="mt-3 max-w-prose text-sm leading-relaxed text-muted">
185
185
-
A demo app showing how any AT Protocol app can send notifications through
186
186
-
atmo.pub. The code is open source — it wires up both auth mechanisms (user-OAuth
187
187
-
for requesting permission, sender-DID for sending) in ~300 lines.
154
154
+
{APP_DESCRIPTION} A demo of how any AT Protocol app integrates with atmo.pub: connect,
155
155
+
let the user route notifications per category, and send.
188
156
</p>
189
157
190
158
{#if data.did}
···
193
161
Signed in as
194
162
<span class="font-medium text-fg">{data.handle ? `@${data.handle}` : data.did}</span>
195
163
</span>
196
196
-
<button
197
197
-
onclick={signOut}
198
198
-
class="rounded-md border border-line px-3 py-1.5 font-medium text-fg hover:bg-surface-2"
199
199
-
>
200
200
-
Sign out
201
201
-
</button>
164
164
+
<button onclick={signOut} class={btnGhost}>Sign out</button>
202
165
</div>
203
166
{:else}
204
167
<form onsubmit={signIn} class="mt-5 flex max-w-sm flex-col gap-2 sm:flex-row">
205
168
<input
206
169
bind:value={handle}
207
207
-
placeholder="jcsalterego.bsky.social"
170
170
+
placeholder="you.bsky.social"
208
171
autocomplete="off"
209
172
autocapitalize="none"
210
173
autocorrect="off"
···
222
185
{/if}
223
186
</header>
224
187
188
188
+
{#if errorMsg}
189
189
+
<p class="rounded-card border border-line bg-danger/10 px-3 py-2 text-sm text-danger" role="alert">
190
190
+
{errorMsg}
191
191
+
</p>
192
192
+
{/if}
193
193
+
225
194
{#if data.did}
195
195
+
<!-- Connection status -->
226
196
<section class={card}>
227
227
-
<h2 class="text-base font-semibold text-fg">Step 1 — Request permission</h2>
228
228
-
<p class="mt-2 text-sm leading-relaxed text-muted">
229
229
-
Calls <code class="font-mono text-fg">requestPermission</code> on the relay using a
230
230
-
service-auth token minted by your PDS (proves you authorized this app). The request names
231
231
-
this app's own DID (<code class="font-mono break-all text-fg">{SENDER_DID}</code>) as the
232
232
-
sender — “{APP_TITLE}”, {APP_DESCRIPTION}
233
233
-
</p>
234
234
-
<button onclick={request} disabled={reqBusy} class="{btnPrimary} mt-4">
235
235
-
{reqBusy ? 'Requesting…' : 'Request notifications'}
236
236
-
</button>
237
237
-
{#if reqResult}
238
238
-
{#if reqResult.status === 'alreadyGranted'}
239
239
-
<p class={noticeOk}>✓ You've already approved this app. Skip to Step 2.</p>
197
197
+
{#if data.connected}
198
198
+
<div class="flex flex-wrap items-center justify-between gap-3">
199
199
+
<div class="flex items-center gap-2 text-sm font-medium text-fg">
200
200
+
<span
201
201
+
class="flex size-5 items-center justify-center rounded-full bg-accent text-accent-fg"
202
202
+
>
203
203
+
<svg
204
204
+
width="12"
205
205
+
height="12"
206
206
+
viewBox="0 0 24 24"
207
207
+
fill="none"
208
208
+
stroke="currentColor"
209
209
+
stroke-width="3"
210
210
+
stroke-linecap="round"
211
211
+
stroke-linejoin="round"
212
212
+
aria-hidden="true"><path d="M5 12l5 5L20 7" /></svg
213
213
+
>
214
214
+
</span>
215
215
+
Connected to atmo.pub
216
216
+
</div>
217
217
+
<button
218
218
+
class={btnGhost}
219
219
+
disabled={busy['manage']}
220
220
+
onclick={() => openAtmo(`/apps/${SENDER_DID}`, 'manage')}
221
221
+
>
222
222
+
{busy['manage'] ? 'Opening…' : 'Manage in atmo.pub ↗'}
223
223
+
</button>
224
224
+
</div>
225
225
+
{:else}
226
226
+
<h2 class="text-base font-semibold text-fg">Connect to atmo.pub</h2>
227
227
+
<p class="mt-2 text-sm leading-relaxed text-muted">
228
228
+
Ask for permission to send <span class="font-medium text-fg">{APP_TITLE}</span>
229
229
+
notifications. You approve it once in atmo.pub, then choose how they reach you.
230
230
+
</p>
231
231
+
{#if !pendingApproval}
232
232
+
<button onclick={connectApp} disabled={busy['connect']} class="{btnPrimary} mt-4">
233
233
+
{busy['connect'] ? 'Requesting…' : 'Connect to atmo.pub'}
234
234
+
</button>
240
235
{:else}
241
241
-
<p class={noticeOk}>
242
242
-
✓ Request sent. Approve it at
243
243
-
<a
244
244
-
class="text-accent hover:underline"
245
245
-
href={DASHBOARD_ORIGIN}
246
246
-
target="_blank"
247
247
-
rel="noreferrer">atmo.pub</a
248
248
-
>, where you can also connect Telegram. Already approved? Skip to Step 2.
249
249
-
</p>
236
236
+
<p class={noticeOk}>✓ Request sent — approve it in atmo.pub, then come back.</p>
237
237
+
<div class="mt-3 flex flex-wrap gap-2">
238
238
+
<button
239
239
+
class={btnPrimary}
240
240
+
disabled={busy['approve']}
241
241
+
onclick={() => openAtmo('/apps', 'approve')}
242
242
+
>
243
243
+
{busy['approve'] ? 'Opening…' : 'Allow app in atmo.pub ↗'}
244
244
+
</button>
245
245
+
<button class={btnGhost} disabled={busy['recheck']} onclick={() => run('recheck', async () => {})}>
246
246
+
{busy['recheck'] ? 'Checking…' : "I've approved — refresh"}
247
247
+
</button>
248
248
+
</div>
250
249
{/if}
251
250
{/if}
252
252
-
{#if reqError}<p class="mt-3 text-sm text-danger" role="alert">{reqError}</p>{/if}
253
251
</section>
254
252
255
255
-
<section class={card}>
256
256
-
<h2 class="text-base font-semibold text-fg">Step 2 — Send a test</h2>
257
257
-
<p class="mt-2 text-sm leading-relaxed text-muted">
258
258
-
Calls <code class="font-mono text-fg">send</code> using a JWT signed by this app's own
259
259
-
private key — no user OAuth involved. The recipient (you) is in the body.
260
260
-
</p>
261
261
-
<form onsubmit={send} class="mt-4 space-y-2">
262
262
-
<input bind:value={title} placeholder="Hello from the example sender" class={inputCls} />
263
263
-
<input
264
264
-
bind:value={body}
265
265
-
placeholder="If you can read this, the integration works end to end."
266
266
-
class={inputCls}
267
267
-
/>
268
268
-
<button type="submit" disabled={sendBusy} class={btnPrimary}>
269
269
-
{sendBusy ? 'Sending…' : 'Send test notification'}
270
270
-
</button>
271
271
-
</form>
272
272
-
{#if sendResult}
273
273
-
{#if sendResult.ok && sendResult.delivered > 0}
274
274
-
<p class={noticeOk}>✓ Sent — check your Telegram.</p>
275
275
-
{:else if sendResult.ok}
276
276
-
<p class={noticeWarn}>
277
277
-
⚠ Sent, but you have no delivery channels linked. Connect Telegram at
278
278
-
<a class="underline" href={DASHBOARD_ORIGIN} target="_blank" rel="noreferrer"
279
279
-
>atmo.pub</a
280
280
-
>.
253
253
+
{#if data.connected && data.routing}
254
254
+
{#if noTargets}
255
255
+
<!-- No delivery channels: route changes wouldn't go anywhere yet. -->
256
256
+
<section class={card}>
257
257
+
<h2 class="text-base font-semibold text-fg">Set up where notifications go</h2>
258
258
+
<p class="mt-2 text-sm leading-relaxed text-muted">
259
259
+
You haven't connected any delivery channels yet (push, Telegram, email, …). Add one in
260
260
+
atmo.pub, then come back to route this app's notifications.
281
261
</p>
282
282
-
{:else}
283
283
-
<p class={noticeWarn}>
284
284
-
⚠ Approve this app first at
285
285
-
<a class="underline" href={DASHBOARD_ORIGIN} target="_blank" rel="noreferrer"
286
286
-
>atmo.pub</a
287
287
-
>, then try again.
262
262
+
<button
263
263
+
class="{btnPrimary} mt-4"
264
264
+
disabled={busy['settings']}
265
265
+
onclick={() => openAtmo('/settings?tab=channels', 'settings')}
266
266
+
>
267
267
+
{busy['settings'] ? 'Opening…' : 'Set up channels in atmo.pub ↗'}
268
268
+
</button>
269
269
+
</section>
270
270
+
{:else}
271
271
+
<!-- App-wide routing -->
272
272
+
<section class={card}>
273
273
+
<h2 class="text-base font-semibold text-fg">App defaults</h2>
274
274
+
<p class="mt-2 text-sm leading-relaxed text-muted">
275
275
+
Where everything from {APP_TITLE} goes.
276
276
+
<span class="font-medium text-fg">Account default</span> follows your atmo.pub default ({routeLabel(
277
277
+
data.routing.defaultRoute
278
278
+
)}).
288
279
</p>
289
289
-
{/if}
280
280
+
<div class="mt-3">
281
281
+
<RoutePicker
282
282
+
value={data.routing.route}
283
283
+
inherit={{ token: 'default', label: 'Account default' }}
284
284
+
available={availableTypes}
285
285
+
disabled={busy['__app']}
286
286
+
onchange={(route) => run('__app', () => setAppRoute({ route }))}
287
287
+
/>
288
288
+
</div>
289
289
+
</section>
290
290
+
291
291
+
<!-- Per-category routing -->
292
292
+
<section class={card}>
293
293
+
<h2 class="text-base font-semibold text-fg">Categories</h2>
294
294
+
<p class="mt-2 text-sm leading-relaxed text-muted">
295
295
+
This app declares the kinds of notifications it sends. Route each one separately, or let
296
296
+
it follow the app default.
297
297
+
</p>
298
298
+
{#if data.categoriesError}
299
299
+
<p class={noticeWarn}>
300
300
+
⚠ Couldn't declare categories: {data.categoriesError}. If this mentions a permission
301
301
+
or token, sign out and back in to refresh this app's access.
302
302
+
</p>
303
303
+
{/if}
304
304
+
<ul class="mt-4 space-y-5">
305
305
+
{#each data.routing.categories as c (c.id)}
306
306
+
<li class="border-t border-line pt-4 first:border-t-0 first:pt-0">
307
307
+
<div class="text-sm font-medium text-fg">{c.title ?? c.id}</div>
308
308
+
{#if c.description}<div class="text-xs text-muted">{c.description}</div>{/if}
309
309
+
<div class="mt-2">
310
310
+
<RoutePicker
311
311
+
value={c.route}
312
312
+
inherit={{ token: 'app', label: 'App default' }}
313
313
+
available={availableTypes}
314
314
+
disabled={busy[c.id]}
315
315
+
onchange={(route) => run(c.id, () => setCategoryRoute({ id: c.id, route }))}
316
316
+
/>
317
317
+
</div>
318
318
+
</li>
319
319
+
{:else}
320
320
+
<li class="text-sm text-muted-2">No categories declared yet.</li>
321
321
+
{/each}
322
322
+
</ul>
323
323
+
</section>
290
324
{/if}
291
291
-
{#if sendError}<p class="mt-3 text-sm text-danger" role="alert">{sendError}</p>{/if}
292
292
-
</section>
293
325
294
294
-
<section class={card}>
295
295
-
<h2 class="text-base font-semibold text-fg">Step 3 — Manage your settings (dual-auth)</h2>
296
296
-
<p class="mt-2 text-sm leading-relaxed text-muted">
297
297
-
Read and change how <em>this app's</em> notifications reach you — from inside this app. Each
298
298
-
call carries <strong class="text-fg">two</strong> service-auth tokens: one signed by this app
299
299
-
(proves which app), and a fresh one minted by your PDS (proves you consented). The relay
300
300
-
scopes every change to you + this app — it can't touch your account default or
301
301
-
other apps. Requires an approved grant (Step 1).
302
302
-
</p>
303
303
-
<button onclick={loadSettings} disabled={manageBusy} class="{btnPrimary} mt-4">
304
304
-
{manageBusy ? 'Working…' : loaded ? 'Refresh' : 'Load my settings'}
305
305
-
</button>
326
326
+
<!-- Debug: send a test notification -->
327
327
+
<section class={card}>
328
328
+
<h2 class="text-base font-semibold text-fg">Send a test notification</h2>
329
329
+
<p class="mt-2 text-sm leading-relaxed text-muted">
330
330
+
Debug tool — calls <code class="font-mono text-fg">send</code> with this app's own key. Pick
331
331
+
a category to test per-category routing.
332
332
+
</p>
333
333
+
<form onsubmit={send} class="mt-4 space-y-2">
334
334
+
<input bind:value={title} placeholder="Title" class={inputCls} />
335
335
+
<input bind:value={body} placeholder="Body" class={inputCls} />
336
336
+
<select bind:value={testCategory} class={inputCls}>
337
337
+
<option value="">General (no category)</option>
338
338
+
{#each DEMO_CATEGORIES as c (c.id)}
339
339
+
<option value={c.id}>{c.title}</option>
340
340
+
{/each}
341
341
+
</select>
342
342
+
<button type="submit" disabled={busy['send']} class={btnPrimary}>
343
343
+
{busy['send'] ? 'Sending…' : 'Send test notification'}
344
344
+
</button>
345
345
+
</form>
346
346
+
{#if sendResult}
347
347
+
{#if sendResult.ok && sendResult.delivered > 0}
348
348
+
<p class={noticeOk}>✓ Sent — delivered to {sendResult.delivered} channel{sendResult.delivered === 1 ? '' : 's'}.</p>
349
349
+
{:else if sendResult.ok}
350
350
+
<p class={noticeWarn}>
351
351
+
⚠ Recorded in your inbox, but it fired no alert channels (routed to inbox/off, or no
352
352
+
matching targets).
353
353
+
</p>
354
354
+
{:else}
355
355
+
<p class={noticeWarn}>⚠ Not approved yet — connect the app first.</p>
356
356
+
{/if}
357
357
+
{/if}
358
358
+
</section>
306
359
307
307
-
{#if routing}
308
308
-
<div class="mt-5 space-y-5">
309
309
-
<label class="block text-sm">
310
310
-
<span class="font-medium text-fg">Route all of this app's notifications to</span>
311
311
-
<select
312
312
-
bind:value={routeChoice}
313
313
-
onchange={changeRoute}
314
314
-
disabled={manageBusy}
315
315
-
class="{selectCls} mt-1"
316
316
-
>
317
317
-
<option value="default">Account default ({routing.defaultRoute})</option>
318
318
-
<option value="push">Push</option>
319
319
-
<option value="telegram">Telegram</option>
320
320
-
<option value="push+telegram">Push + Telegram</option>
321
321
-
<option value="off">Off</option>
322
322
-
</select>
323
323
-
</label>
324
324
-
325
325
-
<div>
326
326
-
<div class="flex items-center justify-between">
327
327
-
<h3 class="text-sm font-medium text-fg">Notifications we've sent you</h3>
328
328
-
<button
329
329
-
onclick={markRead}
330
330
-
disabled={manageBusy || notifs.length === 0}
331
331
-
class="rounded-md border border-line px-3 py-1.5 text-xs font-medium text-fg hover:bg-surface-2 disabled:opacity-50"
332
332
-
>
333
333
-
Mark all read
334
334
-
</button>
335
335
-
</div>
336
336
-
{#if notifs.length === 0}
337
337
-
<p class="mt-2 text-sm text-muted">None yet — send one in Step 2, then Refresh.</p>
338
338
-
{:else}
339
339
-
<ul class="mt-2 divide-y divide-line text-sm">
340
340
-
{#each notifs as n (n.id)}
341
341
-
<li class="flex items-center justify-between gap-3 py-2">
342
342
-
<span class="min-w-0 truncate text-fg">{n.read ? '' : '• '}{n.title}</span>
343
343
-
<span class="shrink-0 text-xs text-muted-2">
344
344
-
delivered to {n.delivered ?? 0} · {n.read ? 'read' : 'unread'}
345
345
-
</span>
346
346
-
</li>
347
347
-
{/each}
348
348
-
</ul>
349
349
-
{/if}
350
350
-
</div>
360
360
+
<!-- Debug: recent notifications -->
361
361
+
<section class={card}>
362
362
+
<div class="flex items-center justify-between">
363
363
+
<h2 class="text-base font-semibold text-fg">Recent notifications</h2>
364
364
+
<button
365
365
+
onclick={() => run('__read', () => markAllRead())}
366
366
+
disabled={busy['__read'] || data.notifications.length === 0}
367
367
+
class={btnGhost}
368
368
+
>
369
369
+
Mark all read
370
370
+
</button>
351
371
</div>
352
352
-
{/if}
353
353
-
{#if manageError}<p class="mt-3 text-sm text-danger" role="alert">{manageError}</p>{/if}
354
354
-
</section>
372
372
+
<p class="mt-1 text-xs text-muted-2">The 10 most recent we've sent you (debug view).</p>
373
373
+
{#if data.notifications.length === 0}
374
374
+
<p class="mt-3 text-sm text-muted">None yet — send one above.</p>
375
375
+
{:else}
376
376
+
<ul class="mt-3 divide-y divide-line text-sm">
377
377
+
{#each data.notifications as n (n.id)}
378
378
+
<li class="flex items-start justify-between gap-3 py-2">
379
379
+
<div class="min-w-0">
380
380
+
<div class="truncate text-fg">
381
381
+
{#if !n.read}<span class="text-accent">•</span> {/if}{n.title}
382
382
+
</div>
383
383
+
<div class="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-muted-2">
384
384
+
<span>{n.createdAt.slice(0, 10)}</span>
385
385
+
{#if n.category}
386
386
+
<span class="rounded bg-surface-2 px-1.5 py-0.5 font-mono">{n.category}</span>
387
387
+
{/if}
388
388
+
</div>
389
389
+
</div>
390
390
+
<span class="shrink-0 text-xs text-muted-2">
391
391
+
→ {n.delivered ?? 0} · {n.read ? 'read' : 'unread'}
392
392
+
</span>
393
393
+
</li>
394
394
+
{/each}
395
395
+
</ul>
396
396
+
{/if}
397
397
+
</section>
398
398
+
{/if}
355
399
400
400
+
<!-- Subscribers from atmo.pub's "enable apps" flow (debug) -->
356
401
<section class={card}>
357
357
-
<h2 class="text-base font-semibold text-fg">Cross-app login</h2>
402
402
+
<h2 class="text-base font-semibold text-fg">Subscribers from atmo.pub</h2>
358
403
<p class="mt-2 text-sm leading-relaxed text-muted">
359
359
-
Mints a one-time <code class="font-mono text-fg">pub.atmo.auth</code> token on your PDS and
360
360
-
opens
361
361
-
<a class="text-accent hover:underline" href={DASHBOARD_ORIGIN} target="_blank" rel="noreferrer"
362
362
-
>atmo.pub</a
363
363
-
>
364
364
-
<em>already signed in</em> — deep-linked to this app's settings. No login form.
404
404
+
Users who enabled this app from inside atmo.pub. The relay tells us via a signed
405
405
+
<code class="font-mono text-fg">subscriberChanged</code> callback. Demo store is in-memory.
365
406
</p>
366
366
-
<button onclick={openAtmo} disabled={openBusy} class="{btnPrimary} mt-4">
367
367
-
{openBusy ? 'Opening…' : 'Open in atmo.pub'}
368
368
-
</button>
369
369
-
{#if openError}<p class="mt-3 text-sm text-danger" role="alert">{openError}</p>{/if}
407
407
+
{#if data.subscribers.length === 0}
408
408
+
<p class="mt-3 text-sm text-muted-2">None yet.</p>
409
409
+
{:else}
410
410
+
<ul class="mt-3 space-y-1">
411
411
+
{#each data.subscribers as sub (sub)}
412
412
+
<li class="font-mono text-xs break-all text-fg">{sub}</li>
413
413
+
{/each}
414
414
+
</ul>
415
415
+
{/if}
370
416
</section>
371
417
{/if}
372
418
373
373
-
<section class={card}>
374
374
-
<h2 class="text-base font-semibold text-fg">Subscribers from atmo.pub</h2>
375
375
-
<p class="mt-2 text-sm leading-relaxed text-muted">
376
376
-
Users who enabled this app from inside atmo.pub. The relay tells us via a signed
377
377
-
<code class="font-mono text-fg">subscriberChanged</code> callback (we verify it's really the
378
378
-
relay), and a real app would start sending them notifications. Demo store is in-memory.
379
379
-
</p>
380
380
-
{#if data.subscribers.length === 0}
381
381
-
<p class="mt-3 text-sm text-muted-2">None yet — enable this app from atmo.pub's Apps screen.</p>
382
382
-
{:else}
383
383
-
<ul class="mt-3 space-y-1">
384
384
-
{#each data.subscribers as sub (sub)}
385
385
-
<li class="font-mono text-xs break-all text-fg">{sub}</li>
386
386
-
{/each}
387
387
-
</ul>
388
388
-
{/if}
389
389
-
</section>
390
390
-
391
419
<footer class="border-t border-line pt-6 text-xs text-muted-2">
392
420
Source:
393
421
<a class="hover:text-fg" href={GITHUB_URL} target="_blank" rel="noreferrer">{GITHUB_URL}</a>.
394
422
Powered by
395
395
-
<a class="hover:text-fg" href={DASHBOARD_ORIGIN} target="_blank" rel="noreferrer"
396
396
-
>atmo.pub</a
397
397
-
>.
423
423
+
<a class="hover:text-fg" href={DASHBOARD_ORIGIN} target="_blank" rel="noreferrer">atmo.pub</a>.
398
424
</footer>
399
425
</main>
···
1
1
+
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-label="Example Sender">
2
2
+
<rect width="64" height="64" rx="14" fill="#3a6ff0" />
3
3
+
<g transform="translate(8 8) scale(2)" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
4
4
+
<path d="m22 2-7 20-4-9-9-4Z" />
5
5
+
<path d="M22 2 11 13" />
6
6
+
</g>
7
7
+
</svg>
···
4
4
5
5
const features = [
6
6
{ t: 'Permission first', d: 'Apps request access; nothing is sent until you approve.' },
7
7
-
{ t: 'Delivered to Telegram', d: 'Link your Telegram once and get notifications in chat.' },
7
7
+
{
8
8
+
t: 'Your channels',
9
9
+
d: 'Route alerts to web push, Telegram, email, Bluesky DM, or a webhook — per app and per category.'
10
10
+
},
8
11
{ t: 'Revoke anytime', d: 'Mute or revoke any app from your dashboard.' }
9
12
];
10
13
</script>
···
22
25
{PROJECT_NAME}
23
26
</h1>
24
27
<p class="mt-4 max-w-prose text-base leading-relaxed text-muted">
25
25
-
Lets any AT Protocol app send you notifications via Telegram (and soon more). You stay in
26
26
-
control: every app must ask permission, and you can revoke any time.
28
28
+
Lets any AT Protocol app send you notifications — delivered via web push, Telegram, email,
29
29
+
Bluesky DM, or a webhook, with everything kept in an inbox. You stay in control: every app must
30
30
+
ask permission, and you can revoke any time.
27
31
</p>
28
32
29
33
<div class="mt-8 flex flex-wrap gap-3">
···
45
45
<header>
46
46
<h1 class="text-2xl font-semibold tracking-tight text-fg">Developer docs</h1>
47
47
<p class="mt-2 max-w-prose text-sm leading-relaxed text-muted">
48
48
-
Any atproto app can ask users to receive notifications via {PROJECT_NAME}. Users approve in
49
49
-
the dashboard; the relay delivers via Telegram.
48
48
+
Any atproto app can ask users to receive notifications via {PROJECT_NAME}. Users approve in the
49
49
+
dashboard and choose where alerts go — web push, Telegram, email, Bluesky DM, or a webhook —
50
50
+
while everything also lands in their inbox.
50
51
</p>
51
52
<p class="mt-3 max-w-prose rounded-card border border-line bg-surface p-3 text-sm text-muted">
52
53
<strong class="text-fg">Two endpoints, two auth mechanisms.</strong>
···
113
114
<p class="max-w-prose text-sm leading-relaxed text-muted">
114
115
Returns <code class="font-mono text-fg">{ id, status }</code>
115
116
(<code class="font-mono text-fg">pending</code> or
116
116
-
<code class="font-mono text-fg">alreadyGranted</code>). The user approves in their dashboard or
117
117
-
via Telegram. <code class="font-mono text-fg">title</code> ≤ 50 chars,
117
117
+
<code class="font-mono text-fg">alreadyGranted</code>). The user approves in their dashboard.
118
118
+
<code class="font-mono text-fg">title</code> ≤ 50 chars,
118
119
<code class="font-mono text-fg">description</code> ≤ 200 chars, optional
119
120
<code class="font-mono text-fg">iconUrl</code>.
120
121
</p>
···
126
127
Once granted, sign with your app's own key (no user involved) and send. Field limits:
127
128
<code class="font-mono text-fg">title</code> ≤ 100, <code class="font-mono text-fg">body</code>
128
129
≤ 500, optional <code class="font-mono text-fg">uri</code> and
129
129
-
<code class="font-mono text-fg">threadKey</code>.
130
130
+
<code class="font-mono text-fg">threadKey</code>. Optional
131
131
+
<code class="font-mono text-fg">category</code> (≤ 64) tags the notification so the user can
132
132
+
route that category separately; <code class="font-mono text-fg">categoryDescription</code> labels
133
133
+
it in their settings. The response is
134
134
+
<code class="font-mono text-fg">{ id, delivered }</code> —
135
135
+
<code class="font-mono text-fg">delivered: 0</code> is normal (saved to the inbox, but the user
136
136
+
may have muted you, routed you to “off”, or connected no channels).
130
137
</p>
131
138
{@render codeblock(data.code.sendJwt)}
132
139
<p class="max-w-prose text-sm leading-relaxed text-muted">
···
178
185
</section>
179
186
180
187
<section class="space-y-3">
181
181
-
<h2 class="text-lg font-semibold text-fg">6. Rate limits</h2>
188
188
+
<h2 class="text-lg font-semibold text-fg">6. Let users tune your app (optional)</h2>
189
189
+
<p class="max-w-prose text-sm leading-relaxed text-muted">
190
190
+
If the user grants your app “manage its own settings”, it can read and adjust how
191
191
+
<em>its own</em> notifications reach them — over <strong class="text-fg">dual-auth</strong> XRPC
192
192
+
(your app token in the header + a fresh user token in the body
193
193
+
<code class="font-mono text-fg">userToken</code>, both scoped to the method). These only ever
194
194
+
touch your app's slice for that user.
195
195
+
</p>
196
196
+
<ul class="ml-5 list-disc space-y-1 text-sm text-muted">
197
197
+
<li>
198
198
+
<code class="font-mono text-fg">getRouting</code> /
199
199
+
<code class="font-mono text-fg">setRouting</code> — read the user's routable targets (to render
200
200
+
a picker) and set which channels your notifications use, app-wide and per-category.
201
201
+
</li>
202
202
+
<li>
203
203
+
<code class="font-mono text-fg">getCategories</code> /
204
204
+
<code class="font-mono text-fg">setCategories</code> /
205
205
+
<code class="font-mono text-fg">addCategory</code> /
206
206
+
<code class="font-mono text-fg">removeCategory</code> — declare your categories up front (e.g.
207
207
+
one per webhook the user configures), each with a display title, so they show in routing
208
208
+
before the first send.
209
209
+
</li>
210
210
+
<li>
211
211
+
<code class="font-mono text-fg">listNotifications</code> /
212
212
+
<code class="font-mono text-fg">markRead</code>,
213
213
+
<code class="font-mono text-fg">muteSelf</code>,
214
214
+
<code class="font-mono text-fg">revokeSelf</code> — read/ack your inbox slice; mute or remove
215
215
+
yourself.
216
216
+
</li>
217
217
+
</ul>
218
218
+
<p class="max-w-prose text-sm leading-relaxed text-muted">
219
219
+
A <strong class="text-fg">route</strong> is a <code class="font-mono text-fg">+</code>-joined set
220
220
+
of channel tokens (<code class="font-mono text-fg">push</code>,
221
221
+
<code class="font-mono text-fg">telegram</code>, <code class="font-mono text-fg">email</code>,
222
222
+
<code class="font-mono text-fg">dm</code>, <code class="font-mono text-fg">webhook</code>; or
223
223
+
<code class="font-mono text-fg">channel:<id></code> for one specific device/chat), plus
224
224
+
<code class="font-mono text-fg">off</code>, <code class="font-mono text-fg">inbox</code> (inbox
225
225
+
only), and the inherit sentinels <code class="font-mono text-fg">default</code> /
226
226
+
<code class="font-mono text-fg">app</code>. Full method reference: the
227
227
+
<a class="text-accent hover:underline" href="/llms.txt">LLM integration guide</a> or each lexicon
228
228
+
at <code class="font-mono text-fg">/lexicons/<nsid></code>.
229
229
+
</p>
230
230
+
</section>
231
231
+
232
232
+
<section class="space-y-3">
233
233
+
<h2 class="text-lg font-semibold text-fg">7. Rate limits</h2>
182
234
<ul class="ml-5 list-disc space-y-1 text-sm text-muted">
183
235
<li>At most <strong class="text-fg">1 outstanding pending request</strong> per (sender, recipient).</li>
184
236
<li><code class="font-mono text-fg">requestPermission</code>: <strong class="text-fg">50 / hour</strong> per recipient and <strong class="text-fg">100 / hour</strong> per sender.</li>
···
187
239
</section>
188
240
189
241
<section class="space-y-3">
190
190
-
<h2 class="text-lg font-semibold text-fg">7. Error handling</h2>
242
242
+
<h2 class="text-lg font-semibold text-fg">8. Error handling</h2>
191
243
<p class="max-w-prose text-sm leading-relaxed text-muted">Common XRPC errors:</p>
192
244
<ul class="ml-5 list-disc space-y-1 text-sm text-muted">
193
245
<li><code class="font-mono text-fg">AuthenticationRequired</code> — missing/invalid JWT.</li>
···
3
3
This is a complete, self-contained guide for adding notifications to an AT Protocol
4
4
app via atmo.pub. It is written to be implemented from directly. atmo.pub is a
5
5
notification *relay*: an app asks a user for permission, then sends notifications;
6
6
-
the relay fans them out to the channels the user chose (web push, Telegram, …) and
7
7
-
records them in the user's inbox.
6
6
+
the relay fans them out to the channels the user chose (web push, Telegram, email,
7
7
+
Bluesky DM, webhook) and records them in the user's inbox.
8
8
9
9
You integrate as a **sender** (the common case). Optionally you can also let users
10
10
manage your app's settings from inside your app, and receive opt-in/opt-out
···
147
147
must have granted your app "manage its own settings" in the dashboard; reads are
148
148
open). On a relay you run yourself, you can allow your own DID by default.
149
149
150
150
-
A **route** is a `+`-joined set of channel tokens, e.g. `"push+email"`, `"off"` for
151
151
-
none, plus the inherit sentinels `"default"` (app-wide → account default) and `"app"`
152
152
-
(category → app-wide). A token is either a bare channel — `push`/`telegram`/`email`,
153
153
-
meaning all of that channel's instances — or a channel narrowed to one delivery
154
154
-
instance as `channel:<id>` (e.g. `"push:a1b2c3"` = one specific device). Users pick
155
155
-
instance ids in the dashboard; an unknown id simply delivers to nothing.
150
150
+
A **route** is a `+`-joined set of channel tokens, e.g. `"push+email"`. Special
151
151
+
values: `"off"` (drop — not even saved to the inbox), `"inbox"` (saved, no alerts),
152
152
+
and the inherit sentinels `"default"` (app-wide → account default) and `"app"`
153
153
+
(category → app-wide). A token is either a bare channel —
154
154
+
`push`/`telegram`/`email`/`dm`/`webhook`, meaning all of that channel's instances —
155
155
+
or a channel narrowed to one delivery instance as `channel:<id>` (e.g.
156
156
+
`"push:a1b2c3"` = one specific device). Users pick instance ids in the dashboard;
157
157
+
an unknown id simply delivers to nothing.
156
158
- `setRouting` — body `{ userToken, route?, categories? }`
157
157
-
- `route`: app-wide route — a `+`-joined token set, `"off"`, or `"default"`. Omit to leave unchanged.
158
158
-
- `categories`: `[{ "id": "<category>", "route": "<token set>" | "off" | "app" }]`
159
159
+
- `route`: app-wide route — a `+`-joined token set, `"off"`, `"inbox"`, or `"default"`. Omit to leave unchanged.
160
160
+
- `categories`: `[{ "id": "<category>", "route": "<token set>" | "off" | "inbox" | "app" }]`
159
161
- → `{ "ok": true }`
160
162
- `getRouting` — body `{ userToken }` →
161
163
`{ "route": "<app-wide>", "defaultRoute": "<account default>",
162
162
-
"categories": [{ "id", "description?", "route" }],
164
164
+
"categories": [{ "id", "title?", "description?", "route" }],
163
165
"targets": [{ "type", "id", "label" }] }`
164
166
(each route is a `+`-joined token set / `off` / sentinel as above. `targets` is the
165
167
user's deliverable instances — render a picker and use a target's `id` in a
166
168
`channel:<id>` token; labels are privacy-safe, never a raw email/handle.)
169
169
+
170
170
+
**Categories** are per (user, app) — never shared across users. They're
171
171
+
auto-discovered from `send` (the `category` field), or you can declare them up
172
172
+
front (e.g. one per webhook the user configures) with a display `title`:
173
173
+
- `setCategories` — body `{ userToken, categories: [{ id, title?, description?, route? }] }`.
174
174
+
Full sync: replaces your category set for this user (omitted ones are removed,
175
175
+
along with their routing). `route` is an optional initial route (token set / `off` /
176
176
+
`inbox` / `app`). → `{ "ok": true }`
177
177
+
- `addCategory` — body `{ userToken, id, title?, description?, route? }` (add/update one) → `{ "ok": true }`
178
178
+
- `removeCategory` — body `{ userToken, id }` (drops it + its routing) → `{ "removed": bool }`
179
179
+
- `getCategories` — body `{ userToken }` → `{ "categories": [{ id, title?, description?, route }] }`
167
180
- `listNotifications` — body `{ userToken, limit?(1–100, default 50), cursor? }` →
168
181
`{ "notifications": [{ "id","title","body","uri?","category?",
169
182
"createdAt"(ISO datetime),"read"(bool),"delivered?"(int) }], "cursor?" }`
···
292
305
rpc?lxm=pub.atmo.notify.requestPermission&aud=*
293
306
rpc?lxm=pub.atmo.notify.setRouting&aud=*
294
307
rpc?lxm=pub.atmo.notify.getRouting&aud=*
308
308
+
rpc?lxm=pub.atmo.notify.setCategories&aud=*
309
309
+
rpc?lxm=pub.atmo.notify.addCategory&aud=*
310
310
+
rpc?lxm=pub.atmo.notify.removeCategory&aud=*
311
311
+
rpc?lxm=pub.atmo.notify.getCategories&aud=*
295
312
rpc?lxm=pub.atmo.notify.listNotifications&aud=*
296
313
rpc?lxm=pub.atmo.notify.markRead&aud=*
297
314
rpc?lxm=pub.atmo.notify.revokeSelf&aud=*
···
4
4
CREATE TABLE users (
5
5
did TEXT PRIMARY KEY,
6
6
created_at INTEGER NOT NULL,
7
7
-
notify_pending_via_telegram INTEGER NOT NULL DEFAULT 0,
8
7
-- Account-default alert route: a '+'-joined token set (see lexicons/rpc.ts).
9
9
-
default_route TEXT NOT NULL DEFAULT 'push',
8
8
+
-- New accounts start at 'inbox' (recorded, no alerts) — it always "works" even
9
9
+
-- before any delivery channel is connected; the web app nudges the user to pick
10
10
+
-- a real route once they add their first channel.
11
11
+
default_route TEXT NOT NULL DEFAULT 'inbox',
10
12
-- Incoming-request policy: 'all' | 'trusted' (TRUSTED_SENDERS) | 'none'.
11
11
-
auto_allow TEXT NOT NULL DEFAULT 'trusted'
13
13
+
auto_allow TEXT NOT NULL DEFAULT 'trusted',
14
14
+
-- Where permission-request alerts go: a concrete route (token set) or 'off'.
15
15
+
pending_route TEXT NOT NULL DEFAULT 'off'
12
16
);
13
17
14
18
-- Unified delivery targets. Every place a notification can be delivered — a web
···
123
127
);
124
128
CREATE INDEX notifications_by_recipient ON notifications (recipient_did, created_at DESC);
125
129
126
126
-
-- Categories discovered from `send` (per recipient+sender), so the routing UI can
127
127
-
-- list them with a description.
130
130
+
-- Categories per (recipient, sender): auto-discovered from `send`, or declared up
131
131
+
-- front by an app (setCategories/addCategory). `category` is the routing key; the
132
132
+
-- optional `title` is a human display name (e.g. a webhook's name). Per-user by
133
133
+
-- construction (recipient_did in the PK) — never shared across users.
128
134
CREATE TABLE app_categories (
129
135
recipient_did TEXT NOT NULL,
130
136
sender_did TEXT NOT NULL,
131
137
category TEXT NOT NULL,
138
138
+
title TEXT,
132
139
description TEXT,
133
140
last_seen INTEGER NOT NULL,
134
141
PRIMARY KEY (recipient_did, sender_did, category)
···
7
7
export interface UserRow {
8
8
did: Did;
9
9
created_at: number;
10
10
-
notify_pending_via_telegram: number;
11
10
default_route: string;
12
11
auto_allow: string;
12
12
+
/** Where permission-request alerts go: a concrete route (token set) or 'off'. */
13
13
+
pending_route: string;
13
14
}
14
15
15
16
export interface LinkTokenRow {
···
33
34
id: string;
34
35
recipient_did: Did;
35
36
sender_did: Did;
36
36
-
reason: string | null; // legacy (migration 0001); unused
37
37
title: string | null;
38
38
description: string | null;
39
39
icon_url: string | null;
···
78
78
return db.prepare('SELECT * FROM users WHERE did = ?').bind(did).first<UserRow>();
79
79
}
80
80
81
81
-
/** Insert a users row if absent. No-op when it already exists. */
81
81
+
/**
82
82
+
* Insert a users row if absent. No-op when it already exists. `default_route` is
83
83
+
* set explicitly to 'inbox' (rather than leaning on the column default) so new
84
84
+
* accounts start at inbox-only even on a DB whose column default predates that.
85
85
+
*/
82
86
export async function ensureUser(db: D1Database, did: Did, createdAt: number): Promise<void> {
83
87
await db
84
84
-
.prepare('INSERT OR IGNORE INTO users (did, created_at, notify_pending_via_telegram) VALUES (?, ?, 0)')
88
88
+
.prepare("INSERT OR IGNORE INTO users (did, created_at, default_route) VALUES (?, ?, 'inbox')")
85
89
.bind(did, createdAt)
86
90
.run();
87
91
}
88
92
89
89
-
export async function setNotifyPending(db: D1Database, did: Did, value: boolean): Promise<void> {
90
90
-
await db
91
91
-
.prepare('UPDATE users SET notify_pending_via_telegram = ? WHERE did = ?')
92
92
-
.bind(toInt(value), did)
93
93
-
.run();
93
93
+
/** Set where permission-request alerts go (a concrete route, or 'off'). */
94
94
+
export async function setPendingRoute(db: D1Database, did: Did, route: string): Promise<void> {
95
95
+
await db.prepare('UPDATE users SET pending_route = ? WHERE did = ?').bind(route, did).run();
94
96
}
95
97
96
98
// ---------------------------------------------------------------------------
···
506
508
}
507
509
508
510
/**
509
509
-
* Insert or refresh a grant. Re-granting resets `muted` to 0. Metadata is copied
510
510
-
* from the pending request; `COALESCE` keeps any previously-stored metadata when
511
511
-
* a re-grant provides none (e.g. a manual grant with no requestId).
511
511
+
* Insert or refresh a grant. New grants default to `manage = 'self'` (an app may
512
512
+
* manage its own routing/inbox) — set explicitly here rather than relying on the
513
513
+
* column default, so the behaviour is consistent even on a DB whose `grants`
514
514
+
* table predates that default. Re-granting resets `muted` to 0 but KEEPS the
515
515
+
* user's chosen `manage` capability. Metadata is copied from the pending request;
516
516
+
* `COALESCE` keeps previously-stored metadata when a re-grant provides none.
512
517
*/
513
518
export async function upsertGrant(db: D1Database, input: UpsertGrantInput): Promise<void> {
514
519
await db
515
520
.prepare(
516
516
-
`INSERT INTO grants (recipient_did, sender_did, granted_at, muted, title, description, icon_url)
517
517
-
VALUES (?, ?, ?, 0, ?, ?, ?)
521
521
+
`INSERT INTO grants (recipient_did, sender_did, granted_at, muted, title, description, icon_url, manage)
522
522
+
VALUES (?, ?, ?, 0, ?, ?, ?, 'self')
518
523
ON CONFLICT(recipient_did, sender_did) DO UPDATE SET
519
524
granted_at = excluded.granted_at,
520
525
muted = 0,
···
731
736
return result.meta.changes ?? 0;
732
737
}
733
738
739
739
+
/** Retention backstop: drop inbox entries older than `beforeMs` (read or not). */
740
740
+
export async function deleteOldNotifications(db: D1Database, beforeMs: number): Promise<number> {
741
741
+
const result = await db
742
742
+
.prepare('DELETE FROM notifications WHERE created_at < ?')
743
743
+
.bind(beforeMs)
744
744
+
.run();
745
745
+
return result.meta.changes ?? 0;
746
746
+
}
747
747
+
734
748
// --- App-scoped inbox access (one sender's notifications for one recipient) ---
735
749
// Used by the federated, dual-authed `listNotifications`/`markRead` so an app
736
750
// can see and acknowledge only the notifications *it* sent.
···
804
818
recipient_did: Did;
805
819
sender_did: Did;
806
820
category: string;
821
821
+
title: string | null;
807
822
description: string | null;
808
823
last_seen: number;
809
824
}
···
816
831
lastSeen: number;
817
832
}
818
833
819
819
-
/** Record a category seen from a sender (keeps the latest description). */
834
834
+
/** Record a category seen from a sender (keeps the latest description + any title). */
820
835
export async function upsertAppCategory(db: D1Database, input: UpsertAppCategoryInput): Promise<void> {
821
836
await db
822
837
.prepare(
···
830
845
.run();
831
846
}
832
847
848
848
+
export interface DeclareAppCategoryInput {
849
849
+
recipientDid: Did;
850
850
+
senderDid: Did;
851
851
+
category: string;
852
852
+
title: string | null;
853
853
+
description: string | null;
854
854
+
lastSeen: number;
855
855
+
}
856
856
+
857
857
+
/** App-declared category (setCategories/addCategory): upsert key + title +
858
858
+
* description. Null title/description preserve any existing value. */
859
859
+
export async function declareAppCategory(
860
860
+
db: D1Database,
861
861
+
input: DeclareAppCategoryInput,
862
862
+
): Promise<void> {
863
863
+
await db
864
864
+
.prepare(
865
865
+
`INSERT INTO app_categories (recipient_did, sender_did, category, title, description, last_seen)
866
866
+
VALUES (?, ?, ?, ?, ?, ?)
867
867
+
ON CONFLICT(recipient_did, sender_did, category) DO UPDATE SET
868
868
+
title = COALESCE(excluded.title, app_categories.title),
869
869
+
description = COALESCE(excluded.description, app_categories.description),
870
870
+
last_seen = excluded.last_seen`,
871
871
+
)
872
872
+
.bind(
873
873
+
input.recipientDid,
874
874
+
input.senderDid,
875
875
+
input.category,
876
876
+
input.title,
877
877
+
input.description,
878
878
+
input.lastSeen,
879
879
+
)
880
880
+
.run();
881
881
+
}
882
882
+
883
883
+
/** Remove one app-declared category (its routing row is cleaned up separately). */
884
884
+
export async function deleteAppCategory(
885
885
+
db: D1Database,
886
886
+
recipientDid: Did,
887
887
+
senderDid: Did,
888
888
+
category: string,
889
889
+
): Promise<boolean> {
890
890
+
const result = await db
891
891
+
.prepare('DELETE FROM app_categories WHERE recipient_did = ? AND sender_did = ? AND category = ?')
892
892
+
.bind(recipientDid, senderDid, category)
893
893
+
.run();
894
894
+
return changed(result);
895
895
+
}
896
896
+
833
897
export async function listAppCategoriesForRecipient(
834
898
db: D1Database,
835
899
recipientDid: Did,
···
993
1057
.bind(recipientDid, senderDid)
994
1058
.run();
995
1059
}
1060
1060
+
1061
1061
+
/**
1062
1062
+
* Cascade for `revoke`: drop everything scoped to one (recipient, sender) app —
1063
1063
+
* its app-wide route, per-category routes, and declared/seen categories — so a
1064
1064
+
* revoked app leaves no stale routing/category rows (and a re-grant starts clean).
1065
1065
+
* Inbox notifications are intentionally kept (historical).
1066
1066
+
*/
1067
1067
+
export async function deleteSenderRoutingData(
1068
1068
+
db: D1Database,
1069
1069
+
recipientDid: Did,
1070
1070
+
senderDid: Did,
1071
1071
+
): Promise<void> {
1072
1072
+
await db.batch([
1073
1073
+
db
1074
1074
+
.prepare('DELETE FROM app_routing WHERE recipient_did = ? AND sender_did = ?')
1075
1075
+
.bind(recipientDid, senderDid),
1076
1076
+
db
1077
1077
+
.prepare('DELETE FROM routing WHERE recipient_did = ? AND sender_did = ?')
1078
1078
+
.bind(recipientDid, senderDid),
1079
1079
+
db
1080
1080
+
.prepare('DELETE FROM app_categories WHERE recipient_did = ? AND sender_did = ?')
1081
1081
+
.bind(recipientDid, senderDid),
1082
1082
+
]);
1083
1083
+
}
···
1
1
-
-- Canonical schema reference for the relay's D1 database.
2
2
-
--
3
3
-
-- This file is documentation: the source of truth applied to D1 is the numbered
4
4
-
-- migrations in `apps/relay/migrations/`. Keep this in sync with them (currently
5
5
-
-- it mirrors migrations/0001_init.sql).
6
6
-
--
7
7
-
-- All timestamps are unix milliseconds (Date.now()).
8
8
-
9
9
-
CREATE TABLE users (
10
10
-
did TEXT PRIMARY KEY,
11
11
-
created_at INTEGER NOT NULL,
12
12
-
notify_pending_via_telegram INTEGER NOT NULL DEFAULT 0
13
13
-
);
14
14
-
15
15
-
CREATE TABLE channels (
16
16
-
did TEXT NOT NULL,
17
17
-
platform TEXT NOT NULL,
18
18
-
platform_user_id TEXT NOT NULL,
19
19
-
display_name TEXT,
20
20
-
linked_at INTEGER NOT NULL,
21
21
-
PRIMARY KEY (did, platform)
22
22
-
);
23
23
-
CREATE UNIQUE INDEX channels_by_platform_user ON channels (platform, platform_user_id);
24
24
-
25
25
-
CREATE TABLE link_tokens (
26
26
-
token TEXT PRIMARY KEY,
27
27
-
did TEXT NOT NULL,
28
28
-
platform TEXT NOT NULL,
29
29
-
expires_at INTEGER NOT NULL
30
30
-
);
31
31
-
CREATE INDEX link_tokens_by_did ON link_tokens (did);
32
32
-
CREATE INDEX link_tokens_by_expires ON link_tokens (expires_at);
33
33
-
34
34
-
CREATE TABLE senders (
35
35
-
did TEXT PRIMARY KEY,
36
36
-
handle TEXT,
37
37
-
display_name TEXT,
38
38
-
avatar_url TEXT,
39
39
-
profile_fetched_at INTEGER
40
40
-
);
41
41
-
42
42
-
CREATE TABLE pending_requests (
43
43
-
id TEXT PRIMARY KEY,
44
44
-
recipient_did TEXT NOT NULL,
45
45
-
sender_did TEXT NOT NULL,
46
46
-
reason TEXT, -- legacy (migration 0001); no longer written/read
47
47
-
title TEXT, -- migration 0002: user-supplied display name
48
48
-
description TEXT, -- migration 0002
49
49
-
icon_url TEXT, -- migration 0002
50
50
-
created_at INTEGER NOT NULL,
51
51
-
expires_at INTEGER NOT NULL,
52
52
-
UNIQUE (recipient_did, sender_did)
53
53
-
);
54
54
-
CREATE INDEX pending_by_recipient ON pending_requests (recipient_did);
55
55
-
CREATE INDEX pending_by_expires ON pending_requests (expires_at);
56
56
-
57
57
-
CREATE TABLE grants (
58
58
-
recipient_did TEXT NOT NULL,
59
59
-
sender_did TEXT NOT NULL,
60
60
-
granted_at INTEGER NOT NULL,
61
61
-
muted INTEGER NOT NULL DEFAULT 0,
62
62
-
title TEXT, -- migration 0002: copied from the pending request
63
63
-
description TEXT, -- migration 0002
64
64
-
icon_url TEXT, -- migration 0002
65
65
-
PRIMARY KEY (recipient_did, sender_did)
66
66
-
);
67
67
-
CREATE INDEX grants_by_recipient ON grants (recipient_did);
68
68
-
69
69
-
CREATE TABLE delivery_log (
70
70
-
id TEXT PRIMARY KEY,
71
71
-
recipient_did TEXT NOT NULL,
72
72
-
sender_did TEXT NOT NULL,
73
73
-
title TEXT,
74
74
-
delivered_count INTEGER NOT NULL,
75
75
-
created_at INTEGER NOT NULL
76
76
-
);
77
77
-
CREATE INDEX delivery_by_recipient ON delivery_log (recipient_did, created_at DESC);
···
1
1
+
import type { DeliveryInstance } from '../db/queries';
2
2
+
import type { DeliveryChannel } from '../env';
3
3
+
4
4
+
/** Map a resolved delivery instance to its dispatch-queue channel descriptor. */
5
5
+
export function deliveryChannelFor(target: DeliveryInstance): DeliveryChannel {
6
6
+
switch (target.channel) {
7
7
+
case 'push':
8
8
+
return {
9
9
+
platform: 'webpush',
10
10
+
endpoint: target.endpoint,
11
11
+
p256dh: target.p256dh,
12
12
+
auth: target.auth,
13
13
+
};
14
14
+
case 'telegram':
15
15
+
return { platform: 'telegram', platformUserId: target.chatId };
16
16
+
case 'email':
17
17
+
return { platform: 'email', address: target.address };
18
18
+
case 'dm':
19
19
+
return { platform: 'dm', recipientDid: target.recipientDid };
20
20
+
case 'webhook':
21
21
+
return { platform: 'webhook', url: target.url };
22
22
+
}
23
23
+
}
24
24
+
25
25
+
/** Resolve a user's verified delivery targets that a route selection fires. */
26
26
+
import type { RouteSelection } from '@atmo/notifs-lexicons';
27
27
+
import type { DeliveryTargetRow } from '../db/queries';
28
28
+
import { toDeliveryInstance } from '../db/queries';
29
29
+
30
30
+
export function selectTargets(
31
31
+
rows: DeliveryTargetRow[],
32
32
+
selection: RouteSelection,
33
33
+
): DeliveryInstance[] {
34
34
+
return rows
35
35
+
.map(toDeliveryInstance)
36
36
+
.filter((t): t is DeliveryInstance => t !== null && t.verified)
37
37
+
.filter((t) => {
38
38
+
const sel = selection[t.channel];
39
39
+
return sel !== undefined && (sel.all || sel.ids.includes(t.id));
40
40
+
});
41
41
+
}
···
1
1
+
// Per-channel delivery caps, enforced at send-time fan-out (see xrpc/send.ts).
2
2
+
//
3
3
+
// Two scopes per capped channel, both rolling-daily:
4
4
+
// * per-recipient — how many of this channel ONE user receives per day, across
5
5
+
// all apps (anti-spam / cost control for them).
6
6
+
// * relay-global — how many of this channel the whole relay sends per day
7
7
+
// (stay under the provider's quota, e.g. comail's email plan).
8
8
+
//
9
9
+
// Counting happens when we COMMIT to a delivery (before enqueue). That's
10
10
+
// conservative for the global cap — a send that later fails still consumed its
11
11
+
// slot — which is the safe direction for a provider quota (under-send, never
12
12
+
// over-send). Retries of a queued job are not separately counted.
13
13
+
import type { DeliveryChannelKind } from '../db/queries';
14
14
+
import type { Env } from '../env';
15
15
+
import { checkAndIncrementAll, type RateCheck } from '../ratelimit';
16
16
+
17
17
+
const DAY_SECONDS = 24 * 60 * 60;
18
18
+
19
19
+
export interface ChannelLimit {
20
20
+
/** Max deliveries of this channel to one recipient per rolling day. */
21
21
+
perRecipientPerDay?: number;
22
22
+
/** Max deliveries of this channel across the whole relay per rolling day. */
23
23
+
globalPerDay?: number;
24
24
+
}
25
25
+
26
26
+
/** Parse a non-negative integer env var, falling back to `fallback`. */
27
27
+
function intEnv(value: string | undefined, fallback: number): number {
28
28
+
const n = value !== undefined ? Number.parseInt(value, 10) : Number.NaN;
29
29
+
return Number.isFinite(n) && n >= 0 ? n : fallback;
30
30
+
}
31
31
+
32
32
+
/**
33
33
+
* The daily caps for a channel, or `undefined` if the channel is uncapped. Only
34
34
+
* email is capped today (it costs money and the provider has a quota); push /
35
35
+
* telegram / dm / webhook are free fan-out, so they're unlimited. Add a channel
36
36
+
* here to cap it.
37
37
+
*/
38
38
+
export function channelLimit(env: Env, channel: DeliveryChannelKind): ChannelLimit | undefined {
39
39
+
if (channel === 'email') {
40
40
+
return {
41
41
+
perRecipientPerDay: intEnv(env.EMAIL_DAILY_PER_RECIPIENT, 10),
42
42
+
globalPerDay: intEnv(env.EMAIL_DAILY_GLOBAL, 100),
43
43
+
};
44
44
+
}
45
45
+
return undefined;
46
46
+
}
47
47
+
48
48
+
/**
49
49
+
* Whether a delivery of `channel` to `recipient` is within the channel's daily
50
50
+
* caps, consuming one slot from each configured cap when allowed (and nothing
51
51
+
* when denied — see {@link checkAndIncrementAll}). Uncapped channels always pass
52
52
+
* and never touch KV.
53
53
+
*/
54
54
+
export async function withinChannelLimits(
55
55
+
env: Env,
56
56
+
channel: DeliveryChannelKind,
57
57
+
recipient: string,
58
58
+
): Promise<boolean> {
59
59
+
const limit = channelLimit(env, channel);
60
60
+
if (limit === undefined) return true;
61
61
+
62
62
+
const checks: RateCheck[] = [];
63
63
+
if (limit.perRecipientPerDay !== undefined) {
64
64
+
checks.push({
65
65
+
key: `rl:chan:${channel}:rcpt:${recipient}`,
66
66
+
limit: limit.perRecipientPerDay,
67
67
+
windowSeconds: DAY_SECONDS,
68
68
+
});
69
69
+
}
70
70
+
if (limit.globalPerDay !== undefined) {
71
71
+
checks.push({ key: `rl:chan:${channel}:all`, limit: limit.globalPerDay, windowSeconds: DAY_SECONDS });
72
72
+
}
73
73
+
if (checks.length === 0) return true;
74
74
+
75
75
+
const { allowed } = await checkAndIncrementAll(env.CACHE, checks);
76
76
+
return allowed;
77
77
+
}
···
117
117
COMAIL_DID: string;
118
118
/** Enrolled sender address for the `from` field, e.g. "atmo.pub <notify@atmo.pub>" (var). */
119
119
COMAIL_FROM: string;
120
120
+
/** Max emails delivered to ONE recipient per rolling day (var; default 10). See delivery/limits.ts. */
121
121
+
EMAIL_DAILY_PER_RECIPIENT?: string;
122
122
+
/** Max emails the relay delivers in total per rolling day (var; default 100). Keep ≤ the comail plan. */
123
123
+
EMAIL_DAILY_GLOBAL?: string;
120
124
121
125
// Bluesky DM delivery — the relay sends DMs from a configured bot account.
122
126
/** Bot handle or DID used for createSession (var). */
···
2
2
deleteExpiredLinkTokens,
3
3
deleteExpiredPending,
4
4
deleteOldDeliveryLog,
5
5
+
deleteOldNotifications,
5
6
} from './db/queries';
6
7
import { handleQueue } from './delivery/dispatcher';
7
8
import type { DispatchJob, Env } from './env';
···
16
17
17
18
const TELEGRAM_WEBHOOK_RE = /^\/telegram\/webhook\/(.+)$/;
18
19
const DELIVERY_LOG_RETENTION_MS = 30 * DAY_MS;
20
20
+
// Inbox backstop so `notifications` can't grow without bound. Generous; the inbox
21
21
+
// is the canonical history, but very old entries are dropped.
22
22
+
const NOTIFICATION_RETENTION_MS = 90 * DAY_MS;
19
23
20
24
export default {
21
25
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
···
49
53
await deleteExpiredPending(env.DB, ts);
50
54
await deleteExpiredLinkTokens(env.DB, ts);
51
55
await deleteOldDeliveryLog(env.DB, ts - DELIVERY_LOG_RETENTION_MS);
56
56
+
await deleteOldNotifications(env.DB, ts - NOTIFICATION_RETENTION_MS);
52
57
},
53
58
} satisfies ExportedHandler<Env, DispatchJob>;
···
33
33
did: 'did:web:example.atmo.pub',
34
34
title: 'Example Sender',
35
35
description: 'Demo app showing the atmo.pub integration.',
36
36
+
iconUrl: 'https://example.atmo.pub/icon.svg',
36
37
// For local end-to-end testing, point this at your local example-sender.
37
38
callbackUrl: 'https://example.atmo.pub',
38
39
// Relay-allowlisted for self-management so the example's "Step 3" demo works
···
54
55
/** The registered app for `did`, if it has a callback URL configured. */
55
56
export function callbackAppFor(did: string): RegisteredApp | undefined {
56
57
return APPS.find((app) => app.did === did && app.callbackUrl !== undefined);
58
58
+
}
59
59
+
60
60
+
/** The registered catalog entry for `did`, if any (for grant display metadata). */
61
61
+
export function registeredApp(did: string): RegisteredApp | undefined {
62
62
+
return APPS.find((app) => app.did === did);
57
63
}
58
64
59
65
/** Public catalog metadata for the web "enable apps" UI (no callback URLs). */
···
66
66
resetIn: resetInSeconds,
67
67
};
68
68
}
69
69
+
70
70
+
/** One cap in a multi-key check. */
71
71
+
export interface RateCheck {
72
72
+
key: string;
73
73
+
limit: number;
74
74
+
windowSeconds: number;
75
75
+
}
76
76
+
77
77
+
/**
78
78
+
* All-or-nothing fixed-window check across several keys. Reads every counter; if
79
79
+
* ANY is already at/over its limit, denies WITHOUT incrementing any of them;
80
80
+
* otherwise increments all. This gates one action behind multiple caps at once
81
81
+
* (e.g. a per-recipient AND a relay-global daily channel cap) without burning a
82
82
+
* slot from one cap when a different one is what blocks. Same KV
83
83
+
* eventual-consistency caveat as {@link checkAndIncrement}.
84
84
+
*/
85
85
+
export async function checkAndIncrementAll(
86
86
+
kv: KVNamespace,
87
87
+
checks: readonly RateCheck[],
88
88
+
): Promise<{ allowed: boolean }> {
89
89
+
if (checks.length === 0) return { allowed: true };
90
90
+
const nowMs = Date.now();
91
91
+
92
92
+
const states = await Promise.all(
93
93
+
checks.map(async (c) => {
94
94
+
const { value, metadata } = await kv.getWithMetadata<CounterMetadata>(c.key, 'text');
95
95
+
if (value !== null && metadata !== null && metadata.expiresAt > nowMs) {
96
96
+
return { key: c.key, limit: c.limit, count: Number.parseInt(value, 10), expiresAt: metadata.expiresAt };
97
97
+
}
98
98
+
return { key: c.key, limit: c.limit, count: 0, expiresAt: nowMs + c.windowSeconds * 1000 };
99
99
+
}),
100
100
+
);
101
101
+
102
102
+
if (states.some((s) => s.count >= s.limit)) {
103
103
+
return { allowed: false };
104
104
+
}
105
105
+
106
106
+
await Promise.all(
107
107
+
states.map((s) => {
108
108
+
const resetIn = Math.max(1, Math.ceil((s.expiresAt - nowMs) / 1000));
109
109
+
return kv.put(s.key, String(s.count + 1), {
110
110
+
expirationTtl: Math.max(KV_MIN_TTL_SECONDS, resetIn),
111
111
+
metadata: { expiresAt: s.expiresAt } satisfies CounterMetadata,
112
112
+
});
113
113
+
}),
114
114
+
);
115
115
+
return { allowed: true };
116
116
+
}
···
1
1
import {
2
2
+
PubAtmoNotifyAddCategory,
3
3
+
PubAtmoNotifyGetCategories,
2
4
PubAtmoNotifyGetRouting,
3
5
PubAtmoNotifyListNotifications,
4
6
PubAtmoNotifyManage,
5
7
PubAtmoNotifyMarkRead,
6
8
PubAtmoNotifyMuteSelf,
9
9
+
PubAtmoNotifyRemoveCategory,
7
10
PubAtmoNotifyRequestPermission,
8
11
PubAtmoNotifyRevokeSelf,
9
12
PubAtmoNotifySend,
13
13
+
PubAtmoNotifySetCategories,
10
14
PubAtmoNotifySetRouting,
11
15
} from '@atmo/notifs-lexicons';
12
16
import { XRPCRouter } from '@atcute/xrpc-server';
13
17
14
18
import { getVerifier } from './auth/verifier';
15
19
import type { AppContext, Env } from './env';
20
20
+
import { makeAddCategory } from './xrpc/addCategory';
21
21
+
import { makeGetCategories } from './xrpc/getCategories';
16
22
import { makeGetRouting } from './xrpc/getRouting';
17
23
import { makeListNotifications } from './xrpc/listNotifications';
18
24
import { makeManage } from './xrpc/manage';
19
25
import { makeMarkRead } from './xrpc/markRead';
20
26
import { makeMuteSelf } from './xrpc/muteSelf';
27
27
+
import { makeRemoveCategory } from './xrpc/removeCategory';
21
28
import { makeRequestPermission } from './xrpc/requestPermission';
22
29
import { makeRevokeSelf } from './xrpc/revokeSelf';
23
30
import { makeSend } from './xrpc/send';
31
31
+
import { makeSetCategories } from './xrpc/setCategories';
24
32
import { makeSetRouting } from './xrpc/setRouting';
25
33
26
34
/**
···
64
72
router.addProcedure(PubAtmoNotifyMarkRead.mainSchema, makeMarkRead(app));
65
73
router.addProcedure(PubAtmoNotifyRevokeSelf.mainSchema, makeRevokeSelf(app));
66
74
router.addProcedure(PubAtmoNotifyMuteSelf.mainSchema, makeMuteSelf(app));
75
75
+
router.addProcedure(PubAtmoNotifySetCategories.mainSchema, makeSetCategories(app));
76
76
+
router.addProcedure(PubAtmoNotifyAddCategory.mainSchema, makeAddCategory(app));
77
77
+
router.addProcedure(PubAtmoNotifyRemoveCategory.mainSchema, makeRemoveCategory(app));
78
78
+
router.addProcedure(PubAtmoNotifyGetCategories.mainSchema, makeGetCategories(app));
67
79
router.addProcedure(PubAtmoNotifyManage.mainSchema, makeManage(app));
68
80
69
81
return router;
···
37
37
import { sendEmail } from '../delivery/email';
38
38
import * as q from '../db/queries';
39
39
import type { Env } from '../env';
40
40
-
import { appCatalog, callbackAppFor } from '../lib/apps';
40
40
+
import { appCatalog, callbackAppFor, registeredApp } from '../lib/apps';
41
41
import { invalidRequest } from '../lib/errors';
42
42
import { newLinkToken, newTargetId } from '../lib/ids';
43
43
import { addMinutes, now, toIsoDatetime } from '../lib/time';
···
102
102
input: PubAtmoNotifyRevoke.$input,
103
103
): Promise<PubAtmoNotifyRevoke.$output> {
104
104
const revoked = await q.deleteGrant(env.DB, did, input.sender);
105
105
-
// The pending request (if any) for this pair is now irrelevant.
105
105
+
// The pending request (if any) for this pair is now irrelevant, and the app's
106
106
+
// routing + declared categories should not outlive the grant.
106
107
await q.deletePendingByPair(env.DB, did, input.sender);
108
108
+
await q.deleteSenderRoutingData(env.DB, did, input.sender);
107
109
108
110
if (revoked) {
109
111
await notifySubscriberChanged(env, did, input.sender, false);
···
162
164
await q.ensureUser(env.DB, did, now());
163
165
164
166
// Partial PATCH: only touch fields present in the input.
165
165
-
if (input.notifyPendingViaTelegram !== undefined) {
166
166
-
await q.setNotifyPending(env.DB, did, input.notifyPendingViaTelegram);
167
167
+
if (input.pendingRoute !== undefined) {
168
168
+
await q.setPendingRoute(env.DB, did, input.pendingRoute);
167
169
}
168
170
if (input.autoAllow !== undefined) {
169
171
await q.setAutoAllow(env.DB, did, input.autoAllow);
···
171
173
172
174
const user = await q.getUser(env.DB, did);
173
175
return {
174
174
-
notifyPendingViaTelegram: (user?.notify_pending_via_telegram ?? 0) === 1,
176
176
+
pendingRoute: user?.pending_route ?? 'off',
175
177
};
176
178
}
177
179
···
182
184
const rows = await q.listGrantsForRecipient(env.DB, did);
183
185
184
186
return {
185
185
-
grants: rows.map((row) => ({
186
186
-
sender: row.sender_did,
187
187
-
// user-supplied title; fall back to the Bluesky name/handle, then the DID
188
188
-
title: row.title ?? row.display_name ?? row.handle ?? row.sender_did,
189
189
-
description: row.description ?? undefined,
190
190
-
iconUrl: row.icon_url ?? undefined,
191
191
-
senderHandle: row.handle ?? undefined,
192
192
-
senderBskyDisplayName: row.display_name ?? undefined,
193
193
-
senderBskyAvatar: row.avatar_url ?? undefined,
194
194
-
grantedAt: toIsoDatetime(row.granted_at),
195
195
-
muted: row.muted === 1,
196
196
-
})),
187
187
+
grants: rows.map((row) => {
188
188
+
// For a registered app, fall back to its catalog branding when the grant
189
189
+
// has no stored metadata (e.g. enabled from the web, not via requestPermission).
190
190
+
const app = registeredApp(row.sender_did);
191
191
+
return {
192
192
+
sender: row.sender_did,
193
193
+
// grant metadata → registered catalog → Bluesky name/handle → the DID
194
194
+
title: row.title ?? app?.title ?? row.display_name ?? row.handle ?? row.sender_did,
195
195
+
description: row.description ?? app?.description ?? undefined,
196
196
+
iconUrl: row.icon_url ?? app?.iconUrl ?? undefined,
197
197
+
senderHandle: row.handle ?? undefined,
198
198
+
senderBskyDisplayName: row.display_name ?? undefined,
199
199
+
senderBskyAvatar: row.avatar_url ?? undefined,
200
200
+
grantedAt: toIsoDatetime(row.granted_at),
201
201
+
muted: row.muted === 1,
202
202
+
};
203
203
+
}),
197
204
};
198
205
}
199
206
···
204
211
const rows = await q.listPendingForRecipient(env.DB, did, now());
205
212
206
213
return {
207
207
-
pending: rows.map((row) => ({
208
208
-
id: row.id,
209
209
-
sender: row.sender_did,
210
210
-
// user-supplied display metadata (fall back to the DID for title)
211
211
-
title: row.title ?? row.sender_did,
212
212
-
description: row.description ?? undefined,
213
213
-
iconUrl: row.icon_url ?? undefined,
214
214
-
// best-effort Bluesky profile (informational "verified on Bluesky")
215
215
-
senderHandle: row.handle ?? undefined,
216
216
-
senderBskyDisplayName: row.display_name ?? undefined,
217
217
-
senderBskyAvatar: row.avatar_url ?? undefined,
218
218
-
createdAt: toIsoDatetime(row.created_at),
219
219
-
expiresAt: toIsoDatetime(row.expires_at),
220
220
-
})),
214
214
+
pending: rows.map((row) => {
215
215
+
const app = registeredApp(row.sender_did);
216
216
+
return {
217
217
+
id: row.id,
218
218
+
sender: row.sender_did,
219
219
+
// requester metadata → registered catalog → the DID
220
220
+
title: row.title ?? app?.title ?? row.sender_did,
221
221
+
description: row.description ?? app?.description ?? undefined,
222
222
+
iconUrl: row.icon_url ?? app?.iconUrl ?? undefined,
223
223
+
// best-effort Bluesky profile (informational "verified on Bluesky")
224
224
+
senderHandle: row.handle ?? undefined,
225
225
+
senderBskyDisplayName: row.display_name ?? undefined,
226
226
+
senderBskyAvatar: row.avatar_url ?? undefined,
227
227
+
createdAt: toIsoDatetime(row.created_at),
228
228
+
expiresAt: toIsoDatetime(row.expires_at),
229
229
+
};
230
230
+
}),
221
231
};
222
232
}
223
233
···
299
309
const pushDevices = (await q.countDeliveryTargets(env.DB, did, 'push'))?.c ?? 0;
300
310
301
311
return {
302
302
-
notifyPendingViaTelegram: (user?.notify_pending_via_telegram ?? 0) === 1,
312
312
+
pendingRoute: user?.pending_route ?? 'off',
303
313
autoAllow: (user?.auto_allow ?? 'trusted') as 'all' | 'trusted' | 'none',
304
314
pushDevices,
305
315
};
···
391
401
export async function getRouting(env: Env, did: Did): Promise<RoutingConfig> {
392
402
await q.ensureUser(env.DB, did, now());
393
403
const user = await q.getUser(env.DB, did);
394
394
-
const defaultRoute = (user?.default_route ?? 'push') as AlertRoute;
404
404
+
const defaultRoute = (user?.default_route ?? 'inbox') as AlertRoute;
395
405
396
406
const [grants, categories, routing, appRouting, deliveryTargets] = await Promise.all([
397
407
q.listGrantsForRecipient(env.DB, did),
···
412
422
}
413
423
414
424
const routeBy = new Map<string, string>();
415
415
-
for (const r of routing) routeBy.set(`${r.sender_did}�${r.category}`, r.route);
425
425
+
for (const r of routing) routeBy.set(JSON.stringify([r.sender_did, r.category]), r.route);
416
426
417
427
const appRouteBy = new Map<string, string>();
418
428
for (const a of appRouting) appRouteBy.set(a.sender_did, a.route);
···
424
434
catsBySender.set(c.sender_did, list);
425
435
}
426
436
427
427
-
const apps: RoutingApp[] = grants.map((g) => ({
428
428
-
sender: g.sender_did,
429
429
-
title: g.title ?? g.display_name ?? g.handle ?? g.sender_did,
430
430
-
route: (appRouteBy.get(g.sender_did) ?? 'default') as AppRoute,
431
431
-
manage: g.manage as Capability,
432
432
-
muted: g.muted === 1,
433
433
-
iconUrl: g.icon_url ?? g.avatar_url ?? undefined,
434
434
-
categories: (catsBySender.get(g.sender_did) ?? []).map((c) => ({
435
435
-
category: c.category,
437
437
+
const apps: RoutingApp[] = grants.map((g) => {
438
438
+
// Registered apps fall back to their catalog branding (covers grants made via
439
439
+
// the web "Enable" flow, which stores no per-grant title/icon).
440
440
+
const reg = registeredApp(g.sender_did);
441
441
+
return {
442
442
+
sender: g.sender_did,
443
443
+
title: g.title ?? reg?.title ?? g.display_name ?? g.handle ?? g.sender_did,
444
444
+
route: (appRouteBy.get(g.sender_did) ?? 'default') as AppRoute,
445
445
+
manage: g.manage as Capability,
446
446
+
muted: g.muted === 1,
447
447
+
iconUrl: g.icon_url ?? reg?.iconUrl ?? g.avatar_url ?? undefined,
448
448
+
categories: (catsBySender.get(g.sender_did) ?? []).map((c) => ({
449
449
+
category: c.category,
450
450
+
title: c.title ?? undefined,
451
451
+
description: c.description ?? undefined,
452
452
+
route: (routeBy.get(JSON.stringify([g.sender_did, c.category])) ?? 'app') as CategoryRoute,
453
453
+
})),
454
454
+
};
455
455
+
});
456
456
+
457
457
+
return { defaultRoute, apps, channels };
458
458
+
}
459
459
+
460
460
+
// --- federated getRouting (one app's slice, for third-party apps) -----------
461
461
+
462
462
+
/** A single delivery target as exposed to apps: opaque id + privacy-safe label. */
463
463
+
export interface AppTargetView {
464
464
+
type: string;
465
465
+
id: string;
466
466
+
label: string;
467
467
+
}
468
468
+
469
469
+
export interface AppRoutingView {
470
470
+
route: AppRoute;
471
471
+
defaultRoute: AlertRoute;
472
472
+
categories: CategoryView[];
473
473
+
targets: AppTargetView[];
474
474
+
}
475
475
+
476
476
+
// Privacy-safe generic labels per channel (no PII), with a capitalized-id fallback.
477
477
+
const GENERIC_LABEL: Record<string, string> = {
478
478
+
push: 'Push device',
479
479
+
telegram: 'Telegram',
480
480
+
email: 'Email',
481
481
+
dm: 'Direct message',
482
482
+
webhook: 'Webhook',
483
483
+
};
484
484
+
const genericLabel = (channel: string): string =>
485
485
+
GENERIC_LABEL[channel] ?? channel.charAt(0).toUpperCase() + channel.slice(1);
486
486
+
487
487
+
/**
488
488
+
* The app-facing target catalog. Labels never leak the raw email address /
489
489
+
* telegram handle: a user-chosen name (`named`) is used as-is; push device labels
490
490
+
* (a UA descriptor, not PII) pass through; everything else gets a generic label,
491
491
+
* numbered when a channel has more than one verified target.
492
492
+
*/
493
493
+
export function safeTargets(rows: q.DeliveryTargetRow[]): AppTargetView[] {
494
494
+
const verified = rows.filter((r) => r.verified === 1);
495
495
+
const counts: Record<string, number> = {};
496
496
+
for (const r of verified) counts[r.channel] = (counts[r.channel] ?? 0) + 1;
497
497
+
const seen: Record<string, number> = {};
498
498
+
return verified.map((r) => {
499
499
+
const n = (seen[r.channel] ?? 0) + 1;
500
500
+
seen[r.channel] = n;
501
501
+
let label: string;
502
502
+
if (r.named === 1 && r.label) label = r.label;
503
503
+
else if (r.channel === 'push' && r.label) label = r.label;
504
504
+
else label = (counts[r.channel] ?? 0) > 1 ? `${genericLabel(r.channel)} ${n}` : genericLabel(r.channel);
505
505
+
return { type: r.channel, id: r.id, label };
506
506
+
});
507
507
+
}
508
508
+
509
509
+
/**
510
510
+
* One app's routing slice for a user (the federated `getRouting` view): the app's
511
511
+
* own route + categories, the account default (so 'default'/'app' can be labelled),
512
512
+
* and the privacy-safe target catalog so the app can render a full route picker.
513
513
+
*/
514
514
+
export async function getAppRouting(env: Env, did: Did, sender: Did): Promise<AppRoutingView> {
515
515
+
const [user, appRoute, cats, routes, deliveryTargets] = await Promise.all([
516
516
+
q.getUser(env.DB, did),
517
517
+
q.getAppRoute(env.DB, did, sender),
518
518
+
q.listAppCategoriesForSender(env.DB, did, sender),
519
519
+
q.listRoutingForSender(env.DB, did, sender),
520
520
+
q.listDeliveryTargets(env.DB, did),
521
521
+
]);
522
522
+
const routeByCategory = new Map(routes.map((r) => [r.category, r.route]));
523
523
+
return {
524
524
+
route: (appRoute?.route ?? 'default') as AppRoute,
525
525
+
defaultRoute: (user?.default_route ?? 'inbox') as AlertRoute,
526
526
+
categories: cats.map((c) => ({
527
527
+
id: c.category,
528
528
+
title: c.title ?? undefined,
436
529
description: c.description ?? undefined,
437
437
-
route: (routeBy.get(`${g.sender_did}�${c.category}`) ?? 'app') as CategoryRoute,
530
530
+
route: (routeByCategory.get(c.category) ?? 'app') as CategoryRoute,
438
531
})),
439
439
-
}));
440
440
-
441
441
-
return { defaultRoute, apps, channels };
532
532
+
targets: safeTargets(deliveryTargets),
533
533
+
};
442
534
}
443
535
444
536
export async function setRouting(
···
489
581
): Promise<{ ok: boolean }> {
490
582
await q.setGrantManage(env.DB, did, sender, manage);
491
583
return { ok: true };
584
584
+
}
585
585
+
586
586
+
// --- app-declared categories (per user, per app) ---------------------------
587
587
+
588
588
+
/** A category an app declares for a user: key + optional title/description and an
589
589
+
* optional initial route. Scoped to (user, app), so never shared across users. */
590
590
+
export interface CategoryInput {
591
591
+
id: string;
592
592
+
title?: string;
593
593
+
description?: string;
594
594
+
route?: CategoryRoute;
595
595
+
}
596
596
+
597
597
+
/** Declare/update one category for (user, app), optionally setting its route. */
598
598
+
async function applyCategory(env: Env, did: Did, sender: Did, cat: CategoryInput): Promise<void> {
599
599
+
await q.declareAppCategory(env.DB, {
600
600
+
recipientDid: did,
601
601
+
senderDid: sender,
602
602
+
category: cat.id,
603
603
+
title: cat.title ?? null,
604
604
+
description: cat.description ?? null,
605
605
+
lastSeen: now(),
606
606
+
});
607
607
+
if (cat.route !== undefined) {
608
608
+
if (cat.route === 'app') await q.deleteRouting(env.DB, did, sender, cat.id);
609
609
+
else await q.upsertRouting(env.DB, did, sender, cat.id, cat.route);
610
610
+
}
611
611
+
}
612
612
+
613
613
+
/** Add or update one of the app's categories for the user. */
614
614
+
export async function addCategory(
615
615
+
env: Env,
616
616
+
did: Did,
617
617
+
sender: Did,
618
618
+
cat: CategoryInput,
619
619
+
): Promise<{ ok: boolean }> {
620
620
+
await applyCategory(env, did, sender, cat);
621
621
+
return { ok: true };
622
622
+
}
623
623
+
624
624
+
/** Full sync: upsert the listed categories, prune the rest (and their routing). */
625
625
+
export async function setCategories(
626
626
+
env: Env,
627
627
+
did: Did,
628
628
+
sender: Did,
629
629
+
categories: CategoryInput[],
630
630
+
): Promise<{ ok: boolean }> {
631
631
+
const keep = new Set(categories.map((c) => c.id));
632
632
+
const existing = await q.listAppCategoriesForSender(env.DB, did, sender);
633
633
+
for (const cat of categories) await applyCategory(env, did, sender, cat);
634
634
+
for (const row of existing) {
635
635
+
if (!keep.has(row.category)) {
636
636
+
await q.deleteAppCategory(env.DB, did, sender, row.category);
637
637
+
await q.deleteRouting(env.DB, did, sender, row.category);
638
638
+
}
639
639
+
}
640
640
+
return { ok: true };
641
641
+
}
642
642
+
643
643
+
/** Remove one category + its per-category routing. */
644
644
+
export async function removeCategory(
645
645
+
env: Env,
646
646
+
did: Did,
647
647
+
sender: Did,
648
648
+
id: string,
649
649
+
): Promise<{ removed: boolean }> {
650
650
+
const removed = await q.deleteAppCategory(env.DB, did, sender, id);
651
651
+
await q.deleteRouting(env.DB, did, sender, id);
652
652
+
return { removed };
653
653
+
}
654
654
+
655
655
+
export interface CategoryView {
656
656
+
id: string;
657
657
+
title?: string;
658
658
+
description?: string;
659
659
+
route: CategoryRoute;
660
660
+
}
661
661
+
662
662
+
/** List the app's categories for the user, with each one's current route. */
663
663
+
export async function getCategories(
664
664
+
env: Env,
665
665
+
did: Did,
666
666
+
sender: Did,
667
667
+
): Promise<{ categories: CategoryView[] }> {
668
668
+
const [cats, routes] = await Promise.all([
669
669
+
q.listAppCategoriesForSender(env.DB, did, sender),
670
670
+
q.listRoutingForSender(env.DB, did, sender),
671
671
+
]);
672
672
+
const routeBy = new Map(routes.map((r) => [r.category, r.route]));
673
673
+
return {
674
674
+
categories: cats.map((c) => ({
675
675
+
id: c.category,
676
676
+
title: c.title ?? undefined,
677
677
+
description: c.description ?? undefined,
678
678
+
route: (routeBy.get(c.category) ?? 'app') as CategoryRoute,
679
679
+
})),
680
680
+
};
492
681
}
493
682
494
683
// --- email channel ---------------------------------------------------------
···
5
5
import { answerCallbackQuery, editMessageText } from '../delivery/telegram';
6
6
import { now } from '../lib/time';
7
7
8
8
-
import { settingsKeyboard, settingsText } from './commands';
9
8
import type { TelegramCallbackQuery } from './webhook';
10
9
11
10
const PLATFORM = 'telegram';
···
32
31
}
33
32
if (data.startsWith('deny:')) {
34
33
await handleDeny(env, query, channel.did, data.slice('deny:'.length), messageId);
35
35
-
return;
36
36
-
}
37
37
-
if (data === 'toggle:notifyPending') {
38
38
-
await handleToggle(env, query, channel.did, messageId);
39
34
return;
40
35
}
41
36
···
96
91
}
97
92
}
98
93
await answerCallbackQuery(env, { callback_query_id: query.id, text: 'Denied' });
99
99
-
}
100
100
-
101
101
-
async function handleToggle(
102
102
-
env: Env,
103
103
-
query: TelegramCallbackQuery,
104
104
-
recipientDid: Did,
105
105
-
messageId: number | undefined,
106
106
-
): Promise<void> {
107
107
-
await q.ensureUser(env.DB, recipientDid, now());
108
108
-
const user = await q.getUser(env.DB, recipientDid);
109
109
-
const next = (user?.notify_pending_via_telegram ?? 0) === 0;
110
110
-
await q.setNotifyPending(env.DB, recipientDid, next);
111
111
-
112
112
-
if (messageId !== undefined) {
113
113
-
await editMessageText(env, {
114
114
-
chat_id: query.from.id,
115
115
-
message_id: messageId,
116
116
-
text: settingsText(next),
117
117
-
parse_mode: 'MarkdownV2',
118
118
-
reply_markup: settingsKeyboard(next),
119
119
-
});
120
120
-
}
121
121
-
await answerCallbackQuery(env, {
122
122
-
callback_query_id: query.id,
123
123
-
text: next ? 'Enabled' : 'Disabled',
124
124
-
});
125
94
}
126
95
127
96
/** A human-friendly label for a sender (cached handle, falling back to DID). */
···
2
2
3
3
import * as q from '../db/queries';
4
4
import type { Env } from '../env';
5
5
-
import {
6
6
-
escapeMd,
7
7
-
type InlineKeyboardMarkup,
8
8
-
sendMessage,
9
9
-
} from '../delivery/telegram';
5
5
+
import { escapeMd, sendMessage } from '../delivery/telegram';
10
6
import { resolveHandle } from '../identity/resolve';
11
7
import { newTargetId } from '../lib/ids';
12
8
import { now } from '../lib/time';
···
35
31
return;
36
32
case '/revoke':
37
33
await handleRevoke(env, message.chat.id, arg);
38
38
-
return;
39
39
-
case '/settings':
40
40
-
await handleSettings(env, message.chat.id);
41
34
return;
42
35
default:
43
36
await handleHelp(env, message.chat.id);
···
139
132
);
140
133
}
141
134
142
142
-
async function handleSettings(env: Env, chatId: number): Promise<void> {
143
143
-
const channel = await q.getDeliveryTargetByRef(env.DB, PLATFORM, String(chatId));
144
144
-
if (channel === null) {
145
145
-
await replyText(env, chatId, NOT_LINKED);
146
146
-
return;
147
147
-
}
148
148
-
149
149
-
await q.ensureUser(env.DB, channel.did, now());
150
150
-
const user = await q.getUser(env.DB, channel.did);
151
151
-
const enabled = (user?.notify_pending_via_telegram ?? 0) === 1;
152
152
-
await sendMessage(env, {
153
153
-
chat_id: chatId,
154
154
-
text: settingsText(enabled),
155
155
-
parse_mode: 'MarkdownV2',
156
156
-
reply_markup: settingsKeyboard(enabled),
157
157
-
});
158
158
-
}
159
159
-
160
135
async function handleHelp(env: Env, chatId: number): Promise<void> {
161
136
await replyText(
162
137
env,
···
166
141
'/start — link your account',
167
142
'/list — list authorized apps',
168
143
'/revoke <handle-or-did> — revoke an app',
169
169
-
'/settings — notification settings',
144
144
+
'',
145
145
+
`Manage where notifications go at ${DASHBOARD_URL}`,
170
146
].join('\n'),
171
147
);
172
148
}
···
179
155
const handle = input.replace(/^@/, '');
180
156
const sender = await q.getSenderByHandle(env.DB, handle);
181
157
return sender?.did ?? null;
182
182
-
}
183
183
-
184
184
-
// --- shared settings rendering (also used by callbacks.ts) ------------------
185
185
-
186
186
-
export function settingsText(enabled: boolean): string {
187
187
-
return `*Settings*\n\nNotify me on Telegram about new permission requests: *${enabled ? 'ON' : 'OFF'}*`;
188
188
-
}
189
189
-
190
190
-
export function settingsKeyboard(enabled: boolean): InlineKeyboardMarkup {
191
191
-
return {
192
192
-
inline_keyboard: [
193
193
-
[
194
194
-
{
195
195
-
text: enabled ? '🔔 Pending alerts: ON' : '🔕 Pending alerts: OFF',
196
196
-
callback_data: 'toggle:notifyPending',
197
197
-
},
198
198
-
],
199
199
-
],
200
200
-
};
201
158
}
202
159
203
160
const NOT_LINKED = `This Telegram account is not linked yet. Link it from ${DASHBOARD_URL}`;
···
1
1
+
import addCategory from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/addCategory.json';
2
2
+
import getCategories from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/getCategories.json';
1
3
import getRouting from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/getRouting.json';
2
4
import listNotifications from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/listNotifications.json';
5
5
+
import removeCategory from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/removeCategory.json';
6
6
+
import setCategories from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/setCategories.json';
3
7
import manage from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/manage.json';
4
8
import markRead from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/markRead.json';
5
9
import muteSelf from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/muteSelf.json';
···
21
25
'pub.atmo.notify.send': send,
22
26
'pub.atmo.notify.setRouting': setRouting,
23
27
'pub.atmo.notify.getRouting': getRouting,
28
28
+
'pub.atmo.notify.setCategories': setCategories,
29
29
+
'pub.atmo.notify.addCategory': addCategory,
30
30
+
'pub.atmo.notify.removeCategory': removeCategory,
31
31
+
'pub.atmo.notify.getCategories': getCategories,
24
32
'pub.atmo.notify.listNotifications': listNotifications,
25
33
'pub.atmo.notify.manage': manage,
26
34
'pub.atmo.notify.markRead': markRead,
···
1
1
+
import { isCategoryRoute, PubAtmoNotifyAddCategory } from '@atmo/notifs-lexicons';
2
2
+
import { json, type ProcedureConfig } from '@atcute/xrpc-server';
3
3
+
4
4
+
import { verifyManagementCall } from '../auth/management';
5
5
+
import type { AppContext } from '../env';
6
6
+
import { invalidRequest } from '../lib/errors';
7
7
+
import * as ops from '../rpc/ops';
8
8
+
9
9
+
const LXM = 'pub.atmo.notify.addCategory';
10
10
+
11
11
+
/** Declare (add/update) one of the calling app's categories for a user. Self-scoped. */
12
12
+
export function makeAddCategory(
13
13
+
app: AppContext,
14
14
+
): ProcedureConfig<PubAtmoNotifyAddCategory.mainSchema> {
15
15
+
return {
16
16
+
handler: async ({ request, input }) => {
17
17
+
const { appDid, userDid } = await verifyManagementCall(app, request, input, {
18
18
+
scope: 'self',
19
19
+
write: true,
20
20
+
lxm: LXM,
21
21
+
});
22
22
+
23
23
+
if (input.route !== undefined && !isCategoryRoute(input.route)) {
24
24
+
throw invalidRequest('Invalid category route');
25
25
+
}
26
26
+
27
27
+
await ops.addCategory(app.env, userDid, appDid, {
28
28
+
id: input.id,
29
29
+
title: input.title,
30
30
+
description: input.description,
31
31
+
route: input.route,
32
32
+
});
33
33
+
return json({ ok: true });
34
34
+
},
35
35
+
};
36
36
+
}
···
1
1
+
import { PubAtmoNotifyGetCategories } from '@atmo/notifs-lexicons';
2
2
+
import { json, type ProcedureConfig } from '@atcute/xrpc-server';
3
3
+
4
4
+
import { verifyManagementCall } from '../auth/management';
5
5
+
import type { AppContext } from '../env';
6
6
+
import * as ops from '../rpc/ops';
7
7
+
8
8
+
const LXM = 'pub.atmo.notify.getCategories';
9
9
+
10
10
+
/** List the calling app's categories for a user (its own only), with each route. Self-scoped read. */
11
11
+
export function makeGetCategories(
12
12
+
app: AppContext,
13
13
+
): ProcedureConfig<PubAtmoNotifyGetCategories.mainSchema> {
14
14
+
return {
15
15
+
handler: async ({ request, input }) => {
16
16
+
const { appDid, userDid } = await verifyManagementCall(app, request, input, {
17
17
+
scope: 'self',
18
18
+
write: false,
19
19
+
lxm: LXM,
20
20
+
});
21
21
+
22
22
+
const { categories } = await ops.getCategories(app.env, userDid, appDid);
23
23
+
return json({ categories });
24
24
+
},
25
25
+
};
26
26
+
}
···
2
2
import { json, type ProcedureConfig } from '@atcute/xrpc-server';
3
3
4
4
import { verifyManagementCall } from '../auth/management';
5
5
-
import * as q from '../db/queries';
6
5
import type { AppContext } from '../env';
6
6
+
import * as ops from '../rpc/ops';
7
7
8
8
const LXM = 'pub.atmo.notify.getRouting';
9
9
10
10
-
// Privacy-safe generic labels per channel (no PII), with a capitalized-id fallback.
11
11
-
const GENERIC_LABEL: Record<string, string> = {
12
12
-
push: 'Push device',
13
13
-
telegram: 'Telegram',
14
14
-
email: 'Email',
15
15
-
dm: 'Direct message',
16
16
-
webhook: 'Webhook',
17
17
-
};
18
18
-
const genericLabel = (channel: string): string =>
19
19
-
GENERIC_LABEL[channel] ?? channel.charAt(0).toUpperCase() + channel.slice(1);
20
20
-
21
21
-
/**
22
22
-
* Build the app-facing target catalog. Labels never leak the raw email
23
23
-
* address / telegram handle: a user-chosen name (`named`) is used as-is; push
24
24
-
* device labels (a UA descriptor, not PII) pass through; everything else gets a
25
25
-
* generic label, numbered when a channel has more than one target.
26
26
-
*/
27
27
-
function safeTargets(rows: q.DeliveryTargetRow[]): { type: string; id: string; label: string }[] {
28
28
-
const verified = rows.filter((r) => r.verified === 1);
29
29
-
const counts: Record<string, number> = {};
30
30
-
for (const r of verified) counts[r.channel] = (counts[r.channel] ?? 0) + 1;
31
31
-
const seen: Record<string, number> = {};
32
32
-
return verified.map((r) => {
33
33
-
const n = (seen[r.channel] ?? 0) + 1;
34
34
-
seen[r.channel] = n;
35
35
-
let label: string;
36
36
-
if (r.named === 1 && r.label) label = r.label;
37
37
-
else if (r.channel === 'push' && r.label) label = r.label;
38
38
-
else label = (counts[r.channel] ?? 0) > 1 ? `${genericLabel(r.channel)} ${n}` : genericLabel(r.channel);
39
39
-
return { type: r.channel, id: r.id, label };
40
40
-
});
41
41
-
}
42
42
-
43
10
/**
44
11
* Read how the calling app's own notifications are currently routed for a user,
45
12
* so the app can render an accurate in-app settings UI. A self-scoped management
46
46
-
* read (see MANAGEMENT-AUTH.md); returns only this app's slice plus the
47
47
-
* account-default value (so 'default'/'app' can be labelled by the caller).
13
13
+
* read (see MANAGEMENT-AUTH.md). Delegates to `ops.getAppRouting`; returns only
14
14
+
* this app's slice plus the account-default value and the privacy-safe target
15
15
+
* catalog (so 'default'/'app' can be labelled and a full picker rendered).
48
16
*/
49
17
export function makeGetRouting(
50
18
app: AppContext,
···
57
25
lxm: LXM,
58
26
});
59
27
60
60
-
const [user, appRoute, cats, routes, deliveryTargets] = await Promise.all([
61
61
-
q.getUser(app.env.DB, userDid),
62
62
-
q.getAppRoute(app.env.DB, userDid, senderDid),
63
63
-
q.listAppCategoriesForSender(app.env.DB, userDid, senderDid),
64
64
-
q.listRoutingForSender(app.env.DB, userDid, senderDid),
65
65
-
q.listDeliveryTargets(app.env.DB, userDid),
66
66
-
]);
67
67
-
68
68
-
const routeByCategory = new Map(routes.map((r) => [r.category, r.route]));
69
69
-
70
70
-
// DB columns are plain strings; cast to the lexicon's route unions at the
71
71
-
// boundary (values were validated when written).
72
72
-
type Out = PubAtmoNotifyGetRouting.$output;
73
73
-
return json({
74
74
-
route: (appRoute?.route ?? 'default') as Out['route'],
75
75
-
defaultRoute: (user?.default_route ?? 'push') as Out['defaultRoute'],
76
76
-
categories: cats.map(
77
77
-
(c): PubAtmoNotifyGetRouting.Category => ({
78
78
-
id: c.category,
79
79
-
description: c.description ?? undefined,
80
80
-
route: (routeByCategory.get(c.category) ?? 'app') as PubAtmoNotifyGetRouting.Category['route'],
81
81
-
}),
82
82
-
),
83
83
-
targets: safeTargets(deliveryTargets),
84
84
-
});
28
28
+
const view = await ops.getAppRouting(app.env, userDid, senderDid);
29
29
+
// DB strings → the lexicon's route unions at the boundary (validated on write).
30
30
+
return json(view as PubAtmoNotifyGetRouting.$output);
85
31
},
86
32
};
87
33
}
···
29
29
// gate (`verifyManagementCall` at `scope: 'full'`) runs before any op.
30
30
type OpFn = (env: Env, did: Did, params: unknown) => Promise<unknown>;
31
31
32
32
+
// NOTE: category management (setCategories/addCategory/removeCategory/getCategories)
33
33
+
// and addWebhook are deliberately NOT exposed here. They are inherently per-app
34
34
+
// self-scope (an app declares ITS OWN categories / adds a webhook), so they belong
35
35
+
// on the self surface; a `full` account-manager has no business mutating another
36
36
+
// app's categories. setGrantManage is likewise user-only.
32
37
const OPS: Record<string, OpFn> = {
33
38
// reads
34
39
listGrants: (env, did) => ops.listGrants(env, did),
···
2
2
import { json, type ProcedureConfig } from '@atcute/xrpc-server';
3
3
4
4
import { verifyManagementCall } from '../auth/management';
5
5
-
import * as q from '../db/queries';
6
5
import type { AppContext } from '../env';
6
6
+
import * as ops from '../rpc/ops';
7
7
8
8
const LXM = 'pub.atmo.notify.muteSelf';
9
9
···
16
16
write: true,
17
17
lxm: LXM,
18
18
});
19
19
-
await q.setGrantMuted(app.env.DB, userDid, appDid, input.muted);
19
19
+
await ops.muteGrant(app.env, userDid, { sender: appDid, muted: input.muted });
20
20
return json({ ok: true });
21
21
},
22
22
};
···
1
1
+
import { PubAtmoNotifyRemoveCategory } from '@atmo/notifs-lexicons';
2
2
+
import { json, type ProcedureConfig } from '@atcute/xrpc-server';
3
3
+
4
4
+
import { verifyManagementCall } from '../auth/management';
5
5
+
import type { AppContext } from '../env';
6
6
+
import * as ops from '../rpc/ops';
7
7
+
8
8
+
const LXM = 'pub.atmo.notify.removeCategory';
9
9
+
10
10
+
/** Remove one of the calling app's categories for a user (+ its routing). Self-scoped. */
11
11
+
export function makeRemoveCategory(
12
12
+
app: AppContext,
13
13
+
): ProcedureConfig<PubAtmoNotifyRemoveCategory.mainSchema> {
14
14
+
return {
15
15
+
handler: async ({ request, input }) => {
16
16
+
const { appDid, userDid } = await verifyManagementCall(app, request, input, {
17
17
+
scope: 'self',
18
18
+
write: true,
19
19
+
lxm: LXM,
20
20
+
});
21
21
+
22
22
+
const { removed } = await ops.removeCategory(app.env, userDid, appDid, input.id);
23
23
+
return json({ removed });
24
24
+
},
25
25
+
};
26
26
+
}
···
1
1
-
import { PubAtmoNotifyRequestPermission } from '@atmo/notifs-lexicons';
1
1
+
import { PubAtmoNotifyRequestPermission, routeSelection } from '@atmo/notifs-lexicons';
2
2
import type { Did } from '@atcute/lexicons';
3
3
import { isDid } from '@atcute/lexicons/syntax';
4
4
import { InvalidRequestError, json, type ProcedureConfig } from '@atcute/xrpc-server';
5
5
6
6
import { verifyUserRequest } from '../auth/user';
7
7
import * as q from '../db/queries';
8
8
-
import type { AppContext } from '../env';
8
8
+
import { deliveryChannelFor, selectTargets } from '../delivery/channel';
9
9
+
import type { AppContext, DispatchJob } from '../env';
9
10
import { rateLimited } from '../lib/errors';
10
11
import { newId } from '../lib/ids';
11
12
import { addDays, now } from '../lib/time';
···
124
125
return `granted:${recipient}:${senderDid}`;
125
126
}
126
127
128
128
+
/** Where users review/approve permission requests. */
129
129
+
const PENDING_URL = 'https://atmo.pub/apps';
130
130
+
127
131
/**
128
128
-
* If the recipient linked Telegram and opted into pending-request alerts, enqueue
129
129
-
* a `pendingRequest` job so they can approve/deny from the chat.
132
132
+
* Alert the recipient about a new permission request, on whichever channels they
133
133
+
* chose (`users.pending_route`). Telegram gets an inline approve/deny prompt; any
134
134
+
* other channel gets a plain notification linking to the dashboard.
130
135
*/
131
136
async function maybeNotifyPending(
132
137
app: AppContext,
···
138
143
iconUrl: string | null,
139
144
): Promise<void> {
140
145
const user = await q.getUser(app.env.DB, recipient);
141
141
-
if (user === null || user.notify_pending_via_telegram !== 1) {
146
146
+
const route = user?.pending_route ?? 'off';
147
147
+
if (route === 'off' || route === '') {
142
148
return;
143
149
}
144
144
-
const telegram = (await q.listDeliveryTargets(app.env.DB, recipient)).find(
145
145
-
(target) => target.channel === 'telegram',
150
150
+
151
151
+
const targets = selectTargets(
152
152
+
await q.listDeliveryTargets(app.env.DB, recipient),
153
153
+
routeSelection(route),
146
154
);
147
147
-
if (telegram === undefined) {
148
148
-
return;
149
149
-
}
155
155
+
if (targets.length === 0) return;
150
156
151
151
-
await app.env.DISPATCH_QUEUE.send({
152
152
-
kind: 'pendingRequest',
153
153
-
channel: { platform: 'telegram', platformUserId: telegram.ref },
154
154
-
requestId,
155
155
-
senderTitle: title,
156
156
-
senderDescription: description ?? undefined,
157
157
-
senderIconUrl: iconUrl ?? undefined,
158
158
-
senderDid,
159
159
-
});
157
157
+
const body = `${title} wants to send you notifications — review it in atmo.pub.`;
158
158
+
const jobs = targets.map((t) => ({
159
159
+
body:
160
160
+
t.channel === 'telegram'
161
161
+
? ({
162
162
+
kind: 'pendingRequest' as const,
163
163
+
channel: { platform: 'telegram' as const, platformUserId: t.chatId },
164
164
+
requestId,
165
165
+
senderTitle: title,
166
166
+
senderDescription: description ?? undefined,
167
167
+
senderIconUrl: iconUrl ?? undefined,
168
168
+
senderDid,
169
169
+
} satisfies DispatchJob)
170
170
+
: ({
171
171
+
kind: 'notification' as const,
172
172
+
channel: deliveryChannelFor(t),
173
173
+
title: 'Permission request',
174
174
+
body,
175
175
+
uri: PENDING_URL,
176
176
+
senderDid,
177
177
+
} satisfies DispatchJob),
178
178
+
}));
179
179
+
await app.env.DISPATCH_QUEUE.sendBatch(jobs);
160
180
}
···
2
2
import { json, type ProcedureConfig } from '@atcute/xrpc-server';
3
3
4
4
import { verifyManagementCall } from '../auth/management';
5
5
-
import * as q from '../db/queries';
6
5
import type { AppContext } from '../env';
6
6
+
import * as ops from '../rpc/ops';
7
7
8
8
const LXM = 'pub.atmo.notify.revokeSelf';
9
9
10
10
-
/** An app removes its own grant for a user (turns itself off). Self-scoped. */
10
10
+
/** An app removes its own grant for a user (turns itself off). Self-scoped.
11
11
+
* Delegates to `ops.revoke` so it also cascades routing/categories + fires the
12
12
+
* subscriberChanged callback. */
11
13
export function makeRevokeSelf(
12
14
app: AppContext,
13
15
): ProcedureConfig<PubAtmoNotifyRevokeSelf.mainSchema> {
···
18
20
write: true,
19
21
lxm: LXM,
20
22
});
21
21
-
await q.deleteGrant(app.env.DB, userDid, appDid);
23
23
+
await ops.revoke(app.env, userDid, { sender: appDid });
22
24
return json({ ok: true });
23
25
},
24
26
};
···
4
4
5
5
import { verifySenderRequest } from '../auth/sender';
6
6
import * as q from '../db/queries';
7
7
+
import { deliveryChannelFor, selectTargets } from '../delivery/channel';
8
8
+
import { withinChannelLimits } from '../delivery/limits';
7
9
import type { AppContext, DispatchJob } from '../env';
8
10
import { notAuthorized, rateLimited } from '../lib/errors';
9
11
import { newId } from '../lib/ids';
···
31
33
throw notAuthorized();
32
34
}
33
35
36
36
+
// 3. Rate limits (per recipient+sender pair) — checked before any write so a
37
37
+
// rate-limited send has no side effects (no inbox row, no category).
38
38
+
const perSecond = await checkAndIncrement(
39
39
+
app.env.CACHE,
40
40
+
`rl:send:1s:${senderDid}:${recipient}`,
41
41
+
PER_SECOND_LIMIT,
42
42
+
PER_SECOND_WINDOW,
43
43
+
);
44
44
+
if (!perSecond.allowed) {
45
45
+
throw rateLimited(perSecond.resetIn, 'Sending too fast');
46
46
+
}
47
47
+
const perDay = await checkAndIncrement(
48
48
+
app.env.CACHE,
49
49
+
`rl:send:1d:${senderDid}:${recipient}`,
50
50
+
PER_DAY_LIMIT,
51
51
+
PER_DAY_WINDOW,
52
52
+
);
53
53
+
if (!perDay.allowed) {
54
54
+
throw rateLimited(perDay.resetIn, 'Daily notification limit reached for this recipient');
55
55
+
}
56
56
+
34
57
// Register the category up front so it stays configurable in the routing UI
35
58
// even when this notification is dropped (route 'off').
36
59
if (input.category != null) {
···
54
77
route = (await q.getAppRoute(app.env.DB, recipient, senderDid))?.route;
55
78
}
56
79
if (route === undefined) {
57
57
-
route = (await q.getUser(app.env.DB, recipient))?.default_route ?? 'push';
80
80
+
route = (await q.getUser(app.env.DB, recipient))?.default_route ?? 'inbox';
58
81
}
59
82
60
83
// 'off' → dropped entirely: not recorded, not delivered.
···
81
104
return json({ id, delivered: 0 });
82
105
}
83
106
84
84
-
// 4. Rate limits (per recipient+sender pair) — only for actual alert delivery.
85
85
-
const perSecond = await checkAndIncrement(
86
86
-
app.env.CACHE,
87
87
-
`rl:send:1s:${senderDid}:${recipient}`,
88
88
-
PER_SECOND_LIMIT,
89
89
-
PER_SECOND_WINDOW,
90
90
-
);
91
91
-
if (!perSecond.allowed) {
92
92
-
throw rateLimited(perSecond.resetIn, 'Sending too fast');
93
93
-
}
94
94
-
const perDay = await checkAndIncrement(
95
95
-
app.env.CACHE,
96
96
-
`rl:send:1d:${senderDid}:${recipient}`,
97
97
-
PER_DAY_LIMIT,
98
98
-
PER_DAY_WINDOW,
107
107
+
// 4. Resolve the token set against the user's deliverable targets. A bare
108
108
+
// channel ('push') fires all its instances; 'push:<id>' fires just one.
109
109
+
const matched = selectTargets(
110
110
+
await q.listDeliveryTargets(app.env.DB, recipient),
111
111
+
routeSelection(route),
99
112
);
100
100
-
if (!perDay.allowed) {
101
101
-
throw rateLimited(perDay.resetIn, 'Daily notification limit reached for this recipient');
113
113
+
114
114
+
// 5. Apply per-channel daily caps (per-recipient + relay-global, e.g. email).
115
115
+
// A channel over its cap is skipped for this notification — it's still
116
116
+
// recorded in the inbox above; uncapped channels pass without touching the
117
117
+
// limiter. Sequential so the relay-global counter is consumed accurately.
118
118
+
const targets: typeof matched = [];
119
119
+
for (const target of matched) {
120
120
+
if (await withinChannelLimits(app.env, target.channel, recipient)) {
121
121
+
targets.push(target);
122
122
+
}
102
123
}
103
103
-
104
104
-
// 5. Resolve the token set against the user's deliverable targets. A bare
105
105
-
// channel ('push') fires all its instances; 'push:<id>' fires just one.
106
106
-
const selection = routeSelection(route);
107
107
-
const targets = (await q.listDeliveryTargets(app.env.DB, recipient))
108
108
-
.map(q.toDeliveryInstance)
109
109
-
.filter((t): t is q.DeliveryInstance => t !== null && t.verified)
110
110
-
.filter((t) => {
111
111
-
const sel = selection[t.channel];
112
112
-
return sel !== undefined && (sel.all || sel.ids.includes(t.id));
113
113
-
});
114
124
const deliveredCount = targets.length;
115
125
116
126
// No targets → accept but deliver to nobody.
···
121
131
122
132
// 5. Enqueue one dispatch job per matched target.
123
133
const jobs = targets.map((target) => ({
124
124
-
body: toNotificationJob(target, input, senderDid),
134
134
+
body: {
135
135
+
kind: 'notification' as const,
136
136
+
channel: deliveryChannelFor(target),
137
137
+
title: input.title,
138
138
+
body: input.body,
139
139
+
uri: input.uri,
140
140
+
senderDid,
141
141
+
} satisfies DispatchJob,
125
142
}));
126
143
await app.env.DISPATCH_QUEUE.sendBatch(jobs);
127
144
···
130
147
return json({ id, delivered: deliveredCount });
131
148
},
132
149
};
133
133
-
}
134
134
-
135
135
-
/** Build a notification dispatch job for one resolved delivery instance. */
136
136
-
function toNotificationJob(
137
137
-
target: q.DeliveryInstance,
138
138
-
input: { title: string; body: string; uri?: string },
139
139
-
senderDid: string,
140
140
-
): DispatchJob {
141
141
-
const common = {
142
142
-
kind: 'notification' as const,
143
143
-
title: input.title,
144
144
-
body: input.body,
145
145
-
uri: input.uri,
146
146
-
senderDid,
147
147
-
};
148
148
-
if (target.channel === 'push') {
149
149
-
return {
150
150
-
...common,
151
151
-
channel: {
152
152
-
platform: 'webpush',
153
153
-
endpoint: target.endpoint,
154
154
-
p256dh: target.p256dh,
155
155
-
auth: target.auth,
156
156
-
},
157
157
-
};
158
158
-
}
159
159
-
if (target.channel === 'telegram') {
160
160
-
return { ...common, channel: { platform: 'telegram', platformUserId: target.chatId } };
161
161
-
}
162
162
-
if (target.channel === 'email') {
163
163
-
return { ...common, channel: { platform: 'email', address: target.address } };
164
164
-
}
165
165
-
if (target.channel === 'dm') {
166
166
-
return { ...common, channel: { platform: 'dm', recipientDid: target.recipientDid } };
167
167
-
}
168
168
-
return { ...common, channel: { platform: 'webhook', url: target.url } };
169
150
}
170
151
171
152
function logDelivery(
···
1
1
+
import { isCategoryRoute, PubAtmoNotifySetCategories } from '@atmo/notifs-lexicons';
2
2
+
import { json, type ProcedureConfig } from '@atcute/xrpc-server';
3
3
+
4
4
+
import { verifyManagementCall } from '../auth/management';
5
5
+
import type { AppContext } from '../env';
6
6
+
import { invalidRequest } from '../lib/errors';
7
7
+
import * as ops from '../rpc/ops';
8
8
+
9
9
+
const LXM = 'pub.atmo.notify.setCategories';
10
10
+
11
11
+
/**
12
12
+
* Replace the calling app's category catalog for a user (full sync). Self-scoped
13
13
+
* management write (app JWT + a fresh user token). Only ever touches categories +
14
14
+
* routing keyed by (userDid, appDid).
15
15
+
*/
16
16
+
export function makeSetCategories(
17
17
+
app: AppContext,
18
18
+
): ProcedureConfig<PubAtmoNotifySetCategories.mainSchema> {
19
19
+
return {
20
20
+
handler: async ({ request, input }) => {
21
21
+
const { appDid, userDid } = await verifyManagementCall(app, request, input, {
22
22
+
scope: 'self',
23
23
+
write: true,
24
24
+
lxm: LXM,
25
25
+
});
26
26
+
27
27
+
for (const c of input.categories) {
28
28
+
if (c.route !== undefined && !isCategoryRoute(c.route)) {
29
29
+
throw invalidRequest('Invalid category route');
30
30
+
}
31
31
+
}
32
32
+
33
33
+
await ops.setCategories(
34
34
+
app.env,
35
35
+
userDid,
36
36
+
appDid,
37
37
+
input.categories.map((c) => ({
38
38
+
id: c.id,
39
39
+
title: c.title,
40
40
+
description: c.description,
41
41
+
route: c.route,
42
42
+
})),
43
43
+
);
44
44
+
return json({ ok: true });
45
45
+
},
46
46
+
};
47
47
+
}
···
1
1
-
import { isConcreteRoute, PubAtmoNotifySetRouting } from '@atmo/notifs-lexicons';
1
1
+
import { isAppRoute, isCategoryRoute, PubAtmoNotifySetRouting } from '@atmo/notifs-lexicons';
2
2
import { json, type ProcedureConfig } from '@atcute/xrpc-server';
3
3
4
4
import { verifyManagementCall } from '../auth/management';
5
5
-
import * as q from '../db/queries';
6
5
import type { AppContext } from '../env';
7
6
import { invalidRequest } from '../lib/errors';
7
7
+
import * as ops from '../rpc/ops';
8
8
9
9
const LXM = 'pub.atmo.notify.setRouting';
10
10
···
14
14
* A self-scoped management write (see MANAGEMENT-AUTH.md): the app is
15
15
* authenticated by its service-auth bearer and the user by the body `userToken`;
16
16
* `verifyManagementCall` enforces the (user, app) capability + self-write policy.
17
17
-
* Only ever touches routing rows keyed by (userDid, senderDid) — never the
18
18
-
* account default, other apps, or channels.
17
17
+
* Delegates to the same `ops.*` the binding uses (one implementation), always
18
18
+
* scoped to (userDid, senderDid) — never the account default, other apps, or channels.
19
19
*/
20
20
export function makeSetRouting(
21
21
app: AppContext,
···
28
28
lxm: LXM,
29
29
});
30
30
31
31
-
// The lexicon route fields are free strings; validate the channel-set format.
32
32
-
if (input.route !== undefined && input.route !== 'default' && !isConcreteRoute(input.route)) {
31
31
+
// The lexicon route fields are free strings; validate the token-set format.
32
32
+
if (input.route !== undefined && !isAppRoute(input.route)) {
33
33
throw invalidRequest('Invalid route');
34
34
}
35
35
for (const c of input.categories ?? []) {
36
36
-
if (c.route !== 'app' && !isConcreteRoute(c.route)) {
36
36
+
if (!isCategoryRoute(c.route)) {
37
37
throw invalidRequest('Invalid category route');
38
38
}
39
39
}
40
40
41
41
if (input.route !== undefined) {
42
42
-
if (input.route === 'default') {
43
43
-
await q.deleteAppRoute(app.env.DB, userDid, senderDid);
44
44
-
} else {
45
45
-
await q.upsertAppRoute(app.env.DB, userDid, senderDid, input.route);
46
46
-
}
42
42
+
await ops.setAppRouting(app.env, userDid, senderDid, input.route);
47
43
}
48
48
-
49
44
for (const c of input.categories ?? []) {
50
50
-
if (c.route === 'app') {
51
51
-
await q.deleteRouting(app.env.DB, userDid, senderDid, c.id);
52
52
-
} else {
53
53
-
await q.upsertRouting(app.env.DB, userDid, senderDid, c.id, c.route);
54
54
-
}
45
45
+
await ops.setRouting(app.env, userDid, senderDid, c.id, c.route);
55
46
}
56
47
57
48
return json({ ok: true });
···
182
182
expect(res.status).toBe(200);
183
183
const data = (await res.json()) as { route: string; defaultRoute: string; categories: unknown[] };
184
184
expect(data.route).toBe('default');
185
185
-
expect(data.defaultRoute).toBe('push');
185
185
+
expect(data.defaultRoute).toBe('inbox'); // new accounts start at inbox-only
186
186
expect(data.categories).toEqual([]);
187
187
});
188
188
···
1
1
+
import type { Did } from '@atcute/lexicons';
2
2
+
import { env } from 'cloudflare:test';
3
3
+
import { expect, it } from 'vitest';
4
4
+
5
5
+
import * as q from '../src/db/queries';
6
6
+
import * as ops from '../src/rpc/ops';
7
7
+
8
8
+
const APP = 'did:plc:catapp' as Did;
9
9
+
10
10
+
it('addCategory declares a category with title + an optional initial route', async () => {
11
11
+
const user = 'did:plc:cat-user1' as Did;
12
12
+
await ops.addCategory(env, user, APP, { id: 'wh_1', title: 'My Webhook', route: 'inbox' });
13
13
+
const { categories } = await ops.getCategories(env, user, APP);
14
14
+
expect(categories).toEqual([{ id: 'wh_1', title: 'My Webhook', route: 'inbox' }]);
15
15
+
});
16
16
+
17
17
+
it('addCategory without a route leaves it inheriting the app route', async () => {
18
18
+
const user = 'did:plc:cat-noroute' as Did;
19
19
+
await ops.addCategory(env, user, APP, { id: 'wh_x', title: 'X' });
20
20
+
expect((await ops.getCategories(env, user, APP)).categories[0]).toMatchObject({
21
21
+
id: 'wh_x',
22
22
+
route: 'app',
23
23
+
});
24
24
+
});
25
25
+
26
26
+
it('setCategories full-sync upserts the listed ones and prunes the rest (with routing)', async () => {
27
27
+
const user = 'did:plc:cat-sync' as Did;
28
28
+
await ops.setCategories(env, user, APP, [
29
29
+
{ id: 'a', title: 'A', route: 'push' },
30
30
+
{ id: 'b', title: 'B' },
31
31
+
]);
32
32
+
expect((await q.getRoutingRoute(env.DB, user, APP, 'a'))?.route).toBe('push');
33
33
+
34
34
+
// Re-sync to just [a]; b (and its routing) is pruned, a's route is preserved.
35
35
+
await ops.setCategories(env, user, APP, [{ id: 'a', title: 'A renamed' }]);
36
36
+
const { categories } = await ops.getCategories(env, user, APP);
37
37
+
expect(categories.map((c) => c.id)).toEqual(['a']);
38
38
+
expect(categories[0]?.title).toBe('A renamed');
39
39
+
expect(await q.getRoutingRoute(env.DB, user, APP, 'b')).toBeNull();
40
40
+
expect((await q.getRoutingRoute(env.DB, user, APP, 'a'))?.route).toBe('push');
41
41
+
});
42
42
+
43
43
+
it('removeCategory deletes the category and its routing', async () => {
44
44
+
const user = 'did:plc:cat-remove' as Did;
45
45
+
await ops.addCategory(env, user, APP, { id: 'gone', title: 'Gone', route: 'telegram' });
46
46
+
expect((await ops.removeCategory(env, user, APP, 'gone')).removed).toBe(true);
47
47
+
expect((await ops.getCategories(env, user, APP)).categories).toHaveLength(0);
48
48
+
expect(await q.getRoutingRoute(env.DB, user, APP, 'gone')).toBeNull();
49
49
+
});
50
50
+
51
51
+
it('categories are per-user: one user never sees another’s', async () => {
52
52
+
const userA = 'did:plc:cat-A' as Did;
53
53
+
const userB = 'did:plc:cat-B' as Did;
54
54
+
await ops.addCategory(env, userA, APP, { id: 'secret', title: 'A only' });
55
55
+
const { categories } = await ops.getCategories(env, userB, APP);
56
56
+
expect(categories.find((c) => c.id === 'secret')).toBeUndefined();
57
57
+
});
58
58
+
59
59
+
it('revoke cascades: the app’s routing + categories are removed', async () => {
60
60
+
const user = 'did:plc:cat-revoke' as Did;
61
61
+
await q.upsertGrant(env.DB, {
62
62
+
recipientDid: user,
63
63
+
senderDid: APP,
64
64
+
grantedAt: Date.now(),
65
65
+
title: null,
66
66
+
description: null,
67
67
+
iconUrl: null,
68
68
+
});
69
69
+
await ops.setAppRouting(env, user, APP, 'push');
70
70
+
await ops.addCategory(env, user, APP, { id: 'c', title: 'C', route: 'telegram' });
71
71
+
72
72
+
await ops.revoke(env, user, { sender: APP });
73
73
+
74
74
+
expect(await q.getAppRoute(env.DB, user, APP)).toBeNull();
75
75
+
expect(await q.getRoutingRoute(env.DB, user, APP, 'c')).toBeNull();
76
76
+
expect((await ops.getCategories(env, user, APP)).categories).toHaveLength(0);
77
77
+
});
78
78
+
79
79
+
it('getRouting surfaces the category title', async () => {
80
80
+
const user = 'did:plc:cat-routing' as Did;
81
81
+
await q.upsertGrant(env.DB, {
82
82
+
recipientDid: user,
83
83
+
senderDid: APP,
84
84
+
grantedAt: Date.now(),
85
85
+
title: null,
86
86
+
description: null,
87
87
+
iconUrl: null,
88
88
+
});
89
89
+
await ops.addCategory(env, user, APP, { id: 'wh', title: 'Webhook Name', route: 'push' });
90
90
+
91
91
+
const routing = await ops.getRouting(env, user);
92
92
+
const cat = routing.apps.find((a) => a.sender === APP)?.categories.find((c) => c.category === 'wh');
93
93
+
expect(cat).toMatchObject({ category: 'wh', title: 'Webhook Name', route: 'push' });
94
94
+
});
···
33
33
expect(grant?.title).toBe('Bookhive');
34
34
expect(grant?.description).toBe('New comments on your books');
35
35
expect(grant?.icon_url).toBe('https://bookhive.example/icon.png');
36
36
+
// New grants default to self-management ('self'), set explicitly (not via the
37
37
+
// column default) so it holds even on an older DB.
38
38
+
expect(grant?.manage).toBe('self');
39
39
+
});
40
40
+
41
41
+
it('a re-grant keeps the user-chosen manage capability', async () => {
42
42
+
const user: Did = 'did:plc:regrantmanage';
43
43
+
await q.upsertGrant(env.DB, {
44
44
+
recipientDid: user,
45
45
+
senderDid: SENDER,
46
46
+
grantedAt: Date.now(),
47
47
+
title: null,
48
48
+
description: null,
49
49
+
iconUrl: null,
50
50
+
});
51
51
+
// User downgrades to 'none'.
52
52
+
await q.setGrantManage(env.DB, user, SENDER, 'none');
53
53
+
// Re-granting (e.g. the app re-requests) must not silently restore 'self'.
54
54
+
await q.upsertGrant(env.DB, {
55
55
+
recipientDid: user,
56
56
+
senderDid: SENDER,
57
57
+
grantedAt: Date.now(),
58
58
+
title: null,
59
59
+
description: null,
60
60
+
iconUrl: null,
61
61
+
});
62
62
+
expect((await q.getGrant(env.DB, user, SENDER))?.manage).toBe('none');
36
63
});
37
64
38
65
it('revoke removes the grant', async () => {
···
1
1
+
import type { Did } from '@atcute/lexicons';
2
2
+
import { env } from 'cloudflare:test';
3
3
+
import { expect, it } from 'vitest';
4
4
+
5
5
+
import { channelLimit, withinChannelLimits } from '../src/delivery/limits';
6
6
+
7
7
+
it('email is capped; push/telegram/dm/webhook are not', () => {
8
8
+
const email = channelLimit(env, 'email');
9
9
+
expect(email?.perRecipientPerDay).toBeGreaterThan(0);
10
10
+
expect(email?.globalPerDay).toBeGreaterThan(0);
11
11
+
for (const c of ['push', 'telegram', 'dm', 'webhook'] as const) {
12
12
+
expect(channelLimit(env, c)).toBeUndefined();
13
13
+
}
14
14
+
});
15
15
+
16
16
+
it('uncapped channels always pass and never limit', async () => {
17
17
+
const did: Did = 'did:plc:limits-push';
18
18
+
for (let i = 0; i < 50; i++) {
19
19
+
expect(await withinChannelLimits(env, 'push', did)).toBe(true);
20
20
+
}
21
21
+
});
22
22
+
23
23
+
it('email caps per recipient per day, independently per recipient', async () => {
24
24
+
const a: Did = 'did:plc:limits-emailA';
25
25
+
const b: Did = 'did:plc:limits-emailB';
26
26
+
const perRecipient = channelLimit(env, 'email')!.perRecipientPerDay!;
27
27
+
// Isolate the per-recipient cap from the relay-global cap: raise the global so
28
28
+
// A exhausting its own budget can't also drain the shared global budget (which
29
29
+
// would block B for the wrong reason). Tests the per-recipient dimension only.
30
30
+
env.EMAIL_DAILY_GLOBAL = String(perRecipient * 100);
31
31
+
32
32
+
// A can receive up to the per-recipient cap…
33
33
+
for (let i = 0; i < perRecipient; i++) {
34
34
+
expect(await withinChannelLimits(env, 'email', a)).toBe(true);
35
35
+
}
36
36
+
// …the next email to A is blocked.
37
37
+
expect(await withinChannelLimits(env, 'email', a)).toBe(false);
38
38
+
39
39
+
// B has its own budget — unaffected by A hitting the cap.
40
40
+
expect(await withinChannelLimits(env, 'email', b)).toBe(true);
41
41
+
});
···
25
25
const GETROUTING = 'pub.atmo.notify.getRouting'; // self-read
26
26
const REVOKESELF = 'pub.atmo.notify.revokeSelf'; // self-write
27
27
const MUTESELF = 'pub.atmo.notify.muteSelf'; // self-write
28
28
+
const ADDCATEGORY = 'pub.atmo.notify.addCategory'; // self-write
29
29
+
const SETCATEGORIES = 'pub.atmo.notify.setCategories'; // self-write
30
30
+
const REMOVECATEGORY = 'pub.atmo.notify.removeCategory'; // self-write
31
31
+
const GETCATEGORIES = 'pub.atmo.notify.getCategories'; // self-read
32
32
+
33
33
+
interface CategoryList {
34
34
+
categories: { id: string; title?: string; route: string }[];
35
35
+
}
28
36
29
37
async function call(req: Request): Promise<Response> {
30
38
const ctx = createExecutionContext();
···
142
150
await designate(user.did, app.did, 'full');
143
151
const res = await dualCall(SETROUTING, app, user, { route: 'push' });
144
152
expect(res.status).toBe(200);
153
153
+
});
154
154
+
155
155
+
// --- category management (federated, self-scoped) --------------------------
156
156
+
157
157
+
it('addCategory is a write: denied undesignated, allowed once designated `self`', async () => {
158
158
+
const { app, user } = await plain('cat-fed-write');
159
159
+
const denied = await dualCall(ADDCATEGORY, app, user, { id: 'wh', title: 'WH' });
160
160
+
expect(denied.status).toBe(403);
161
161
+
162
162
+
await designate(user.did, app.did, 'self');
163
163
+
const ok = await dualCall(ADDCATEGORY, app, user, { id: 'wh', title: 'WH', route: 'inbox' });
164
164
+
expect(ok.status).toBe(200);
165
165
+
});
166
166
+
167
167
+
it('addCategory rejects an invalid route', async () => {
168
168
+
const { app, user } = await plain('cat-fed-badroute');
169
169
+
await designate(user.did, app.did, 'self');
170
170
+
const res = await dualCall(ADDCATEGORY, app, user, { id: 'wh', route: 'nope!' });
171
171
+
expect(res.status).toBe(400);
172
172
+
});
173
173
+
174
174
+
it('setCategories full-sync + getCategories over XRPC (only this app’s)', async () => {
175
175
+
const { app, user } = await plain('cat-fed-sync');
176
176
+
await designate(user.did, app.did, 'self');
177
177
+
178
178
+
await dualCall(SETCATEGORIES, app, user, {
179
179
+
categories: [
180
180
+
{ id: 'a', title: 'A' },
181
181
+
{ id: 'b', title: 'B', route: 'push' },
182
182
+
],
183
183
+
});
184
184
+
let data = (await (await dualCall(GETCATEGORIES, app, user)).json()) as CategoryList;
185
185
+
expect(data.categories.map((c) => c.id).sort()).toEqual(['a', 'b']);
186
186
+
187
187
+
await dualCall(SETCATEGORIES, app, user, { categories: [{ id: 'a', title: 'A2' }] });
188
188
+
data = (await (await dualCall(GETCATEGORIES, app, user)).json()) as CategoryList;
189
189
+
expect(data.categories.map((c) => c.id)).toEqual(['a']);
190
190
+
expect(data.categories[0]?.title).toBe('A2');
191
191
+
});
192
192
+
193
193
+
it('removeCategory over XRPC', async () => {
194
194
+
const { app, user } = await plain('cat-fed-remove');
195
195
+
await designate(user.did, app.did, 'self');
196
196
+
await dualCall(ADDCATEGORY, app, user, { id: 'gone', title: 'G' });
197
197
+
const res = await dualCall(REMOVECATEGORY, app, user, { id: 'gone' });
198
198
+
expect(res.status).toBe(200);
199
199
+
expect((await (await dualCall(GETCATEGORIES, app, user)).json()) as CategoryList).toEqual({
200
200
+
categories: [],
201
201
+
});
145
202
});
146
203
147
204
it('getRouting returns the target catalog with privacy-safe labels', async () => {
···
1
1
import { env } from 'cloudflare:test';
2
2
import { expect, it, vi } from 'vitest';
3
3
4
4
-
import { checkAndIncrement } from '../src/ratelimit';
4
4
+
import { checkAndIncrement, checkAndIncrementAll } from '../src/ratelimit';
5
5
6
6
it('allows requests under the limit and denies those over it', async () => {
7
7
const key = 'rl:test:underover';
···
36
36
vi.useRealTimers();
37
37
}
38
38
});
39
39
+
40
40
+
it('checkAndIncrementAll: empty checks are allowed', async () => {
41
41
+
expect((await checkAndIncrementAll(env.CACHE, [])).allowed).toBe(true);
42
42
+
});
43
43
+
44
44
+
it('checkAndIncrementAll allows up to the limit on a single key', async () => {
45
45
+
const checks = [{ key: 'rl:test:all:single', limit: 2, windowSeconds: 60 }];
46
46
+
expect((await checkAndIncrementAll(env.CACHE, checks)).allowed).toBe(true);
47
47
+
expect((await checkAndIncrementAll(env.CACHE, checks)).allowed).toBe(true);
48
48
+
expect((await checkAndIncrementAll(env.CACHE, checks)).allowed).toBe(false);
49
49
+
});
50
50
+
51
51
+
it('checkAndIncrementAll denies when ANY key is over — without consuming the others', async () => {
52
52
+
const a = 'rl:test:all:a';
53
53
+
const b = 'rl:test:all:b';
54
54
+
const checks = [
55
55
+
{ key: a, limit: 1, windowSeconds: 60 },
56
56
+
{ key: b, limit: 5, windowSeconds: 60 },
57
57
+
];
58
58
+
59
59
+
// First call: both under → allowed, both incremented (a=1, b=1).
60
60
+
expect((await checkAndIncrementAll(env.CACHE, checks)).allowed).toBe(true);
61
61
+
// Second: a is now at its limit → denied, and b must NOT be incremented.
62
62
+
expect((await checkAndIncrementAll(env.CACHE, checks)).allowed).toBe(false);
63
63
+
64
64
+
// Prove b is still at 1 (not 2): exactly 4 more single-key increments fit under 5.
65
65
+
for (let i = 0; i < 4; i++) {
66
66
+
expect((await checkAndIncrement(env.CACHE, b, 5, 60)).allowed).toBe(true);
67
67
+
}
68
68
+
expect((await checkAndIncrement(env.CACHE, b, 5, 60)).allowed).toBe(false);
69
69
+
});
···
1
1
+
import { isAppRoute, isCategoryRoute, isConcreteRoute } from '@atmo/notifs-lexicons';
2
2
+
import { expect, it } from 'vitest';
3
3
+
4
4
+
import { newTargetId } from '../src/lib/ids';
5
5
+
6
6
+
it('accepts channel sets, off, and inbox', () => {
7
7
+
for (const r of ['push', 'push+telegram', 'email+dm+webhook', 'off', 'inbox']) {
8
8
+
expect(isConcreteRoute(r)).toBe(true);
9
9
+
}
10
10
+
});
11
11
+
12
12
+
it('accepts instance-scoped tokens whose ids use nanoid chars (incl. _ and -)', () => {
13
13
+
// Regression: target ids are nanoids (A–Z a–z 0–9 _ -). The validator used to
14
14
+
// reject ids containing _ or -, silently dropping per-instance routes.
15
15
+
expect(isConcreteRoute('dm:qvHICr-uJhd5')).toBe(true);
16
16
+
expect(isConcreteRoute('push:abc_DE-12')).toBe(true);
17
17
+
expect(isConcreteRoute('push:a1b2c3+telegram')).toBe(true);
18
18
+
19
19
+
// Any freshly-generated target id must validate as an instance token.
20
20
+
for (let i = 0; i < 50; i++) {
21
21
+
expect(isConcreteRoute(`push:${newTargetId()}`)).toBe(true);
22
22
+
}
23
23
+
});
24
24
+
25
25
+
it('rejects malformed routes and the inherit sentinels', () => {
26
26
+
expect(isConcreteRoute('default')).toBe(false);
27
27
+
expect(isConcreteRoute('app')).toBe(false);
28
28
+
expect(isConcreteRoute('push+push')).toBe(false); // duplicate token
29
29
+
expect(isConcreteRoute('push:bad/id')).toBe(false); // illegal id char
30
30
+
});
31
31
+
32
32
+
it('app/category routes also accept their inherit sentinel', () => {
33
33
+
expect(isAppRoute('default')).toBe(true);
34
34
+
expect(isAppRoute('dm:qvHICr-uJhd5')).toBe(true);
35
35
+
expect(isCategoryRoute('app')).toBe(true);
36
36
+
expect(isCategoryRoute('dm:qvHICr-uJhd5')).toBe(true);
37
37
+
});
···
30
30
const user = 'did:plc:routing1' as Did;
31
31
await grantWithCategory(user);
32
32
const cfg = await ops.getRouting(env, user);
33
33
-
expect(cfg.defaultRoute).toBe('push');
33
33
+
expect(cfg.defaultRoute).toBe('inbox'); // new accounts start at inbox-only
34
34
+
34
35
const app = cfg.apps.find((a) => a.sender === SENDER);
35
36
expect(app?.route).toBe('default');
36
37
expect(app?.categories[0]?.route).toBe('app');
···
10
10
[[d1_databases]]
11
11
binding = "DB"
12
12
database_name = "notifs-relay"
13
13
-
database_id = "c83e2e03-0081-4fc4-b25f-6897a614e169"
13
13
+
database_id = "53f56b8c-d25e-468a-9d02-e8575bf8964f"
14
14
migrations_dir = "migrations"
15
15
16
16
[[kv_namespaces]]
···
44
44
VAPID_SUBJECT = "https://atmo.pub"
45
45
# Email via comail (https://comail.at). COMAIL_DID = the account DID for the
46
46
# X-Atmos-DID header; COMAIL_FROM = an enrolled sender address.
47
47
-
COMAIL_DID = "did:web:relay.atmo.pub"
47
47
+
COMAIL_DID = "did:plc:jf3acz4zktzkaptswh5so43u"
48
48
COMAIL_FROM = "atmo.pub <notify@atmo.pub>"
49
49
+
# Daily email caps (rolling 24h). Per-recipient = anti-spam/cost; global = keep
50
50
+
# under the comail plan. Omit to use the defaults (10 / 100). See delivery/limits.ts.
51
51
+
EMAIL_DAILY_PER_RECIPIENT = "10"
52
52
+
EMAIL_DAILY_GLOBAL = "10"
53
53
+
# Bluesky DM via a bot account. BLUESKY_DM_IDENTIFIER = the bot's handle or DID
54
54
+
# (used for createSession); BLUESKY_DM_SERVICE = its PDS (defaults to
55
55
+
# https://bsky.social if omitted). The app password is the BLUESKY_DM_APP_PASSWORD secret.
56
56
+
BLUESKY_DM_IDENTIFIER = "did:plc:jf3acz4zktzkaptswh5so43u"
57
57
+
BLUESKY_DM_SERVICE = "https://bsky.social"
49
58
50
59
# Secrets set via `wrangler secret put`:
51
60
# - TELEGRAM_BOT_TOKEN
52
61
# - TELEGRAM_WEBHOOK_SECRET
53
53
-
# - VAPID_PRIVATE_JWK (the private JWK from `pnpm vapid:keygen`)
54
54
-
# - RELAY_PRIVATE_KEY (the private multikey from `pnpm relay:keygen`)
55
55
-
# - COMAIL_API_KEY (atmos_… from comail.at)
62
62
+
# - VAPID_PRIVATE_JWK (the private JWK from `pnpm vapid:keygen`)
63
63
+
# - RELAY_PRIVATE_KEY (the private multikey from `pnpm relay:keygen`)
64
64
+
# - COMAIL_API_KEY (atmos_… from comail.at)
65
65
+
# - BLUESKY_DM_APP_PASSWORD (an app password from the bot's Bluesky account)
···
15
15
value,
16
16
inherit,
17
17
instances,
18
18
+
allowInbox = true,
18
19
disabled = false,
19
20
onchange
20
21
}: {
···
24
25
inherit?: { token: 'default' | 'app'; label: string };
25
26
/** Routable instances per channel, listed as targets under each channel. */
26
27
instances?: RouteInstances;
28
28
+
/** Offer the "Inbox only" mode (off for contexts like pending requests). */
29
29
+
allowInbox?: boolean;
27
30
disabled?: boolean;
28
31
onchange: (route: string) => void;
29
32
} = $props();
33
33
+
34
34
+
// When inbox-only isn't offered, an empty custom selection collapses to 'off'.
35
35
+
const emptyValue = $derived(allowInbox ? 'inbox' : 'off');
30
36
31
37
// Local: did the user explicitly open the Custom tree? An empty custom selection
32
38
// stores 'inbox', so without this the tree would collapse back to "Inbox only".
···
61
67
}
62
68
function pickCustom() {
63
69
customOpen = true;
64
64
-
// Entering Custom from a non-custom value starts empty (= inbox only) until a
65
65
-
// box is checked.
66
66
-
if (!hasChannels) onchange('inbox');
70
70
+
// Entering Custom from a non-custom value starts empty until a box is checked.
71
71
+
if (!hasChannels) onchange(emptyValue);
67
72
}
68
73
69
74
function instancesFor(c: Channel): { id: string; label: string }[] {
···
99
104
else for (const id of s) tokens.push({ channel: c, instance: id });
100
105
}
101
106
const route = formatRoute(tokens);
102
102
-
onchange(route === 'off' ? 'inbox' : route);
107
107
+
onchange(route === 'off' ? emptyValue : route);
103
108
}
104
109
105
110
function parentState(c: Channel): 'on' | 'partial' | 'off' {
···
175
180
{inherit.label}
176
181
</button>
177
182
{/if}
178
178
-
<button
179
179
-
type="button"
180
180
-
{disabled}
181
181
-
onclick={pickInbox}
182
182
-
aria-pressed={mode === 'inbox'}
183
183
-
class="{modeBtn} {mode === 'inbox' ? on : off}"
184
184
-
>
185
185
-
Inbox only
186
186
-
</button>
183
183
+
{#if allowInbox}
184
184
+
<button
185
185
+
type="button"
186
186
+
{disabled}
187
187
+
onclick={pickInbox}
188
188
+
aria-pressed={mode === 'inbox'}
189
189
+
class="{modeBtn} {mode === 'inbox' ? on : off}"
190
190
+
>
191
191
+
Inbox only
192
192
+
</button>
193
193
+
{/if}
187
194
<button
188
195
type="button"
189
196
{disabled}
···
210
217
{#if available.length === 0}
211
218
<p class="px-1 py-0.5 text-xs text-muted">
212
219
No channels connected.
213
213
-
<a href="/settings?tab=channels" class="text-accent hover:underline">Add one in settings</a>.
220
220
+
<a href="/settings?tab=channels" class="text-accent hover:underline"
221
221
+
>Add one in settings</a
222
222
+
>.
214
223
</p>
215
224
{:else}
216
225
<div class="flex flex-col gap-0.5">
···
1
1
-
<script lang="ts">
2
2
-
import { onMount } from 'svelte';
3
3
-
4
4
-
type Theme = 'system' | 'light' | 'dark';
5
5
-
6
6
-
const order: Theme[] = ['system', 'light', 'dark'];
7
7
-
const labels: Record<Theme, string> = { system: 'System', light: 'Light', dark: 'Dark' };
8
8
-
9
9
-
let theme = $state<Theme>('system');
10
10
-
11
11
-
onMount(() => {
12
12
-
const stored = localStorage.getItem('theme');
13
13
-
theme = stored === 'light' || stored === 'dark' ? stored : 'system';
14
14
-
});
15
15
-
16
16
-
function cycle() {
17
17
-
const next = order[(order.indexOf(theme) + 1) % order.length] ?? 'system';
18
18
-
theme = next;
19
19
-
if (next === 'system') {
20
20
-
localStorage.removeItem('theme');
21
21
-
delete document.documentElement.dataset.theme;
22
22
-
} else {
23
23
-
localStorage.setItem('theme', next);
24
24
-
document.documentElement.dataset.theme = next;
25
25
-
}
26
26
-
}
27
27
-
</script>
28
28
-
29
29
-
<button
30
30
-
type="button"
31
31
-
onclick={cycle}
32
32
-
title={`Theme: ${labels[theme]}`}
33
33
-
aria-label={`Theme: ${labels[theme]}. Click to change.`}
34
34
-
class="grid size-9 place-items-center rounded-md text-muted transition-colors hover:bg-surface-2 hover:text-fg"
35
35
-
>
36
36
-
{#if theme === 'system'}
37
37
-
<svg
38
38
-
width="16"
39
39
-
height="16"
40
40
-
viewBox="0 0 24 24"
41
41
-
fill="none"
42
42
-
stroke="currentColor"
43
43
-
stroke-width="2"
44
44
-
stroke-linecap="round"
45
45
-
stroke-linejoin="round"
46
46
-
aria-hidden="true"
47
47
-
>
48
48
-
<rect x="2" y="3" width="20" height="14" rx="2" />
49
49
-
<path d="M8 21h8M12 17v4" />
50
50
-
</svg>
51
51
-
{:else if theme === 'light'}
52
52
-
<svg
53
53
-
width="16"
54
54
-
height="16"
55
55
-
viewBox="0 0 24 24"
56
56
-
fill="none"
57
57
-
stroke="currentColor"
58
58
-
stroke-width="2"
59
59
-
stroke-linecap="round"
60
60
-
stroke-linejoin="round"
61
61
-
aria-hidden="true"
62
62
-
>
63
63
-
<circle cx="12" cy="12" r="4" />
64
64
-
<path
65
65
-
d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"
66
66
-
/>
67
67
-
</svg>
68
68
-
{:else}
69
69
-
<svg
70
70
-
width="16"
71
71
-
height="16"
72
72
-
viewBox="0 0 24 24"
73
73
-
fill="none"
74
74
-
stroke="currentColor"
75
75
-
stroke-width="2"
76
76
-
stroke-linecap="round"
77
77
-
stroke-linejoin="round"
78
78
-
aria-hidden="true"
79
79
-
>
80
80
-
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
81
81
-
</svg>
82
82
-
{/if}
83
83
-
</button>
···
2
2
// `load` functions; after a command runs, the client calls `invalidateAll()` to
3
3
// refresh the page data.
4
4
import type { Did } from '@atcute/lexicons';
5
5
-
import { isConcreteRoute } from '@atmo/notifs-lexicons';
5
5
+
import { isAppRoute, isCategoryRoute, isConcreteRoute } from '@atmo/notifs-lexicons';
6
6
import { command, getRequestEvent } from '$app/server';
7
7
import { error } from '@sveltejs/kit';
8
8
import * as v from 'valibot';
9
9
10
10
+
import { emailEnabledFor } from '$lib/server/featureAccess';
10
11
import { relayFor } from '$lib/server/relay';
11
12
12
13
/** Resolve the relay bound to the signed-in user, or 401. */
···
42
43
}
43
44
);
44
45
45
45
-
export const setNotifyPending = command(v.object({ value: v.boolean() }), async ({ value }) => {
46
46
-
await requireRelay().updateSettings({ notifyPendingViaTelegram: value });
47
47
-
});
48
48
-
49
46
const optionalLabel = v.optional(v.pipe(v.string(), v.trim(), v.maxLength(64)));
50
47
51
48
/** Returns the Telegram deep link; the client navigates to it. Optional `label`
···
61
58
export const linkEmail = command(
62
59
v.object({ address: v.pipe(v.string(), v.email()), label: optionalLabel }),
63
60
async ({ address, label }) => {
61
61
+
// Email is allowlisted; reject non-whitelisted DIDs (the UI is hidden for them
62
62
+
// too, so this only fires on a crafted request).
63
63
+
const { locals } = getRequestEvent();
64
64
+
if (!emailEnabledFor(locals.did)) error(403, 'Email is not available for your account');
64
65
await requireRelay().linkEmail(address, label);
65
66
}
66
67
);
···
131
132
132
133
// A route is a `+`-joined channel set or 'off'; app/category add an inherit sentinel.
133
134
const concreteRoute = v.pipe(v.string(), v.check(isConcreteRoute, 'Invalid route'));
134
134
-
const appRoute = v.pipe(
135
135
-
v.string(),
136
136
-
v.check((s) => s === 'default' || isConcreteRoute(s), 'Invalid route')
137
137
-
);
138
138
-
const categoryRoute = v.pipe(
139
139
-
v.string(),
140
140
-
v.check((s) => s === 'app' || isConcreteRoute(s), 'Invalid route')
141
141
-
);
135
135
+
const appRoute = v.pipe(v.string(), v.check(isAppRoute, 'Invalid route'));
136
136
+
const categoryRoute = v.pipe(v.string(), v.check(isCategoryRoute, 'Invalid route'));
142
137
143
138
export const setDefaultRoute = command(v.object({ route: concreteRoute }), async ({ route }) => {
144
139
await requireRelay().setDefaultRoute(route);
140
140
+
});
141
141
+
142
142
+
/** Where permission-request alerts are sent (a concrete route, or 'off'). */
143
143
+
export const setPendingRoute = command(v.object({ route: concreteRoute }), async ({ route }) => {
144
144
+
await requireRelay().updateSettings({ pendingRoute: route });
145
145
});
146
146
147
147
export const setRouting = command(
···
1
1
+
// Server-only feature gating by DID allowlist. Email delivery is limited to these
2
2
+
// users for now: everyone else doesn't see the Email section in Settings and can't
3
3
+
// link an address (the `linkEmail` command rejects them too). Add DIDs here to
4
4
+
// grant access. Kept server-side so the list never ships to the browser.
5
5
+
const EMAIL_WHITELIST = new Set<string>([
6
6
+
// 'did:plc:257wekqxg4hyapkq6k47igmp',
7
7
+
]);
8
8
+
9
9
+
/** True if `did` may use the email delivery channel. */
10
10
+
export function emailEnabledFor(did: string | null | undefined): boolean {
11
11
+
return did != null && EMAIL_WHITELIST.has(did);
12
12
+
}
···
4
4
import { page } from '$app/state';
5
5
import { oauthLogout } from '$lib/atproto/oauth.remote';
6
6
import Icon from '$lib/components/Icon.svelte';
7
7
-
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
8
7
import Wordmark from '$lib/components/Wordmark.svelte';
9
8
import { DOCS_URL } from '$lib/config';
10
9
import type { LayoutData } from './$types';
···
44
43
45
44
<div class="md:grid md:h-screen md:grid-cols-[15.5rem_1fr]">
46
45
<!-- Desktop sidebar -->
47
47
-
<aside
48
48
-
class="sticky top-0 hidden h-screen flex-col gap-1 border-r border-line bg-bg p-3 md:flex"
49
49
-
>
46
46
+
<aside class="sticky top-0 hidden h-screen flex-col gap-1 border-r border-line bg-bg p-3 md:flex">
50
47
<div class="px-2 pt-2 pb-4">
51
48
<Wordmark size={15} />
52
49
</div>
···
81
78
<span>Developer docs</span>
82
79
</a>
83
80
84
84
-
<!-- Account / theme / sign out -->
81
81
+
<!-- Account / sign out -->
85
82
<div class="mt-1 flex flex-col gap-2 border-t border-line pt-3">
86
83
<div class="px-2" title={data.did}>
87
84
{#if data.handle}
···
90
87
<div class="truncate font-mono text-xs text-muted-2">{shortDid}</div>
91
88
{/if}
92
89
</div>
93
93
-
<div class="flex items-center gap-2">
94
94
-
<ThemeToggle />
95
95
-
<button
96
96
-
type="button"
97
97
-
onclick={signOut}
98
98
-
disabled={signingOut}
99
99
-
class="flex flex-1 items-center justify-start gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg disabled:opacity-50"
100
100
-
>
101
101
-
<Icon name="logout" size={16} />
102
102
-
<span>{signingOut ? 'Signing out…' : 'Sign out'}</span>
103
103
-
</button>
104
104
-
</div>
90
90
+
<button
91
91
+
type="button"
92
92
+
onclick={signOut}
93
93
+
disabled={signingOut}
94
94
+
class="flex items-center justify-start gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg disabled:opacity-50"
95
95
+
>
96
96
+
<Icon name="logout" size={16} />
97
97
+
<span>{signingOut ? 'Signing out…' : 'Sign out'}</span>
98
98
+
</button>
105
99
</div>
106
100
</aside>
107
101
···
109
103
pinned and only the content scrolls), the grid's 2nd cell on desktop. -->
110
104
<div class="flex h-dvh min-w-0 flex-col md:h-screen">
111
105
<!-- Mobile top bar -->
112
112
-
<header
113
113
-
class="flex h-14 shrink-0 items-center justify-between border-b border-line bg-bg px-4 md:hidden"
114
114
-
>
106
106
+
<header class="flex h-14 shrink-0 items-center border-b border-line bg-bg px-4 md:hidden">
115
107
<Wordmark size={15} />
116
116
-
<ThemeToggle />
117
108
</header>
118
109
119
110
<main bind:this={mainEl} class="min-h-0 flex-1 overflow-y-auto">
···
122
113
123
114
<!-- Mobile bottom tab bar — in-flow, so it always sits at the column's
124
115
bottom (no `fixed`, which is unreliable with iOS scrolling). -->
125
125
-
<nav
126
126
-
class="flex shrink-0 justify-around border-t border-line bg-bg pt-2 pb-6 md:hidden"
127
127
-
>
116
116
+
<nav class="flex shrink-0 justify-around border-t border-line bg-bg pt-2 pb-6 md:hidden">
128
117
{#each nav as item (item.id)}
129
118
{@const isActive = active === item.id}
130
119
<a
···
14
14
15
15
return {
16
16
app,
17
17
-
defaultRoute: routing?.defaultRoute ?? 'push',
17
17
+
defaultRoute: routing?.defaultRoute ?? 'inbox',
18
18
channels: routing?.channels ?? emptyRouteInstances()
19
19
};
20
20
};
···
76
76
});
77
77
}
78
78
79
79
+
// Temporarily hidden: the per-app management-access control. The capability
80
80
+
// still exists end-to-end (new grants default to 'self'); we just don't expose
81
81
+
// the picker yet. Flip to `true` to bring the section back.
82
82
+
const SHOW_MANAGEMENT_ACCESS = false;
83
83
+
79
84
const CAPABILITIES: Capability[] = ['none', 'self', 'full'];
80
85
const CAP_LABELS: Record<Capability, string> = {
81
86
none: 'No access',
···
183
188
: ''}"
184
189
>
185
190
<div class="min-w-0">
186
186
-
<div class="text-sm font-semibold text-fg">{c.category}</div>
191
191
+
<div class="text-sm font-semibold text-fg">{c.title ?? c.category}</div>
192
192
+
{#if c.title}
193
193
+
<div class="truncate font-mono text-[0.65rem] text-muted-2">{c.category}</div>
194
194
+
{/if}
187
195
{#if c.description}<div class="text-xs text-muted">{c.description}</div>{/if}
188
196
</div>
189
197
<ChannelRoutePicker
···
199
207
{/if}
200
208
</section>
201
209
202
202
-
<!-- Management access (capability designation; see MANAGEMENT-AUTH.md) -->
203
203
-
<section class="mt-8 max-w-2xl">
204
204
-
<h2 class="mb-3 font-mono text-[0.7rem] tracking-wide text-muted-2 uppercase">
205
205
-
Management access
206
206
-
</h2>
207
207
-
<div class="rounded-card border border-line bg-surface p-4">
208
208
-
<div class="flex items-center justify-between gap-4">
209
209
-
<div class="min-w-0">
210
210
-
<div class="text-sm font-medium text-fg">Let this app change your settings</div>
211
211
-
<p class="mt-1 text-xs text-muted">
212
212
-
<span class="font-medium text-fg">Manage its own settings</span> lets the app adjust
213
213
-
how
214
214
-
<em>its</em> notifications reach you, from inside the app.
215
215
-
<span class="font-medium text-fg">Manage your whole account</span> lets it act as a full
216
216
-
dashboard — every app, channel and setting. Grant full access only to apps you trust.
217
217
-
</p>
210
210
+
<!-- Management access (capability designation; see MANAGEMENT-AUTH.md).
211
211
+
Temporarily hidden via SHOW_MANAGEMENT_ACCESS; backend still works. -->
212
212
+
{#if SHOW_MANAGEMENT_ACCESS}
213
213
+
<section class="mt-8 max-w-2xl">
214
214
+
<h2 class="mb-3 font-mono text-[0.7rem] tracking-wide text-muted-2 uppercase">
215
215
+
Management access
216
216
+
</h2>
217
217
+
<div class="rounded-card border border-line bg-surface p-4">
218
218
+
<div class="flex items-center justify-between gap-4">
219
219
+
<div class="min-w-0">
220
220
+
<div class="text-sm font-medium text-fg">Let this app change your settings</div>
221
221
+
<p class="mt-1 text-xs text-muted">
222
222
+
<span class="font-medium text-fg">Manage its own settings</span> lets the app adjust
223
223
+
how
224
224
+
<em>its</em> notifications reach you, from inside the app.
225
225
+
<span class="font-medium text-fg">Manage your whole account</span> lets it act as a full
226
226
+
dashboard — every app, channel and setting. Grant full access only to apps you trust.
227
227
+
</p>
228
228
+
</div>
229
229
+
<select
230
230
+
class={selectClass}
231
231
+
value={data.app.manage}
232
232
+
disabled={busy['__manage']}
233
233
+
onchange={(e) => changeManage(e.currentTarget.value as Capability)}
234
234
+
>
235
235
+
{#each CAPABILITIES as c (c)}
236
236
+
<option value={c}>{CAP_LABELS[c]}</option>
237
237
+
{/each}
238
238
+
</select>
218
239
</div>
219
219
-
<select
220
220
-
class={selectClass}
221
221
-
value={data.app.manage}
222
222
-
disabled={busy['__manage']}
223
223
-
onchange={(e) => changeManage(e.currentTarget.value as Capability)}
224
224
-
>
225
225
-
{#each CAPABILITIES as c (c)}
226
226
-
<option value={c}>{CAP_LABELS[c]}</option>
227
227
-
{/each}
228
228
-
</select>
229
240
</div>
230
230
-
</div>
231
231
-
</section>
241
241
+
</section>
242
242
+
{/if}
232
243
233
244
<!-- Notifications -->
234
245
<section class="mt-8 max-w-2xl">
···
1
1
import { emptyRouteInstances } from '@atmo/notifs-lexicons';
2
2
3
3
+
import { emailEnabledFor } from '$lib/server/featureAccess';
3
4
import { relayFor } from '$lib/server/relay';
4
5
5
6
import type { PageServerLoad } from './$types';
···
23
24
emails: all.filter((t) => t.channel === 'email'),
24
25
dms: all.filter((t) => t.channel === 'dm'),
25
26
webhooks: all.filter((t) => t.channel === 'webhook'),
26
26
-
notifyPendingViaTelegram: settings?.notifyPendingViaTelegram ?? false,
27
27
+
// Email is allowlisted for now (hidden + blocked for everyone else).
28
28
+
emailEnabled: emailEnabledFor(locals.did),
29
29
+
pendingRoute: settings?.pendingRoute ?? 'off',
27
30
autoAllow: settings?.autoAllow ?? 'trusted',
28
28
-
defaultRoute: routing?.defaultRoute ?? 'push',
31
31
+
defaultRoute: routing?.defaultRoute ?? 'inbox',
29
32
// Catalog of routable instances per channel, for the routing picker.
30
33
channels: routing?.channels ?? emptyRouteInstances()
31
34
};
···
1
1
<script lang="ts">
2
2
-
import { onMount } from 'svelte';
3
2
import { page } from '$app/state';
4
4
-
import { invalidateAll } from '$app/navigation';
5
5
-
import ChannelRoutePicker from '$lib/components/ChannelRoutePicker.svelte';
6
6
-
import Icon from '$lib/components/Icon.svelte';
7
7
-
import IOSToggle from '$lib/components/IOSToggle.svelte';
8
8
-
import RelativeTime from '$lib/components/RelativeTime.svelte';
9
9
-
import RouteChip from '$lib/components/RouteChip.svelte';
10
10
-
import { currentSubscription, pushSupported, subscribe, unsubscribe } from '$lib/push';
11
11
-
import {
12
12
-
addWebhook,
13
13
-
enableDM,
14
14
-
linkEmail,
15
15
-
linkTelegram,
16
16
-
registerPush,
17
17
-
removeTarget,
18
18
-
renameTarget,
19
19
-
setAutoAllow,
20
20
-
setDefaultRoute,
21
21
-
setNotifyPending,
22
22
-
unregisterPush,
23
23
-
verifyEmail
24
24
-
} from '$lib/remote/notifs.remote';
25
25
-
import { DOCS_URL } from '$lib/config';
3
3
+
import AppearanceTab from './AppearanceTab.svelte';
4
4
+
import ChannelsTab from './ChannelsTab.svelte';
5
5
+
import DeveloperTab from './DeveloperTab.svelte';
6
6
+
import RequestsTab from './RequestsTab.svelte';
7
7
+
import RoutingTab from './RoutingTab.svelte';
26
8
import type { PageData } from './$types';
27
9
28
10
let { data }: { data: PageData } = $props();
29
29
-
30
30
-
let busy = $state<Record<string, boolean>>({});
31
31
-
let errorMsg = $state('');
32
11
33
12
const TABS = [
34
13
{ id: 'channels', label: 'Channels' },
35
14
{ id: 'routing', label: 'Routing' },
36
36
-
{ id: 'auto-allow', label: 'Auto-allow' },
15
15
+
{ id: 'requests', label: 'Requests' },
37
16
{ id: 'appearance', label: 'Appearance' },
38
17
{ id: 'developer', label: 'Developer docs' }
39
18
] as const;
40
19
const initialTab = page.url.searchParams.get('tab') ?? 'channels';
41
20
let tab = $state(TABS.some((t) => t.id === initialTab) ? initialTab : 'channels');
42
42
-
43
43
-
const autoAllowOptions = [
44
44
-
{ value: 'all', label: 'All apps', desc: 'Any app that asks is approved automatically.' },
45
45
-
{
46
46
-
value: 'trusted',
47
47
-
label: 'Trusted apps only',
48
48
-
desc: 'A small built-in allowlist is auto-approved; everything else waits for you.'
49
49
-
},
50
50
-
{ value: 'none', label: 'No apps', desc: 'Every request waits for your approval in Apps.' }
51
51
-
] as const;
52
52
-
53
53
-
type Theme = 'system' | 'light' | 'dark';
54
54
-
let theme = $state<Theme>('system');
55
55
-
const themeOptions = [
56
56
-
{ value: 'system', label: 'System', desc: 'Match your device appearance.' },
57
57
-
{ value: 'light', label: 'Light', desc: 'Always use the light theme.' },
58
58
-
{ value: 'dark', label: 'Dark', desc: 'Always use the dark theme.' }
59
59
-
] as const;
60
60
-
61
61
-
function setTheme(value: Theme) {
62
62
-
theme = value;
63
63
-
if (value === 'system') {
64
64
-
localStorage.removeItem('theme');
65
65
-
delete document.documentElement.dataset.theme;
66
66
-
} else {
67
67
-
localStorage.setItem('theme', value);
68
68
-
document.documentElement.dataset.theme = value;
69
69
-
}
70
70
-
}
71
71
-
72
72
-
/** Run a mutation, refresh page data, and surface errors. */
73
73
-
async function run(key: string, fn: () => Promise<unknown>) {
74
74
-
busy[key] = true;
75
75
-
errorMsg = '';
76
76
-
try {
77
77
-
await fn();
78
78
-
await invalidateAll();
79
79
-
} catch (err) {
80
80
-
errorMsg = err instanceof Error ? err.message : 'Something went wrong';
81
81
-
} finally {
82
82
-
busy[key] = false;
83
83
-
}
84
84
-
}
85
85
-
86
86
-
// Rename any delivery target (device / telegram chat / email) inline.
87
87
-
let renaming = $state<string | null>(null);
88
88
-
let renameLabel = $state('');
89
89
-
function startRename(id: string, label: string) {
90
90
-
renaming = id;
91
91
-
renameLabel = label;
92
92
-
}
93
93
-
async function saveRename(id: string) {
94
94
-
const label = renameLabel.trim();
95
95
-
if (!label) {
96
96
-
renaming = null;
97
97
-
return;
98
98
-
}
99
99
-
await run(`rename:${id}`, () => renameTarget({ id, label }));
100
100
-
renaming = null;
101
101
-
}
102
102
-
103
103
-
// Email: add a new address (+ optional name) + verify pending ones.
104
104
-
let emailInput = $state('');
105
105
-
let emailLabel = $state('');
106
106
-
let codeInputs = $state<Record<string, string>>({});
107
107
-
108
108
-
function submitEmail() {
109
109
-
const address = emailInput.trim();
110
110
-
const label = emailLabel.trim() || undefined;
111
111
-
if (address)
112
112
-
run('email', async () => {
113
113
-
await linkEmail({ address, label });
114
114
-
emailInput = '';
115
115
-
emailLabel = '';
116
116
-
});
117
117
-
}
118
118
-
119
119
-
function submitCode(emailId: string) {
120
120
-
const code = (codeInputs[emailId] ?? '').trim();
121
121
-
if (!/^\d{6}$/.test(code)) return;
122
122
-
run(`email-verify:${emailId}`, async () => {
123
123
-
const { verified } = await verifyEmail({ code });
124
124
-
if (!verified) throw new Error('That code is invalid or expired.');
125
125
-
codeInputs[emailId] = '';
126
126
-
});
127
127
-
}
128
128
-
129
129
-
// Webhook: enter a URL + label; the relay POSTs notifications to it.
130
130
-
let webhookUrl = $state('');
131
131
-
let webhookLabel = $state('');
132
132
-
133
133
-
function submitWebhook() {
134
134
-
const url = webhookUrl.trim();
135
135
-
const label = webhookLabel.trim();
136
136
-
if (!url || !label) return;
137
137
-
run('webhook', async () => {
138
138
-
await addWebhook({ url, label });
139
139
-
webhookUrl = '';
140
140
-
webhookLabel = '';
141
141
-
});
142
142
-
}
143
143
-
144
144
-
// Telegram: optional name typed before linking, carried through the handshake.
145
145
-
let telegramLabel = $state('');
146
146
-
147
147
-
async function connectTelegram() {
148
148
-
busy['link'] = true;
149
149
-
errorMsg = '';
150
150
-
try {
151
151
-
const label = telegramLabel.trim() || undefined;
152
152
-
const { deepLink } = await linkTelegram({ label });
153
153
-
telegramLabel = '';
154
154
-
// Open Telegram in a new tab so the dashboard stays put; the user comes
155
155
-
// back and the linked chat shows after the next load/invalidate.
156
156
-
window.open(deepLink, '_blank', 'noopener,noreferrer');
157
157
-
} catch (err) {
158
158
-
errorMsg = err instanceof Error ? err.message : 'Could not start linking';
159
159
-
} finally {
160
160
-
busy['link'] = false;
161
161
-
}
162
162
-
}
163
163
-
164
164
-
// Web push for THIS browser. The device list comes from the server; we track
165
165
-
// this browser's own subscription endpoint to mark it as "this device".
166
166
-
type PushState = 'loading' | 'unsupported' | 'ready';
167
167
-
let pushState = $state<PushState>('loading');
168
168
-
let currentEndpoint = $state<string | null>(null);
169
169
-
// Optional name for this device, typed before enabling push.
170
170
-
let deviceName = $state('');
171
171
-
172
172
-
// This browser counts as connected only if its live subscription is also known
173
173
-
// server-side. A browser can hold a stale subscription the server no longer has
174
174
-
// (e.g. after a DB reset); we surface that as "reconnect" rather than hiding it.
175
175
-
const thisDeviceConnected = $derived(
176
176
-
currentEndpoint !== null && data.devices.some((d) => d.endpoint === currentEndpoint)
177
177
-
);
178
178
-
179
179
-
onMount(async () => {
180
180
-
const stored = localStorage.getItem('theme');
181
181
-
theme = stored === 'light' || stored === 'dark' ? stored : 'system';
182
182
-
183
183
-
if (!pushSupported()) {
184
184
-
pushState = 'unsupported';
185
185
-
return;
186
186
-
}
187
187
-
try {
188
188
-
currentEndpoint = (await currentSubscription())?.endpoint ?? null;
189
189
-
} catch {
190
190
-
currentEndpoint = null;
191
191
-
}
192
192
-
pushState = 'ready';
193
193
-
});
194
194
-
195
195
-
async function enablePush() {
196
196
-
busy['push'] = true;
197
197
-
errorMsg = '';
198
198
-
try {
199
199
-
const sub = await subscribe(deviceName.trim() || undefined);
200
200
-
await registerPush(sub);
201
201
-
currentEndpoint = sub.endpoint;
202
202
-
deviceName = '';
203
203
-
await invalidateAll();
204
204
-
} catch (err) {
205
205
-
errorMsg = err instanceof Error ? err.message : 'Could not enable push';
206
206
-
} finally {
207
207
-
busy['push'] = false;
208
208
-
}
209
209
-
}
210
210
-
211
211
-
async function disableCurrent() {
212
212
-
busy['push'] = true;
213
213
-
errorMsg = '';
214
214
-
try {
215
215
-
const endpoint = await unsubscribe();
216
216
-
if (endpoint) await unregisterPush({ endpoint });
217
217
-
currentEndpoint = null;
218
218
-
await invalidateAll();
219
219
-
} catch (err) {
220
220
-
errorMsg = err instanceof Error ? err.message : 'Could not disable push';
221
221
-
} finally {
222
222
-
busy['push'] = false;
223
223
-
}
224
224
-
}
225
225
-
226
226
-
const sectionLabel = 'mb-2 font-mono text-[0.7rem] tracking-wide text-muted-2 uppercase';
227
227
-
const card = 'rounded-card border border-line bg-surface p-4';
228
228
-
const rowBtn = 'text-xs font-medium text-muted hover:text-fg';
229
229
-
const dangerBtn = 'text-xs font-medium text-danger hover:underline disabled:opacity-50';
230
21
</script>
231
22
232
23
<svelte:head><title>Settings · atmo.pub</title></svelte:head>
···
251
42
</nav>
252
43
</header>
253
44
254
254
-
{#if errorMsg}
255
255
-
<p
256
256
-
class="mt-4 rounded-card border border-line bg-danger/10 px-3 py-2 text-sm text-danger"
257
257
-
role="alert"
258
258
-
>
259
259
-
{errorMsg}
260
260
-
</p>
261
261
-
{/if}
262
262
-
<div aria-live="polite" class="sr-only">{errorMsg}</div>
263
263
-
264
45
{#if tab === 'channels'}
265
265
-
<p class="mt-6 max-w-2xl text-sm text-muted">
266
266
-
Where atmo.pub can reach you. You can connect several of each and give them names; apps and
267
267
-
categories route to one or more of these — everything also lands in your inbox.
268
268
-
</p>
269
269
-
270
270
-
<div
271
271
-
class="mt-4 flex max-w-2xl items-start gap-2.5 rounded-card border border-line bg-surface-2 px-3 py-2.5"
272
272
-
>
273
273
-
<Icon name="info" size={16} stroke={2} class="mt-0.5 shrink-0 text-muted-2" />
274
274
-
<p class="text-xs text-muted">
275
275
-
Apps you connect can see the <span class="font-medium text-fg">names</span> you give these channels
276
276
-
(so they can show you a routing picker) — but never the email address, phone number, or device
277
277
-
behind them.
278
278
-
</p>
279
279
-
</div>
280
280
-
281
281
-
<!-- Push devices -->
282
282
-
<section class="mt-4 max-w-2xl">
283
283
-
<div class="flex items-center justify-between">
284
284
-
<h2 class={sectionLabel}>Push devices</h2>
285
285
-
{#if data.devices.length > 0}
286
286
-
<span class="font-mono text-[0.7rem] text-muted-2">
287
287
-
{data.devices.length} device{data.devices.length === 1 ? '' : 's'}
288
288
-
</span>
289
289
-
{/if}
290
290
-
</div>
291
291
-
<div class={card}>
292
292
-
<div class="mb-3 flex items-center gap-2.5">
293
293
-
<RouteChip route="push" size="md" />
294
294
-
<span class="text-sm font-semibold text-fg">Web push</span>
295
295
-
</div>
296
296
-
297
297
-
{#if data.devices.length > 0}
298
298
-
<ul class="divide-y divide-line-2">
299
299
-
{#each data.devices as d (d.id)}
300
300
-
<li class="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0">
301
301
-
{#if renaming === d.id}
302
302
-
<input
303
303
-
class="min-w-0 flex-1 rounded-md border border-line bg-surface-2 px-2 py-1 text-sm text-fg focus:border-accent"
304
304
-
bind:value={renameLabel}
305
305
-
aria-label="Device name"
306
306
-
onkeydown={(e) => {
307
307
-
if (e.key === 'Enter') saveRename(d.id);
308
308
-
else if (e.key === 'Escape') renaming = null;
309
309
-
}}
310
310
-
/>
311
311
-
<div class="flex shrink-0 items-center gap-3">
312
312
-
<button
313
313
-
class="text-xs font-medium text-accent"
314
314
-
onclick={() => saveRename(d.id)}
315
315
-
>
316
316
-
Save
317
317
-
</button>
318
318
-
<button class={rowBtn} onclick={() => (renaming = null)}>Cancel</button>
319
319
-
</div>
320
320
-
{:else}
321
321
-
<div class="min-w-0 flex-1">
322
322
-
<div class="flex items-center gap-2">
323
323
-
<span class="truncate text-sm font-medium text-fg">{d.label}</span>
324
324
-
{#if d.endpoint === currentEndpoint}
325
325
-
<span
326
326
-
class="rounded bg-accent-soft px-1.5 py-0.5 font-mono text-[0.6rem] font-bold tracking-wide text-accent uppercase"
327
327
-
>
328
328
-
This device
329
329
-
</span>
330
330
-
{/if}
331
331
-
</div>
332
332
-
<div class="mt-0.5 font-mono text-[0.65rem] text-muted-2">
333
333
-
added <RelativeTime date={d.createdAt} />
334
334
-
</div>
335
335
-
</div>
336
336
-
<div class="flex shrink-0 items-center gap-3">
337
337
-
<button class={rowBtn} onclick={() => startRename(d.id, d.label)}>Rename</button
338
338
-
>
339
339
-
{#if d.endpoint === currentEndpoint}
340
340
-
<button class={dangerBtn} disabled={busy['push']} onclick={disableCurrent}>
341
341
-
Disable
342
342
-
</button>
343
343
-
{:else}
344
344
-
<button
345
345
-
class={dangerBtn}
346
346
-
disabled={busy[`rm:${d.id}`]}
347
347
-
onclick={() => run(`rm:${d.id}`, () => removeTarget({ id: d.id }))}
348
348
-
>
349
349
-
Remove
350
350
-
</button>
351
351
-
{/if}
352
352
-
</div>
353
353
-
{/if}
354
354
-
</li>
355
355
-
{/each}
356
356
-
</ul>
357
357
-
{/if}
358
358
-
359
359
-
{#if pushState === 'loading'}
360
360
-
<p class="text-xs text-muted-2">Checking this browser…</p>
361
361
-
{:else if pushState === 'unsupported'}
362
362
-
<p class="text-xs text-muted">Push isn't available in this browser.</p>
363
363
-
{:else if !thisDeviceConnected}
364
364
-
{@const reconnect = currentEndpoint !== null}
365
365
-
<div class={data.devices.length > 0 ? 'mt-3 border-t border-line-2 pt-3' : ''}>
366
366
-
<p class="mb-2 text-xs text-muted">
367
367
-
{#if reconnect}
368
368
-
This browser was subscribed but isn't linked to your account anymore — reconnect it.
369
369
-
{:else}
370
370
-
Get notifications in this browser, even when it's closed.
371
371
-
{/if}
372
372
-
</p>
373
373
-
<div class="flex flex-col gap-2 sm:flex-row">
374
374
-
{#if !reconnect}
375
375
-
<input
376
376
-
bind:value={deviceName}
377
377
-
placeholder="Device name (optional)"
378
378
-
class="min-w-0 rounded-md border border-line bg-surface-2 px-3 py-1.5 text-sm text-fg placeholder:text-muted-2 focus:border-accent sm:w-52"
379
379
-
onkeydown={(e) => {
380
380
-
if (e.key === 'Enter') enablePush();
381
381
-
}}
382
382
-
/>
383
383
-
{/if}
384
384
-
<button
385
385
-
class="shrink-0 rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition-opacity hover:opacity-90 disabled:opacity-50"
386
386
-
disabled={busy['push']}
387
387
-
onclick={enablePush}
388
388
-
>
389
389
-
{busy['push']
390
390
-
? 'Enabling…'
391
391
-
: reconnect
392
392
-
? 'Reconnect this device'
393
393
-
: 'Enable on this device'}
394
394
-
</button>
395
395
-
</div>
396
396
-
</div>
397
397
-
{/if}
398
398
-
</div>
399
399
-
</section>
400
400
-
401
401
-
<!-- Telegram -->
402
402
-
<section class="mt-6 max-w-2xl">
403
403
-
<h2 class={sectionLabel}>Telegram</h2>
404
404
-
<div class={card}>
405
405
-
<div class="mb-3 flex items-center gap-2.5">
406
406
-
<RouteChip route="telegram" size="md" />
407
407
-
<span class="text-sm font-semibold text-fg">Telegram DM</span>
408
408
-
</div>
409
409
-
410
410
-
{#if data.telegrams.length > 0}
411
411
-
<ul class="mb-3 divide-y divide-line-2">
412
412
-
{#each data.telegrams as t (t.id)}
413
413
-
<li class="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0">
414
414
-
{#if renaming === t.id}
415
415
-
<input
416
416
-
class="min-w-0 flex-1 rounded-md border border-line bg-surface-2 px-2 py-1 text-sm text-fg focus:border-accent"
417
417
-
bind:value={renameLabel}
418
418
-
aria-label="Chat name"
419
419
-
onkeydown={(e) => {
420
420
-
if (e.key === 'Enter') saveRename(t.id);
421
421
-
else if (e.key === 'Escape') renaming = null;
422
422
-
}}
423
423
-
/>
424
424
-
<div class="flex shrink-0 items-center gap-3">
425
425
-
<button
426
426
-
class="text-xs font-medium text-accent"
427
427
-
onclick={() => saveRename(t.id)}
428
428
-
>
429
429
-
Save
430
430
-
</button>
431
431
-
<button class={rowBtn} onclick={() => (renaming = null)}>Cancel</button>
432
432
-
</div>
433
433
-
{:else}
434
434
-
<div class="min-w-0 flex-1">
435
435
-
<div class="truncate text-sm font-medium text-fg">{t.label}</div>
436
436
-
<div class="mt-0.5 font-mono text-[0.65rem] text-muted-2">
437
437
-
linked <RelativeTime date={t.createdAt} />
438
438
-
</div>
439
439
-
</div>
440
440
-
<div class="flex shrink-0 items-center gap-3">
441
441
-
<button class={rowBtn} onclick={() => startRename(t.id, t.label)}>Rename</button
442
442
-
>
443
443
-
<button
444
444
-
class={dangerBtn}
445
445
-
disabled={busy[`rm:${t.id}`]}
446
446
-
onclick={() => run(`rm:${t.id}`, () => removeTarget({ id: t.id }))}
447
447
-
>
448
448
-
Unlink
449
449
-
</button>
450
450
-
</div>
451
451
-
{/if}
452
452
-
</li>
453
453
-
{/each}
454
454
-
</ul>
455
455
-
456
456
-
<div class="flex items-start justify-between gap-4 border-t border-line-2 pt-3">
457
457
-
<label for="notify-pending" class="text-sm text-fg">
458
458
-
Telegram me about new permission requests
459
459
-
</label>
460
460
-
<IOSToggle
461
461
-
id="notify-pending"
462
462
-
checked={data.notifyPendingViaTelegram}
463
463
-
label="Telegram me about new permission requests"
464
464
-
disabled={busy['setting']}
465
465
-
onchange={(value) => run('setting', () => setNotifyPending({ value }))}
466
466
-
/>
467
467
-
</div>
468
468
-
{/if}
469
469
-
470
470
-
<div class={data.telegrams.length > 0 ? 'mt-3 border-t border-line-2 pt-3' : ''}>
471
471
-
{#if data.telegrams.length === 0}
472
472
-
<p class="mb-2 text-xs text-muted">Link a Telegram chat to get notifications there.</p>
473
473
-
{/if}
474
474
-
<div class="flex flex-col gap-2 sm:flex-row">
475
475
-
<input
476
476
-
bind:value={telegramLabel}
477
477
-
placeholder="Name (optional)"
478
478
-
class="min-w-0 rounded-md border border-line bg-surface-2 px-3 py-1.5 text-sm text-fg placeholder:text-muted-2 focus:border-accent sm:w-52"
479
479
-
onkeydown={(e) => {
480
480
-
if (e.key === 'Enter') connectTelegram();
481
481
-
}}
482
482
-
/>
483
483
-
<button
484
484
-
class="shrink-0 rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition-opacity hover:opacity-90 disabled:opacity-50"
485
485
-
disabled={busy['link']}
486
486
-
onclick={connectTelegram}
487
487
-
>
488
488
-
{busy['link']
489
489
-
? 'Opening…'
490
490
-
: data.telegrams.length > 0
491
491
-
? 'Link another'
492
492
-
: 'Link'}
493
493
-
</button>
494
494
-
</div>
495
495
-
</div>
496
496
-
</div>
497
497
-
</section>
498
498
-
499
499
-
<!-- Bluesky DM -->
500
500
-
<section class="mt-6 max-w-2xl">
501
501
-
<h2 class={sectionLabel}>Bluesky DM</h2>
502
502
-
<div class={card}>
503
503
-
<div class="mb-3 flex items-center gap-2.5">
504
504
-
<RouteChip route="dm" size="md" />
505
505
-
<span class="text-sm font-semibold text-fg">Bluesky DM</span>
506
506
-
</div>
507
507
-
508
508
-
{#if data.dms.length > 0}
509
509
-
{#each data.dms as dm (dm.id)}
510
510
-
{#if renaming === dm.id}
511
511
-
<div class="flex items-center justify-between gap-3">
512
512
-
<input
513
513
-
class="min-w-0 flex-1 rounded-md border border-line bg-surface-2 px-2 py-1 text-sm text-fg focus:border-accent"
514
514
-
bind:value={renameLabel}
515
515
-
aria-label="DM name"
516
516
-
onkeydown={(ev) => {
517
517
-
if (ev.key === 'Enter') saveRename(dm.id);
518
518
-
else if (ev.key === 'Escape') renaming = null;
519
519
-
}}
520
520
-
/>
521
521
-
<div class="flex shrink-0 items-center gap-3">
522
522
-
<button class="text-xs font-medium text-accent" onclick={() => saveRename(dm.id)}>
523
523
-
Save
524
524
-
</button>
525
525
-
<button class={rowBtn} onclick={() => (renaming = null)}>Cancel</button>
526
526
-
</div>
527
527
-
</div>
528
528
-
{:else}
529
529
-
<div class="flex items-center justify-between gap-3">
530
530
-
<div class="flex min-w-0 items-center gap-2">
531
531
-
<Icon name="check" size={16} stroke={2.4} class="shrink-0 text-accent" />
532
532
-
<span class="truncate text-sm font-medium text-fg">{dm.label}</span>
533
533
-
</div>
534
534
-
<div class="flex shrink-0 items-center gap-3">
535
535
-
<button class={rowBtn} onclick={() => startRename(dm.id, dm.label)}>Rename</button>
536
536
-
<button
537
537
-
class={dangerBtn}
538
538
-
disabled={busy[`rm:${dm.id}`]}
539
539
-
onclick={() => run(`rm:${dm.id}`, () => removeTarget({ id: dm.id }))}
540
540
-
>
541
541
-
Disable
542
542
-
</button>
543
543
-
</div>
544
544
-
</div>
545
545
-
<p class="mt-2 text-xs text-muted-2">
546
546
-
The atmo.pub bot will DM you on Bluesky. Make sure your Bluesky chat settings allow
547
547
-
messages from it.
548
548
-
</p>
549
549
-
{/if}
550
550
-
{/each}
551
551
-
{:else}
552
552
-
<p class="mb-2 text-xs text-muted">
553
553
-
Get notifications as a Bluesky direct message from the atmo.pub bot.
554
554
-
</p>
555
555
-
<button
556
556
-
class="rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition-opacity hover:opacity-90 disabled:opacity-50"
557
557
-
disabled={busy['dm']}
558
558
-
onclick={() => run('dm', () => enableDM())}
559
559
-
>
560
560
-
{busy['dm'] ? 'Enabling…' : 'Enable'}
561
561
-
</button>
562
562
-
{/if}
563
563
-
</div>
564
564
-
</section>
565
565
-
566
566
-
<!-- Email -->
567
567
-
<section class="mt-6 max-w-2xl">
568
568
-
<h2 class={sectionLabel}>Email</h2>
569
569
-
<div class={card}>
570
570
-
<div class="mb-3 flex items-center gap-2.5">
571
571
-
<RouteChip route="email" size="md" />
572
572
-
<span class="text-sm font-semibold text-fg">Email</span>
573
573
-
</div>
574
574
-
575
575
-
{#if data.emails.length > 0}
576
576
-
<ul class="mb-3 divide-y divide-line-2">
577
577
-
{#each data.emails as e (e.id)}
578
578
-
<li class="py-3 first:pt-0">
579
579
-
{#if renaming === e.id}
580
580
-
<div class="flex items-center justify-between gap-3">
581
581
-
<input
582
582
-
class="min-w-0 flex-1 rounded-md border border-line bg-surface-2 px-2 py-1 text-sm text-fg focus:border-accent"
583
583
-
bind:value={renameLabel}
584
584
-
aria-label="Email name"
585
585
-
onkeydown={(ev) => {
586
586
-
if (ev.key === 'Enter') saveRename(e.id);
587
587
-
else if (ev.key === 'Escape') renaming = null;
588
588
-
}}
589
589
-
/>
590
590
-
<div class="flex shrink-0 items-center gap-3">
591
591
-
<button
592
592
-
class="text-xs font-medium text-accent"
593
593
-
onclick={() => saveRename(e.id)}
594
594
-
>
595
595
-
Save
596
596
-
</button>
597
597
-
<button class={rowBtn} onclick={() => (renaming = null)}>Cancel</button>
598
598
-
</div>
599
599
-
</div>
600
600
-
{:else}
601
601
-
<div class="flex items-center justify-between gap-3">
602
602
-
<div class="flex min-w-0 items-center gap-2">
603
603
-
{#if e.verified}
604
604
-
<Icon name="check" size={16} stroke={2.4} class="shrink-0 text-accent" />
605
605
-
{/if}
606
606
-
<span class="truncate text-sm font-medium text-fg">{e.label}</span>
607
607
-
</div>
608
608
-
<div class="flex shrink-0 items-center gap-3">
609
609
-
<button class={rowBtn} onclick={() => startRename(e.id, e.label)}
610
610
-
>Rename</button
611
611
-
>
612
612
-
<button
613
613
-
class={dangerBtn}
614
614
-
disabled={busy[`rm:${e.id}`]}
615
615
-
onclick={() => run(`rm:${e.id}`, () => removeTarget({ id: e.id }))}
616
616
-
>
617
617
-
Remove
618
618
-
</button>
619
619
-
</div>
620
620
-
</div>
621
621
-
{#if e.label !== e.address}
622
622
-
<div class="mt-0.5 truncate font-mono text-[0.65rem] text-muted-2">
623
623
-
{e.address}
624
624
-
</div>
625
625
-
{/if}
626
626
-
{#if !e.verified}
627
627
-
<div class="mt-1 text-xs text-warn">
628
628
-
Pending — enter the 6-digit code we emailed you.
629
629
-
</div>
630
630
-
<div class="mt-2 flex gap-2">
631
631
-
<input
632
632
-
inputmode="numeric"
633
633
-
maxlength="6"
634
634
-
bind:value={codeInputs[e.id]}
635
635
-
placeholder="123456"
636
636
-
class="w-28 rounded-md border border-line bg-surface-2 px-3 py-1.5 text-sm tracking-widest text-fg placeholder:text-muted-2 focus:border-accent"
637
637
-
/>
638
638
-
<button
639
639
-
class="shrink-0 rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition-opacity hover:opacity-90 disabled:opacity-50"
640
640
-
disabled={busy[`email-verify:${e.id}`]}
641
641
-
onclick={() => submitCode(e.id)}
642
642
-
>
643
643
-
{busy[`email-verify:${e.id}`] ? 'Verifying…' : 'Verify'}
644
644
-
</button>
645
645
-
</div>
646
646
-
{/if}
647
647
-
{/if}
648
648
-
</li>
649
649
-
{/each}
650
650
-
</ul>
651
651
-
{/if}
652
652
-
653
653
-
<div class={data.emails.length > 0 ? 'border-t border-line-2 pt-3' : ''}>
654
654
-
{#if data.emails.length === 0}
655
655
-
<p class="mb-2 text-xs text-muted">Get notifications by email.</p>
656
656
-
{/if}
657
657
-
<div class="flex flex-col gap-2 sm:flex-row">
658
658
-
<input
659
659
-
bind:value={emailLabel}
660
660
-
placeholder="Name (optional)"
661
661
-
class="min-w-0 rounded-md border border-line bg-surface-2 px-3 py-1.5 text-sm text-fg placeholder:text-muted-2 focus:border-accent sm:w-40"
662
662
-
onkeydown={(e) => {
663
663
-
if (e.key === 'Enter') submitEmail();
664
664
-
}}
665
665
-
/>
666
666
-
<input
667
667
-
type="email"
668
668
-
bind:value={emailInput}
669
669
-
placeholder="you@example.com"
670
670
-
class="min-w-0 flex-1 rounded-md border border-line bg-surface-2 px-3 py-1.5 text-sm text-fg placeholder:text-muted-2 focus:border-accent"
671
671
-
onkeydown={(e) => {
672
672
-
if (e.key === 'Enter') submitEmail();
673
673
-
}}
674
674
-
/>
675
675
-
<button
676
676
-
class="shrink-0 rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition-opacity hover:opacity-90 disabled:opacity-50"
677
677
-
disabled={busy['email']}
678
678
-
onclick={submitEmail}
679
679
-
>
680
680
-
{busy['email'] ? 'Sending…' : data.emails.length > 0 ? 'Add' : 'Send code'}
681
681
-
</button>
682
682
-
</div>
683
683
-
</div>
684
684
-
</div>
685
685
-
</section>
686
686
-
687
687
-
<!-- Webhooks -->
688
688
-
<section class="mt-6 max-w-2xl">
689
689
-
<h2 class={sectionLabel}>Webhooks</h2>
690
690
-
<div class={card}>
691
691
-
<div class="mb-3 flex items-center gap-2.5">
692
692
-
<RouteChip route="webhook" size="md" />
693
693
-
<span class="text-sm font-semibold text-fg">Webhook</span>
694
694
-
</div>
695
695
-
696
696
-
{#if data.webhooks.length > 0}
697
697
-
<ul class="mb-3 divide-y divide-line-2">
698
698
-
{#each data.webhooks as w (w.id)}
699
699
-
<li class="py-3 first:pt-0">
700
700
-
{#if renaming === w.id}
701
701
-
<div class="flex items-center justify-between gap-3">
702
702
-
<input
703
703
-
class="min-w-0 flex-1 rounded-md border border-line bg-surface-2 px-2 py-1 text-sm text-fg focus:border-accent"
704
704
-
bind:value={renameLabel}
705
705
-
aria-label="Webhook name"
706
706
-
onkeydown={(ev) => {
707
707
-
if (ev.key === 'Enter') saveRename(w.id);
708
708
-
else if (ev.key === 'Escape') renaming = null;
709
709
-
}}
710
710
-
/>
711
711
-
<div class="flex shrink-0 items-center gap-3">
712
712
-
<button class="text-xs font-medium text-accent" onclick={() => saveRename(w.id)}>
713
713
-
Save
714
714
-
</button>
715
715
-
<button class={rowBtn} onclick={() => (renaming = null)}>Cancel</button>
716
716
-
</div>
717
717
-
</div>
718
718
-
{:else}
719
719
-
<div class="flex items-center justify-between gap-3">
720
720
-
<span class="truncate text-sm font-medium text-fg">{w.label}</span>
721
721
-
<div class="flex shrink-0 items-center gap-3">
722
722
-
<button class={rowBtn} onclick={() => startRename(w.id, w.label)}>Rename</button>
723
723
-
<button
724
724
-
class={dangerBtn}
725
725
-
disabled={busy[`rm:${w.id}`]}
726
726
-
onclick={() => run(`rm:${w.id}`, () => removeTarget({ id: w.id }))}
727
727
-
>
728
728
-
Remove
729
729
-
</button>
730
730
-
</div>
731
731
-
</div>
732
732
-
<div class="mt-0.5 truncate font-mono text-[0.65rem] text-muted-2">{w.url}</div>
733
733
-
{/if}
734
734
-
</li>
735
735
-
{/each}
736
736
-
</ul>
737
737
-
{/if}
738
738
-
739
739
-
<div class={data.webhooks.length > 0 ? 'border-t border-line-2 pt-3' : ''}>
740
740
-
{#if data.webhooks.length === 0}
741
741
-
<p class="mb-2 text-xs text-muted">
742
742
-
Send notifications to your own server. We POST JSON ({'{'} title, body, uri, sender, sentAt
743
743
-
{'}'}) to the URL over HTTPS.
744
744
-
</p>
745
745
-
{/if}
746
746
-
<div class="flex flex-col gap-2 sm:flex-row">
747
747
-
<input
748
748
-
bind:value={webhookLabel}
749
749
-
placeholder="Label (e.g. My server)"
750
750
-
class="min-w-0 rounded-md border border-line bg-surface-2 px-3 py-1.5 text-sm text-fg placeholder:text-muted-2 focus:border-accent sm:w-44"
751
751
-
onkeydown={(e) => {
752
752
-
if (e.key === 'Enter') submitWebhook();
753
753
-
}}
754
754
-
/>
755
755
-
<input
756
756
-
type="url"
757
757
-
inputmode="url"
758
758
-
bind:value={webhookUrl}
759
759
-
placeholder="https://example.com/hook"
760
760
-
class="min-w-0 flex-1 rounded-md border border-line bg-surface-2 px-3 py-1.5 text-sm text-fg placeholder:text-muted-2 focus:border-accent"
761
761
-
onkeydown={(e) => {
762
762
-
if (e.key === 'Enter') submitWebhook();
763
763
-
}}
764
764
-
/>
765
765
-
<button
766
766
-
class="shrink-0 rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition-opacity hover:opacity-90 disabled:opacity-50"
767
767
-
disabled={busy['webhook']}
768
768
-
onclick={submitWebhook}
769
769
-
>
770
770
-
{busy['webhook'] ? 'Adding…' : 'Add'}
771
771
-
</button>
772
772
-
</div>
773
773
-
</div>
774
774
-
</div>
775
775
-
</section>
46
46
+
<ChannelsTab {data} />
776
47
{:else if tab === 'routing'}
777
777
-
<!-- Default routing -->
778
778
-
<section class="mt-6 max-w-2xl">
779
779
-
<h2 class={sectionLabel}>Default routing</h2>
780
780
-
<div class={card}>
781
781
-
<div class="text-sm font-medium text-fg">Where notifications go by default</div>
782
782
-
<p class="mt-1 text-xs text-muted">
783
783
-
Pick where notifications go by default. <span class="font-medium text-fg">Inbox only</span
784
784
-
>
785
785
-
saves them with no alerts; <span class="font-medium text-fg">Off</span> drops them
786
786
-
entirely. Apps set to “Account default” use this; override per-app (and per-category) from
787
787
-
<a href="/apps" class="text-accent hover:underline">Apps</a>.
788
788
-
</p>
789
789
-
<div class="mt-3">
790
790
-
<ChannelRoutePicker
791
791
-
value={data.defaultRoute}
792
792
-
instances={data.channels}
793
793
-
disabled={busy['defaultRoute']}
794
794
-
onchange={(route) => run('defaultRoute', () => setDefaultRoute({ route }))}
795
795
-
/>
796
796
-
</div>
797
797
-
</div>
798
798
-
</section>
799
799
-
{:else if tab === 'auto-allow'}
800
800
-
<!-- Automatic approval -->
801
801
-
<section class="mt-6 max-w-2xl">
802
802
-
<h2 class={sectionLabel}>Allow apps automatically</h2>
803
803
-
<p class="mb-2 text-sm text-muted">
804
804
-
When an app asks to notify you, decide whether it's approved automatically or waits for you
805
805
-
in Apps.
806
806
-
</p>
807
807
-
<div class="overflow-hidden rounded-card border border-line bg-surface">
808
808
-
{#each autoAllowOptions as opt, i (opt.value)}
809
809
-
<button
810
810
-
type="button"
811
811
-
disabled={busy['autoAllow']}
812
812
-
onclick={() => run('autoAllow', () => setAutoAllow({ autoAllow: opt.value }))}
813
813
-
class="flex w-full items-start justify-between gap-3 p-4 text-left transition-colors hover:bg-surface-2 disabled:opacity-50 {i <
814
814
-
autoAllowOptions.length - 1
815
815
-
? 'border-b border-line-2'
816
816
-
: ''}"
817
817
-
>
818
818
-
<div class="min-w-0">
819
819
-
<div class="text-sm font-semibold text-fg">{opt.label}</div>
820
820
-
<div class="mt-0.5 text-xs text-muted">{opt.desc}</div>
821
821
-
</div>
822
822
-
{#if data.autoAllow === opt.value}
823
823
-
<Icon name="check" size={18} stroke={2.4} class="shrink-0 text-accent" />
824
824
-
{/if}
825
825
-
</button>
826
826
-
{/each}
827
827
-
</div>
828
828
-
</section>
48
48
+
<RoutingTab {data} />
49
49
+
{:else if tab === 'requests'}
50
50
+
<RequestsTab {data} />
829
51
{:else if tab === 'appearance'}
830
830
-
<!-- Appearance -->
831
831
-
<section class="mt-6 max-w-2xl">
832
832
-
<h2 class={sectionLabel}>Theme</h2>
833
833
-
<div class="overflow-hidden rounded-card border border-line bg-surface">
834
834
-
{#each themeOptions as opt, i (opt.value)}
835
835
-
<button
836
836
-
type="button"
837
837
-
onclick={() => setTheme(opt.value)}
838
838
-
class="flex w-full items-start justify-between gap-3 p-4 text-left transition-colors hover:bg-surface-2 {i <
839
839
-
themeOptions.length - 1
840
840
-
? 'border-b border-line-2'
841
841
-
: ''}"
842
842
-
>
843
843
-
<div class="min-w-0">
844
844
-
<div class="text-sm font-semibold text-fg">{opt.label}</div>
845
845
-
<div class="mt-0.5 text-xs text-muted">{opt.desc}</div>
846
846
-
</div>
847
847
-
{#if theme === opt.value}
848
848
-
<Icon name="check" size={18} stroke={2.4} class="shrink-0 text-accent" />
849
849
-
{/if}
850
850
-
</button>
851
851
-
{/each}
852
852
-
</div>
853
853
-
</section>
52
52
+
<AppearanceTab />
854
53
{:else if tab === 'developer'}
855
855
-
<!-- Developer docs -->
856
856
-
<section class="mt-6 max-w-2xl">
857
857
-
<h2 class={sectionLabel}>Developer docs</h2>
858
858
-
<div class={card}>
859
859
-
<p class="text-sm text-muted">
860
860
-
Building an app? Send notifications through atmo.pub — request permission, send, and let
861
861
-
users manage routing — over a small XRPC API. The full guide, with code, lives on the docs
862
862
-
site.
863
863
-
</p>
864
864
-
<a
865
865
-
href={DOCS_URL}
866
866
-
target="_blank"
867
867
-
rel="noreferrer"
868
868
-
class="mt-4 inline-flex items-center gap-2 rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition-opacity hover:opacity-90"
869
869
-
>
870
870
-
Open developer docs
871
871
-
<Icon name="arrow-right" size={16} stroke={2} />
872
872
-
</a>
873
873
-
</div>
874
874
-
</section>
54
54
+
<DeveloperTab />
875
55
{/if}
876
56
</div>
···
1
1
+
<script lang="ts">
2
2
+
import { onMount } from 'svelte';
3
3
+
import Icon from '$lib/components/Icon.svelte';
4
4
+
5
5
+
type Theme = 'system' | 'light' | 'dark';
6
6
+
let theme = $state<Theme>('system');
7
7
+
const themeOptions = [
8
8
+
{ value: 'system', label: 'System', desc: 'Match your device appearance.' },
9
9
+
{ value: 'light', label: 'Light', desc: 'Always use the light theme.' },
10
10
+
{ value: 'dark', label: 'Dark', desc: 'Always use the dark theme.' }
11
11
+
] as const;
12
12
+
13
13
+
onMount(() => {
14
14
+
const stored = localStorage.getItem('theme');
15
15
+
theme = stored === 'light' || stored === 'dark' ? stored : 'system';
16
16
+
});
17
17
+
18
18
+
function setTheme(value: Theme) {
19
19
+
theme = value;
20
20
+
if (value === 'system') {
21
21
+
localStorage.removeItem('theme');
22
22
+
delete document.documentElement.dataset.theme;
23
23
+
} else {
24
24
+
localStorage.setItem('theme', value);
25
25
+
document.documentElement.dataset.theme = value;
26
26
+
}
27
27
+
}
28
28
+
</script>
29
29
+
30
30
+
<section class="mt-6 max-w-2xl">
31
31
+
<h2 class="mb-2 font-mono text-[0.7rem] tracking-wide text-muted-2 uppercase">Theme</h2>
32
32
+
<div class="overflow-hidden rounded-card border border-line bg-surface">
33
33
+
{#each themeOptions as opt, i (opt.value)}
34
34
+
<button
35
35
+
type="button"
36
36
+
onclick={() => setTheme(opt.value)}
37
37
+
class="flex w-full items-start justify-between gap-3 p-4 text-left transition-colors hover:bg-surface-2 {i <
38
38
+
themeOptions.length - 1
39
39
+
? 'border-b border-line-2'
40
40
+
: ''}"
41
41
+
>
42
42
+
<div class="min-w-0">
43
43
+
<div class="text-sm font-semibold text-fg">{opt.label}</div>
44
44
+
<div class="mt-0.5 text-xs text-muted">{opt.desc}</div>
45
45
+
</div>
46
46
+
{#if theme === opt.value}
47
47
+
<Icon name="check" size={18} stroke={2.4} class="shrink-0 text-accent" />
48
48
+
{/if}
49
49
+
</button>
50
50
+
{/each}
51
51
+
</div>
52
52
+
</section>
···
1
1
+
<script lang="ts">
2
2
+
import { onMount } from 'svelte';
3
3
+
import { invalidateAll } from '$app/navigation';
4
4
+
import DefaultRoutePrompt from './DefaultRoutePrompt.svelte';
5
5
+
import Icon from '$lib/components/Icon.svelte';
6
6
+
import RelativeTime from '$lib/components/RelativeTime.svelte';
7
7
+
import RouteChip from '$lib/components/RouteChip.svelte';
8
8
+
import { currentSubscription, pushSupported, subscribe, unsubscribe } from '$lib/push';
9
9
+
import {
10
10
+
addWebhook,
11
11
+
enableDM,
12
12
+
linkEmail,
13
13
+
linkTelegram,
14
14
+
registerPush,
15
15
+
removeTarget,
16
16
+
renameTarget,
17
17
+
unregisterPush,
18
18
+
verifyEmail
19
19
+
} from '$lib/remote/notifs.remote';
20
20
+
import type { PageData } from './$types';
21
21
+
22
22
+
let { data }: { data: PageData } = $props();
23
23
+
24
24
+
let busy = $state<Record<string, boolean>>({});
25
25
+
let errorMsg = $state('');
26
26
+
27
27
+
async function run(key: string, fn: () => Promise<unknown>) {
28
28
+
busy[key] = true;
29
29
+
errorMsg = '';
30
30
+
try {
31
31
+
await fn();
32
32
+
await invalidateAll();
33
33
+
} catch (err) {
34
34
+
errorMsg = err instanceof Error ? err.message : 'Something went wrong';
35
35
+
} finally {
36
36
+
busy[key] = false;
37
37
+
}
38
38
+
}
39
39
+
40
40
+
// Rename any delivery target (device / chat / email / webhook) inline.
41
41
+
let renaming = $state<string | null>(null);
42
42
+
let renameLabel = $state('');
43
43
+
function startRename(id: string, label: string) {
44
44
+
renaming = id;
45
45
+
renameLabel = label;
46
46
+
}
47
47
+
async function saveRename(id: string) {
48
48
+
const label = renameLabel.trim();
49
49
+
if (!label) {
50
50
+
renaming = null;
51
51
+
return;
52
52
+
}
53
53
+
await run(`rename:${id}`, () => renameTarget({ id, label }));
54
54
+
renaming = null;
55
55
+
}
56
56
+
57
57
+
// Email: add a new address (+ optional name) + verify pending ones.
58
58
+
let emailInput = $state('');
59
59
+
let emailLabel = $state('');
60
60
+
let codeInputs = $state<Record<string, string>>({});
61
61
+
62
62
+
function submitEmail() {
63
63
+
const address = emailInput.trim();
64
64
+
const label = emailLabel.trim() || undefined;
65
65
+
if (address)
66
66
+
run('email', async () => {
67
67
+
await linkEmail({ address, label });
68
68
+
emailInput = '';
69
69
+
emailLabel = '';
70
70
+
});
71
71
+
}
72
72
+
73
73
+
function submitCode(emailId: string) {
74
74
+
const code = (codeInputs[emailId] ?? '').trim();
75
75
+
if (!/^\d{6}$/.test(code)) return;
76
76
+
run(`email-verify:${emailId}`, async () => {
77
77
+
const { verified } = await verifyEmail({ code });
78
78
+
if (!verified) throw new Error('That code is invalid or expired.');
79
79
+
codeInputs[emailId] = '';
80
80
+
});
81
81
+
}
82
82
+
83
83
+
// Webhook: enter a URL + label; the relay POSTs notifications to it.
84
84
+
let webhookUrl = $state('');
85
85
+
let webhookLabel = $state('');
86
86
+
87
87
+
function submitWebhook() {
88
88
+
const url = webhookUrl.trim();
89
89
+
const label = webhookLabel.trim();
90
90
+
if (!url || !label) return;
91
91
+
run('webhook', async () => {
92
92
+
await addWebhook({ url, label });
93
93
+
webhookUrl = '';
94
94
+
webhookLabel = '';
95
95
+
});
96
96
+
}
97
97
+
98
98
+
// Telegram: optional name typed before linking, carried through the handshake.
99
99
+
let telegramLabel = $state('');
100
100
+
101
101
+
async function connectTelegram() {
102
102
+
busy['link'] = true;
103
103
+
errorMsg = '';
104
104
+
try {
105
105
+
const label = telegramLabel.trim() || undefined;
106
106
+
const { deepLink } = await linkTelegram({ label });
107
107
+
telegramLabel = '';
108
108
+
window.open(deepLink, '_blank', 'noopener,noreferrer');
109
109
+
} catch (err) {
110
110
+
errorMsg = err instanceof Error ? err.message : 'Could not start linking';
111
111
+
} finally {
112
112
+
busy['link'] = false;
113
113
+
}
114
114
+
}
115
115
+
116
116
+
// Web push for THIS browser. The device list comes from the server; we track
117
117
+
// this browser's own subscription endpoint to mark it as "this device".
118
118
+
type PushState = 'loading' | 'unsupported' | 'ready';
119
119
+
let pushState = $state<PushState>('loading');
120
120
+
let currentEndpoint = $state<string | null>(null);
121
121
+
let deviceName = $state('');
122
122
+
123
123
+
const thisDeviceConnected = $derived(
124
124
+
currentEndpoint !== null && data.devices.some((d) => d.endpoint === currentEndpoint)
125
125
+
);
126
126
+
127
127
+
onMount(async () => {
128
128
+
if (!pushSupported()) {
129
129
+
pushState = 'unsupported';
130
130
+
return;
131
131
+
}
132
132
+
try {
133
133
+
currentEndpoint = (await currentSubscription())?.endpoint ?? null;
134
134
+
} catch {
135
135
+
currentEndpoint = null;
136
136
+
}
137
137
+
pushState = 'ready';
138
138
+
});
139
139
+
140
140
+
async function enablePush() {
141
141
+
busy['push'] = true;
142
142
+
errorMsg = '';
143
143
+
try {
144
144
+
const sub = await subscribe(deviceName.trim() || undefined);
145
145
+
await registerPush(sub);
146
146
+
currentEndpoint = sub.endpoint;
147
147
+
deviceName = '';
148
148
+
await invalidateAll();
149
149
+
} catch (err) {
150
150
+
errorMsg = err instanceof Error ? err.message : 'Could not enable push';
151
151
+
} finally {
152
152
+
busy['push'] = false;
153
153
+
}
154
154
+
}
155
155
+
156
156
+
async function disableCurrent() {
157
157
+
busy['push'] = true;
158
158
+
errorMsg = '';
159
159
+
try {
160
160
+
const endpoint = await unsubscribe();
161
161
+
if (endpoint) await unregisterPush({ endpoint });
162
162
+
currentEndpoint = null;
163
163
+
await invalidateAll();
164
164
+
} catch (err) {
165
165
+
errorMsg = err instanceof Error ? err.message : 'Could not disable push';
166
166
+
} finally {
167
167
+
busy['push'] = false;
168
168
+
}
169
169
+
}
170
170
+
171
171
+
const sectionLabel = 'mb-2 font-mono text-[0.7rem] tracking-wide text-muted-2 uppercase';
172
172
+
const card = 'rounded-card border border-line bg-surface p-4';
173
173
+
const rowBtn = 'text-xs font-medium text-muted hover:text-fg';
174
174
+
const dangerBtn = 'text-xs font-medium text-danger hover:underline disabled:opacity-50';
175
175
+
const nameInput =
176
176
+
'min-w-0 rounded-md border border-line bg-surface-2 px-3 py-1.5 text-sm text-fg placeholder:text-muted-2 focus:border-accent';
177
177
+
const addBtn =
178
178
+
'shrink-0 rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition-opacity hover:opacity-90 disabled:opacity-50';
179
179
+
</script>
180
180
+
181
181
+
<!-- A reusable inline-rename row: shows the rename editor for `id`, else `display`. -->
182
182
+
{#snippet renameEditor(id: string, ariaLabel: string)}
183
183
+
<input
184
184
+
class="min-w-0 flex-1 rounded-md border border-line bg-surface-2 px-2 py-1 text-sm text-fg focus:border-accent"
185
185
+
bind:value={renameLabel}
186
186
+
aria-label={ariaLabel}
187
187
+
onkeydown={(e) => {
188
188
+
if (e.key === 'Enter') saveRename(id);
189
189
+
else if (e.key === 'Escape') renaming = null;
190
190
+
}}
191
191
+
/>
192
192
+
<div class="flex shrink-0 items-center gap-3">
193
193
+
<button class="text-xs font-medium text-accent" onclick={() => saveRename(id)}>Save</button>
194
194
+
<button class={rowBtn} onclick={() => (renaming = null)}>Cancel</button>
195
195
+
</div>
196
196
+
{/snippet}
197
197
+
198
198
+
<DefaultRoutePrompt defaultRoute={data.defaultRoute} channels={data.channels} />
199
199
+
200
200
+
<p class="mt-6 max-w-2xl text-sm text-muted">
201
201
+
Where atmo.pub can reach you. You can connect several of each and give them names; apps and
202
202
+
categories route to one or more of these — everything also lands in your inbox.
203
203
+
</p>
204
204
+
205
205
+
<div
206
206
+
class="mt-4 flex max-w-2xl items-start gap-2.5 rounded-card border border-line bg-surface-2 px-3 py-2.5"
207
207
+
>
208
208
+
<Icon name="info" size={16} stroke={2} class="mt-0.5 shrink-0 text-muted-2" />
209
209
+
<p class="text-xs text-muted">
210
210
+
Apps you connect can see the <span class="font-medium text-fg">names</span> you give these channels
211
211
+
(so they can show you a routing picker) — but never the email address, phone number, or device behind
212
212
+
them.
213
213
+
</p>
214
214
+
</div>
215
215
+
216
216
+
{#if errorMsg}
217
217
+
<p
218
218
+
class="mt-4 max-w-2xl rounded-card border border-line bg-danger/10 px-3 py-2 text-sm text-danger"
219
219
+
role="alert"
220
220
+
>
221
221
+
{errorMsg}
222
222
+
</p>
223
223
+
{/if}
224
224
+
225
225
+
<!-- Push devices -->
226
226
+
<section class="mt-4 max-w-2xl">
227
227
+
<div class="flex items-center justify-between">
228
228
+
<h2 class={sectionLabel}>Push devices</h2>
229
229
+
{#if data.devices.length > 0}
230
230
+
<span class="font-mono text-[0.7rem] text-muted-2">
231
231
+
{data.devices.length} device{data.devices.length === 1 ? '' : 's'}
232
232
+
</span>
233
233
+
{/if}
234
234
+
</div>
235
235
+
<div class={card}>
236
236
+
<div class="mb-3 flex items-center gap-2.5">
237
237
+
<RouteChip route="push" size="md" />
238
238
+
<span class="text-sm font-semibold text-fg">Web push</span>
239
239
+
</div>
240
240
+
241
241
+
{#if data.devices.length > 0}
242
242
+
<ul class="divide-y divide-line-2">
243
243
+
{#each data.devices as d (d.id)}
244
244
+
<li class="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0">
245
245
+
{#if renaming === d.id}
246
246
+
{@render renameEditor(d.id, 'Device name')}
247
247
+
{:else}
248
248
+
<div class="min-w-0 flex-1">
249
249
+
<div class="flex items-center gap-2">
250
250
+
<span class="truncate text-sm font-medium text-fg">{d.label}</span>
251
251
+
{#if d.endpoint === currentEndpoint}
252
252
+
<span
253
253
+
class="rounded bg-accent-soft px-1.5 py-0.5 font-mono text-[0.6rem] font-bold tracking-wide text-accent uppercase"
254
254
+
>
255
255
+
This device
256
256
+
</span>
257
257
+
{/if}
258
258
+
</div>
259
259
+
<div class="mt-0.5 font-mono text-[0.65rem] text-muted-2">
260
260
+
added <RelativeTime date={d.createdAt} />
261
261
+
</div>
262
262
+
</div>
263
263
+
<div class="flex shrink-0 items-center gap-3">
264
264
+
<button class={rowBtn} onclick={() => startRename(d.id, d.label)}>Rename</button>
265
265
+
{#if d.endpoint === currentEndpoint}
266
266
+
<button class={dangerBtn} disabled={busy['push']} onclick={disableCurrent}>
267
267
+
Disable
268
268
+
</button>
269
269
+
{:else}
270
270
+
<button
271
271
+
class={dangerBtn}
272
272
+
disabled={busy[`rm:${d.id}`]}
273
273
+
onclick={() => run(`rm:${d.id}`, () => removeTarget({ id: d.id }))}
274
274
+
>
275
275
+
Remove
276
276
+
</button>
277
277
+
{/if}
278
278
+
</div>
279
279
+
{/if}
280
280
+
</li>
281
281
+
{/each}
282
282
+
</ul>
283
283
+
{/if}
284
284
+
285
285
+
{#if pushState === 'loading'}
286
286
+
<p class="text-xs text-muted-2">Checking this browser…</p>
287
287
+
{:else if pushState === 'unsupported'}
288
288
+
<p class="text-xs text-muted">Push isn't available in this browser.</p>
289
289
+
{:else if !thisDeviceConnected}
290
290
+
{@const reconnect = currentEndpoint !== null}
291
291
+
<div class={data.devices.length > 0 ? 'mt-3 border-t border-line-2 pt-3' : ''}>
292
292
+
<p class="mb-2 text-xs text-muted">
293
293
+
{#if reconnect}
294
294
+
This browser was subscribed but isn't linked to your account anymore — reconnect it.
295
295
+
{:else}
296
296
+
Get notifications in this browser, even when it's closed.
297
297
+
{/if}
298
298
+
</p>
299
299
+
<div class="flex flex-col gap-2 sm:flex-row">
300
300
+
{#if !reconnect}
301
301
+
<input
302
302
+
bind:value={deviceName}
303
303
+
placeholder="Device name (optional)"
304
304
+
class="{nameInput} sm:w-52"
305
305
+
onkeydown={(e) => {
306
306
+
if (e.key === 'Enter') enablePush();
307
307
+
}}
308
308
+
/>
309
309
+
{/if}
310
310
+
<button class={addBtn} disabled={busy['push']} onclick={enablePush}>
311
311
+
{busy['push']
312
312
+
? 'Enabling…'
313
313
+
: reconnect
314
314
+
? 'Reconnect this device'
315
315
+
: 'Enable on this device'}
316
316
+
</button>
317
317
+
</div>
318
318
+
</div>
319
319
+
{/if}
320
320
+
</div>
321
321
+
</section>
322
322
+
323
323
+
<!-- Telegram -->
324
324
+
<section class="mt-6 max-w-2xl">
325
325
+
<h2 class={sectionLabel}>Telegram</h2>
326
326
+
<div class={card}>
327
327
+
<div class="mb-3 flex items-center gap-2.5">
328
328
+
<RouteChip route="telegram" size="md" />
329
329
+
<span class="text-sm font-semibold text-fg">Telegram DM</span>
330
330
+
</div>
331
331
+
332
332
+
{#if data.telegrams.length > 0}
333
333
+
<ul class="mb-3 divide-y divide-line-2">
334
334
+
{#each data.telegrams as t (t.id)}
335
335
+
<li class="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0">
336
336
+
{#if renaming === t.id}
337
337
+
{@render renameEditor(t.id, 'Chat name')}
338
338
+
{:else}
339
339
+
<div class="min-w-0 flex-1">
340
340
+
<div class="truncate text-sm font-medium text-fg">{t.label}</div>
341
341
+
<div class="mt-0.5 font-mono text-[0.65rem] text-muted-2">
342
342
+
linked <RelativeTime date={t.createdAt} />
343
343
+
</div>
344
344
+
</div>
345
345
+
<div class="flex shrink-0 items-center gap-3">
346
346
+
<button class={rowBtn} onclick={() => startRename(t.id, t.label)}>Rename</button>
347
347
+
<button
348
348
+
class={dangerBtn}
349
349
+
disabled={busy[`rm:${t.id}`]}
350
350
+
onclick={() => run(`rm:${t.id}`, () => removeTarget({ id: t.id }))}
351
351
+
>
352
352
+
Unlink
353
353
+
</button>
354
354
+
</div>
355
355
+
{/if}
356
356
+
</li>
357
357
+
{/each}
358
358
+
</ul>
359
359
+
{/if}
360
360
+
361
361
+
<div class={data.telegrams.length > 0 ? 'border-t border-line-2 pt-3' : ''}>
362
362
+
{#if data.telegrams.length === 0}
363
363
+
<p class="mb-2 text-xs text-muted">Link a Telegram chat to get notifications there.</p>
364
364
+
{/if}
365
365
+
<div class="flex flex-col gap-2 sm:flex-row">
366
366
+
<input
367
367
+
bind:value={telegramLabel}
368
368
+
placeholder="Name (optional)"
369
369
+
class="{nameInput} sm:w-52"
370
370
+
onkeydown={(e) => {
371
371
+
if (e.key === 'Enter') connectTelegram();
372
372
+
}}
373
373
+
/>
374
374
+
<button class={addBtn} disabled={busy['link']} onclick={connectTelegram}>
375
375
+
{busy['link'] ? 'Opening…' : data.telegrams.length > 0 ? 'Link another' : 'Link'}
376
376
+
</button>
377
377
+
</div>
378
378
+
</div>
379
379
+
</div>
380
380
+
</section>
381
381
+
382
382
+
<!-- Bluesky DM -->
383
383
+
<section class="mt-6 max-w-2xl">
384
384
+
<h2 class={sectionLabel}>Bluesky DM</h2>
385
385
+
<div class={card}>
386
386
+
<div class="mb-3 flex items-center gap-2.5">
387
387
+
<RouteChip route="dm" size="md" />
388
388
+
<span class="text-sm font-semibold text-fg">Bluesky DM</span>
389
389
+
</div>
390
390
+
391
391
+
{#if data.dms.length > 0}
392
392
+
{#each data.dms as dm (dm.id)}
393
393
+
{#if renaming === dm.id}
394
394
+
<div class="flex items-center justify-between gap-3">
395
395
+
{@render renameEditor(dm.id, 'DM name')}
396
396
+
</div>
397
397
+
{:else}
398
398
+
<div class="flex items-center justify-between gap-3">
399
399
+
<div class="flex min-w-0 items-center gap-2">
400
400
+
<Icon name="check" size={16} stroke={2.4} class="shrink-0 text-accent" />
401
401
+
<span class="truncate text-sm font-medium text-fg">{dm.label}</span>
402
402
+
</div>
403
403
+
<div class="flex shrink-0 items-center gap-3">
404
404
+
<button class={rowBtn} onclick={() => startRename(dm.id, dm.label)}>Rename</button>
405
405
+
<button
406
406
+
class={dangerBtn}
407
407
+
disabled={busy[`rm:${dm.id}`]}
408
408
+
onclick={() => run(`rm:${dm.id}`, () => removeTarget({ id: dm.id }))}
409
409
+
>
410
410
+
Disable
411
411
+
</button>
412
412
+
</div>
413
413
+
</div>
414
414
+
<p class="mt-2 text-xs text-muted-2">
415
415
+
The atmo.pub bot will DM you on Bluesky. Make sure your Bluesky chat settings allow
416
416
+
messages from it.
417
417
+
</p>
418
418
+
{/if}
419
419
+
{/each}
420
420
+
{:else}
421
421
+
<p class="mb-2 text-xs text-muted">
422
422
+
Get notifications as a Bluesky direct message from the atmo.pub bot.
423
423
+
</p>
424
424
+
<button class={addBtn} disabled={busy['dm']} onclick={() => run('dm', () => enableDM())}>
425
425
+
{busy['dm'] ? 'Enabling…' : 'Enable'}
426
426
+
</button>
427
427
+
{/if}
428
428
+
</div>
429
429
+
</section>
430
430
+
431
431
+
<!-- Email (allowlisted — hidden unless the user's DID is whitelisted) -->
432
432
+
{#if data.emailEnabled}
433
433
+
<section class="mt-6 max-w-2xl">
434
434
+
<h2 class={sectionLabel}>Email</h2>
435
435
+
<div class={card}>
436
436
+
<div class="mb-3 flex items-center gap-2.5">
437
437
+
<RouteChip route="email" size="md" />
438
438
+
<span class="text-sm font-semibold text-fg">Email</span>
439
439
+
</div>
440
440
+
441
441
+
{#if data.emails.length > 0}
442
442
+
<ul class="mb-3 divide-y divide-line-2">
443
443
+
{#each data.emails as e (e.id)}
444
444
+
<li class="py-3 first:pt-0">
445
445
+
{#if renaming === e.id}
446
446
+
<div class="flex items-center justify-between gap-3">
447
447
+
{@render renameEditor(e.id, 'Email name')}
448
448
+
</div>
449
449
+
{:else}
450
450
+
<div class="flex items-center justify-between gap-3">
451
451
+
<div class="flex min-w-0 items-center gap-2">
452
452
+
{#if e.verified}
453
453
+
<Icon name="check" size={16} stroke={2.4} class="shrink-0 text-accent" />
454
454
+
{/if}
455
455
+
<span class="truncate text-sm font-medium text-fg">{e.label}</span>
456
456
+
</div>
457
457
+
<div class="flex shrink-0 items-center gap-3">
458
458
+
<button class={rowBtn} onclick={() => startRename(e.id, e.label)}>Rename</button>
459
459
+
<button
460
460
+
class={dangerBtn}
461
461
+
disabled={busy[`rm:${e.id}`]}
462
462
+
onclick={() => run(`rm:${e.id}`, () => removeTarget({ id: e.id }))}
463
463
+
>
464
464
+
Remove
465
465
+
</button>
466
466
+
</div>
467
467
+
</div>
468
468
+
{#if e.label !== e.address}
469
469
+
<div class="mt-0.5 truncate font-mono text-[0.65rem] text-muted-2">{e.address}</div>
470
470
+
{/if}
471
471
+
{#if !e.verified}
472
472
+
<div class="mt-1 text-xs text-warn">
473
473
+
Pending — enter the 6-digit code we emailed you.
474
474
+
</div>
475
475
+
<div class="mt-2 flex gap-2">
476
476
+
<input
477
477
+
inputmode="numeric"
478
478
+
maxlength="6"
479
479
+
bind:value={codeInputs[e.id]}
480
480
+
placeholder="123456"
481
481
+
class="w-28 rounded-md border border-line bg-surface-2 px-3 py-1.5 text-sm tracking-widest text-fg placeholder:text-muted-2 focus:border-accent"
482
482
+
/>
483
483
+
<button
484
484
+
class={addBtn}
485
485
+
disabled={busy[`email-verify:${e.id}`]}
486
486
+
onclick={() => submitCode(e.id)}
487
487
+
>
488
488
+
{busy[`email-verify:${e.id}`] ? 'Verifying…' : 'Verify'}
489
489
+
</button>
490
490
+
</div>
491
491
+
{/if}
492
492
+
{/if}
493
493
+
</li>
494
494
+
{/each}
495
495
+
</ul>
496
496
+
{/if}
497
497
+
498
498
+
<div class={data.emails.length > 0 ? 'border-t border-line-2 pt-3' : ''}>
499
499
+
{#if data.emails.length === 0}
500
500
+
<p class="mb-2 text-xs text-muted">Get notifications by email.</p>
501
501
+
{/if}
502
502
+
<div class="flex flex-col gap-2 sm:flex-row">
503
503
+
<input
504
504
+
bind:value={emailLabel}
505
505
+
placeholder="Name (optional)"
506
506
+
class="{nameInput} sm:w-40"
507
507
+
onkeydown={(e) => {
508
508
+
if (e.key === 'Enter') submitEmail();
509
509
+
}}
510
510
+
/>
511
511
+
<input
512
512
+
type="email"
513
513
+
bind:value={emailInput}
514
514
+
placeholder="you@example.com"
515
515
+
class="{nameInput} flex-1"
516
516
+
onkeydown={(e) => {
517
517
+
if (e.key === 'Enter') submitEmail();
518
518
+
}}
519
519
+
/>
520
520
+
<button class={addBtn} disabled={busy['email']} onclick={submitEmail}>
521
521
+
{busy['email'] ? 'Sending…' : data.emails.length > 0 ? 'Add' : 'Send code'}
522
522
+
</button>
523
523
+
</div>
524
524
+
</div>
525
525
+
</div>
526
526
+
</section>
527
527
+
{/if}
528
528
+
529
529
+
<!-- Webhooks -->
530
530
+
<section class="mt-6 max-w-2xl">
531
531
+
<h2 class={sectionLabel}>Webhooks</h2>
532
532
+
<div class={card}>
533
533
+
<div class="mb-3 flex items-center gap-2.5">
534
534
+
<RouteChip route="webhook" size="md" />
535
535
+
<span class="text-sm font-semibold text-fg">Webhook</span>
536
536
+
</div>
537
537
+
538
538
+
{#if data.webhooks.length > 0}
539
539
+
<ul class="mb-3 divide-y divide-line-2">
540
540
+
{#each data.webhooks as w (w.id)}
541
541
+
<li class="py-3 first:pt-0">
542
542
+
{#if renaming === w.id}
543
543
+
<div class="flex items-center justify-between gap-3">
544
544
+
{@render renameEditor(w.id, 'Webhook name')}
545
545
+
</div>
546
546
+
{:else}
547
547
+
<div class="flex items-center justify-between gap-3">
548
548
+
<span class="truncate text-sm font-medium text-fg">{w.label}</span>
549
549
+
<div class="flex shrink-0 items-center gap-3">
550
550
+
<button class={rowBtn} onclick={() => startRename(w.id, w.label)}>Rename</button>
551
551
+
<button
552
552
+
class={dangerBtn}
553
553
+
disabled={busy[`rm:${w.id}`]}
554
554
+
onclick={() => run(`rm:${w.id}`, () => removeTarget({ id: w.id }))}
555
555
+
>
556
556
+
Remove
557
557
+
</button>
558
558
+
</div>
559
559
+
</div>
560
560
+
<div class="mt-0.5 truncate font-mono text-[0.65rem] text-muted-2">{w.url}</div>
561
561
+
{/if}
562
562
+
</li>
563
563
+
{/each}
564
564
+
</ul>
565
565
+
{/if}
566
566
+
567
567
+
<div class={data.webhooks.length > 0 ? 'border-t border-line-2 pt-3' : ''}>
568
568
+
{#if data.webhooks.length === 0}
569
569
+
<p class="mb-2 text-xs text-muted">
570
570
+
Send notifications to your own server. We POST JSON ({'{'} title, body, uri, sender, sentAt
571
571
+
{'}'}) to the URL over HTTPS.
572
572
+
</p>
573
573
+
{/if}
574
574
+
<div class="flex flex-col gap-2 sm:flex-row">
575
575
+
<input
576
576
+
bind:value={webhookLabel}
577
577
+
placeholder="Label (e.g. My server)"
578
578
+
class="{nameInput} sm:w-44"
579
579
+
onkeydown={(e) => {
580
580
+
if (e.key === 'Enter') submitWebhook();
581
581
+
}}
582
582
+
/>
583
583
+
<input
584
584
+
type="url"
585
585
+
inputmode="url"
586
586
+
bind:value={webhookUrl}
587
587
+
placeholder="https://example.com/hook"
588
588
+
class="{nameInput} flex-1"
589
589
+
onkeydown={(e) => {
590
590
+
if (e.key === 'Enter') submitWebhook();
591
591
+
}}
592
592
+
/>
593
593
+
<button class={addBtn} disabled={busy['webhook']} onclick={submitWebhook}>
594
594
+
{busy['webhook'] ? 'Adding…' : 'Add'}
595
595
+
</button>
596
596
+
</div>
597
597
+
</div>
598
598
+
</div>
599
599
+
</section>
···
1
1
+
<script lang="ts">
2
2
+
// Nudge the user to pick a real default route when their account default would
3
3
+
// reach NONE of their connected channels — i.e. a fresh 'inbox' default, or a
4
4
+
// stale one like 'push' when they have no push device. Shows on the Channels
5
5
+
// tab once at least one channel is connected. Only the explicit "Not now"
6
6
+
// persists (localStorage); backdrop/Escape just close it for this session, so a
7
7
+
// stray click can't hide it forever.
8
8
+
import { onMount } from 'svelte';
9
9
+
import { invalidateAll } from '$app/navigation';
10
10
+
import { CHANNELS, channelsRoute, routeChannels, type RouteInstances } from '@atmo/notifs-lexicons';
11
11
+
import { channelLabel } from '$lib/routes';
12
12
+
import { setDefaultRoute } from '$lib/remote/notifs.remote';
13
13
+
14
14
+
let { defaultRoute, channels }: { defaultRoute: string; channels: RouteInstances } = $props();
15
15
+
16
16
+
const DISMISS_KEY = 'atmo:defaultRoutePrompt:dismissed:v2';
17
17
+
18
18
+
let mounted = $state(false);
19
19
+
let dismissed = $state(false); // persisted "Not now"
20
20
+
let closed = $state(false); // session-only (backdrop / Escape)
21
21
+
let busy = $state(false);
22
22
+
23
23
+
onMount(() => {
24
24
+
dismissed = localStorage.getItem(DISMISS_KEY) === '1';
25
25
+
mounted = true;
26
26
+
});
27
27
+
28
28
+
// Channels the user has at least one (verified) target for.
29
29
+
const available = $derived(CHANNELS.filter((c) => (channels[c]?.length ?? 0) > 0));
30
30
+
// The default delivers nowhere useful: none of its channels is one they have.
31
31
+
// (Empty 'inbox'/'' → reaches none; 'off' is a deliberate "drop", so leave it.)
32
32
+
const reachesNone = $derived(
33
33
+
defaultRoute !== 'off' && routeChannels(defaultRoute).every((c) => !available.includes(c))
34
34
+
);
35
35
+
const visible = $derived(
36
36
+
mounted && !dismissed && !closed && available.length > 0 && reachesNone
37
37
+
);
38
38
+
39
39
+
const label = $derived(
40
40
+
available.length === 1 ? channelLabel(available[0]) : 'your connected channels'
41
41
+
);
42
42
+
43
43
+
function notNow() {
44
44
+
dismissed = true;
45
45
+
localStorage.setItem(DISMISS_KEY, '1');
46
46
+
}
47
47
+
48
48
+
async function setAsDefault() {
49
49
+
busy = true;
50
50
+
try {
51
51
+
await setDefaultRoute({ route: channelsRoute(available) });
52
52
+
await invalidateAll(); // default now reaches a channel → `visible` becomes false
53
53
+
} finally {
54
54
+
busy = false;
55
55
+
}
56
56
+
}
57
57
+
</script>
58
58
+
59
59
+
<svelte:window onkeydown={(e) => visible && e.key === 'Escape' && (closed = true)} />
60
60
+
61
61
+
{#if visible}
62
62
+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
63
63
+
<div
64
64
+
class="fixed inset-0 z-50 flex items-end justify-center bg-black/40 p-4 sm:items-center"
65
65
+
onclick={() => (closed = true)}
66
66
+
>
67
67
+
<div
68
68
+
role="dialog"
69
69
+
aria-modal="true"
70
70
+
aria-labelledby="drp-title"
71
71
+
tabindex="-1"
72
72
+
class="w-full max-w-sm rounded-card border border-line bg-surface p-5 shadow-lg"
73
73
+
onclick={(e) => e.stopPropagation()}
74
74
+
>
75
75
+
<h2 id="drp-title" class="text-base font-semibold text-fg">Set a default route?</h2>
76
76
+
<p class="mt-2 text-sm leading-relaxed text-muted">
77
77
+
Right now notifications won't alert you anywhere — they only land in your inbox. Send them to
78
78
+
<span class="font-medium text-fg">{label}</span> by default? You can change this anytime in
79
79
+
Settings → Routing.
80
80
+
</p>
81
81
+
<div class="mt-4 flex justify-end gap-2">
82
82
+
<button
83
83
+
type="button"
84
84
+
onclick={notNow}
85
85
+
disabled={busy}
86
86
+
class="rounded-md border border-line px-3 py-1.5 text-sm font-medium text-fg hover:bg-surface-2 disabled:opacity-50"
87
87
+
>
88
88
+
Not now
89
89
+
</button>
90
90
+
<button
91
91
+
type="button"
92
92
+
onclick={setAsDefault}
93
93
+
disabled={busy}
94
94
+
class="rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition-opacity hover:opacity-90 disabled:opacity-50"
95
95
+
>
96
96
+
{busy ? 'Saving…' : `Send to ${label}`}
97
97
+
</button>
98
98
+
</div>
99
99
+
</div>
100
100
+
</div>
101
101
+
{/if}
···
1
1
+
<script lang="ts">
2
2
+
import Icon from '$lib/components/Icon.svelte';
3
3
+
import { DOCS_URL } from '$lib/config';
4
4
+
</script>
5
5
+
6
6
+
<section class="mt-6 max-w-2xl">
7
7
+
<h2 class="mb-2 font-mono text-[0.7rem] tracking-wide text-muted-2 uppercase">Developer docs</h2>
8
8
+
<div class="rounded-card border border-line bg-surface p-4">
9
9
+
<p class="text-sm text-muted">
10
10
+
Building an app? Send notifications through atmo.pub — request permission, send, and let users
11
11
+
manage routing — over a small XRPC API. The full guide, with code, lives on the docs site.
12
12
+
</p>
13
13
+
<a
14
14
+
href={DOCS_URL}
15
15
+
target="_blank"
16
16
+
rel="noreferrer"
17
17
+
class="mt-4 inline-flex items-center gap-2 rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition-opacity hover:opacity-90"
18
18
+
>
19
19
+
Open developer docs
20
20
+
<Icon name="arrow-right" size={16} stroke={2} />
21
21
+
</a>
22
22
+
</div>
23
23
+
</section>
···
1
1
+
<script lang="ts">
2
2
+
import { invalidateAll } from '$app/navigation';
3
3
+
import ChannelRoutePicker from '$lib/components/ChannelRoutePicker.svelte';
4
4
+
import Icon from '$lib/components/Icon.svelte';
5
5
+
import { setAutoAllow, setPendingRoute } from '$lib/remote/notifs.remote';
6
6
+
import type { PageData } from './$types';
7
7
+
8
8
+
let { data }: { data: PageData } = $props();
9
9
+
10
10
+
let busy = $state<Record<string, boolean>>({});
11
11
+
let errorMsg = $state('');
12
12
+
13
13
+
async function run(key: string, fn: () => Promise<unknown>) {
14
14
+
busy[key] = true;
15
15
+
errorMsg = '';
16
16
+
try {
17
17
+
await fn();
18
18
+
await invalidateAll();
19
19
+
} catch (err) {
20
20
+
errorMsg = err instanceof Error ? err.message : 'Something went wrong';
21
21
+
} finally {
22
22
+
busy[key] = false;
23
23
+
}
24
24
+
}
25
25
+
26
26
+
const autoAllowOptions = [
27
27
+
{ value: 'all', label: 'All apps', desc: 'Any app that asks is approved automatically.' },
28
28
+
{
29
29
+
value: 'trusted',
30
30
+
label: 'Trusted apps only',
31
31
+
desc: 'A small built-in allowlist is auto-approved; everything else waits for you.'
32
32
+
},
33
33
+
{ value: 'none', label: 'No apps', desc: 'Every request waits for your approval in Apps.' }
34
34
+
] as const;
35
35
+
36
36
+
const sectionLabel = 'mb-2 font-mono text-[0.7rem] tracking-wide text-muted-2 uppercase';
37
37
+
</script>
38
38
+
39
39
+
<div class="mt-6 flex max-w-2xl flex-col gap-8">
40
40
+
{#if errorMsg}
41
41
+
<p
42
42
+
class="rounded-card border border-line bg-danger/10 px-3 py-2 text-sm text-danger"
43
43
+
role="alert"
44
44
+
>
45
45
+
{errorMsg}
46
46
+
</p>
47
47
+
{/if}
48
48
+
49
49
+
<!-- Automatic approval -->
50
50
+
<section>
51
51
+
<h2 class={sectionLabel}>Allow apps automatically</h2>
52
52
+
<p class="mb-2 text-sm text-muted">
53
53
+
When an app asks to notify you, decide whether it's approved automatically or waits for you in
54
54
+
Apps.
55
55
+
</p>
56
56
+
<div class="overflow-hidden rounded-card border border-line bg-surface">
57
57
+
{#each autoAllowOptions as opt, i (opt.value)}
58
58
+
<button
59
59
+
type="button"
60
60
+
disabled={busy['autoAllow']}
61
61
+
onclick={() => run('autoAllow', () => setAutoAllow({ autoAllow: opt.value }))}
62
62
+
class="flex w-full items-start justify-between gap-3 p-4 text-left transition-colors hover:bg-surface-2 disabled:opacity-50 {i <
63
63
+
autoAllowOptions.length - 1
64
64
+
? 'border-b border-line-2'
65
65
+
: ''}"
66
66
+
>
67
67
+
<div class="min-w-0">
68
68
+
<div class="text-sm font-semibold text-fg">{opt.label}</div>
69
69
+
<div class="mt-0.5 text-xs text-muted">{opt.desc}</div>
70
70
+
</div>
71
71
+
{#if data.autoAllow === opt.value}
72
72
+
<Icon name="check" size={18} stroke={2.4} class="shrink-0 text-accent" />
73
73
+
{/if}
74
74
+
</button>
75
75
+
{/each}
76
76
+
</div>
77
77
+
</section>
78
78
+
79
79
+
<!-- Where permission requests are sent -->
80
80
+
<section>
81
81
+
<h2 class={sectionLabel}>Alert me about requests</h2>
82
82
+
<div class="rounded-card border border-line bg-surface p-4">
83
83
+
<div class="text-sm font-medium text-fg">Where new permission requests are sent</div>
84
84
+
<p class="mt-1 mb-3 text-xs text-muted">
85
85
+
Pick the channels to ping when an app asks for permission (<span class="font-medium text-fg"
86
86
+
>Off</span
87
87
+
>
88
88
+
for none). You can always review and approve them in
89
89
+
<a href="/apps" class="text-accent hover:underline">Apps</a>.
90
90
+
</p>
91
91
+
<ChannelRoutePicker
92
92
+
value={data.pendingRoute}
93
93
+
instances={data.channels}
94
94
+
allowInbox={false}
95
95
+
disabled={busy['pending']}
96
96
+
onchange={(route) => run('pending', () => setPendingRoute({ route }))}
97
97
+
/>
98
98
+
</div>
99
99
+
</section>
100
100
+
</div>
···
1
1
+
<script lang="ts">
2
2
+
import { invalidateAll } from '$app/navigation';
3
3
+
import ChannelRoutePicker from '$lib/components/ChannelRoutePicker.svelte';
4
4
+
import { setDefaultRoute } from '$lib/remote/notifs.remote';
5
5
+
import type { PageData } from './$types';
6
6
+
7
7
+
let { data }: { data: PageData } = $props();
8
8
+
9
9
+
let busy = $state(false);
10
10
+
let errorMsg = $state('');
11
11
+
12
12
+
async function save(route: string) {
13
13
+
busy = true;
14
14
+
errorMsg = '';
15
15
+
try {
16
16
+
await setDefaultRoute({ route });
17
17
+
await invalidateAll();
18
18
+
} catch (err) {
19
19
+
errorMsg = err instanceof Error ? err.message : 'Something went wrong';
20
20
+
} finally {
21
21
+
busy = false;
22
22
+
}
23
23
+
}
24
24
+
</script>
25
25
+
26
26
+
<section class="mt-6 max-w-2xl">
27
27
+
<h2 class="mb-2 font-mono text-[0.7rem] tracking-wide text-muted-2 uppercase">Default routing</h2>
28
28
+
{#if errorMsg}
29
29
+
<p
30
30
+
class="mb-3 rounded-card border border-line bg-danger/10 px-3 py-2 text-sm text-danger"
31
31
+
role="alert"
32
32
+
>
33
33
+
{errorMsg}
34
34
+
</p>
35
35
+
{/if}
36
36
+
<div class="rounded-card border border-line bg-surface p-4">
37
37
+
<div class="text-sm font-medium text-fg">Where notifications go by default</div>
38
38
+
<p class="mt-1 text-xs text-muted">
39
39
+
Pick where notifications go by default. <span class="font-medium text-fg">Inbox only</span>
40
40
+
saves them with no alerts; <span class="font-medium text-fg">Off</span> drops them entirely.
41
41
+
Apps set to “Account default” use this; override per-app (and per-category) from
42
42
+
<a href="/apps" class="text-accent hover:underline">Apps</a>.
43
43
+
</p>
44
44
+
<div class="mt-3">
45
45
+
<ChannelRoutePicker
46
46
+
value={data.defaultRoute}
47
47
+
instances={data.channels}
48
48
+
disabled={busy}
49
49
+
onchange={save}
50
50
+
/>
51
51
+
</div>
52
52
+
</div>
53
53
+
</section>
···
16
16
│ ├── relay/
17
17
│ │ ├── wrangler.toml
18
18
│ │ ├── vitest.config.ts
19
19
-
│ │ ├── migrations/ # 0001_init.sql, 0002_request_metadata.sql
19
19
+
│ │ ├── migrations/ # 0001_init.sql (consolidated canonical schema)
20
20
│ │ ├── src/
21
21
│ │ │ ├── index.ts # default export { fetch, queue, scheduled }
22
22
│ │ │ ├── env.ts # Env + DispatchJob + AppContext types
23
23
│ │ │ ├── router.ts # builds the XRPCRouter, wires handlers
24
24
│ │ │ ├── auth/ # verifier.ts, sender.ts, user.ts
25
25
│ │ │ ├── xrpc/ # one file per procedure/query
26
26
-
│ │ │ ├── delivery/ # telegram.ts (API), dispatcher.ts (queue consumer)
26
26
+
│ │ │ ├── delivery/ # dispatcher.ts (queue consumer) + one module per channel
27
27
+
│ │ │ │ # (webpush, telegram, email, bluesky-dm, webhook) + channel/limits
27
28
│ │ │ ├── telegram/ # webhook.ts, commands.ts, callbacks.ts
28
28
-
│ │ │ ├── db/ # schema.sql, queries.ts
29
29
+
│ │ │ ├── db/ # queries.ts (schema: migrations/0001_init.sql)
29
30
│ │ │ ├── identity/resolve.ts
30
31
│ │ │ ├── profile/fetch.ts
31
32
│ │ │ ├── ratelimit.ts
···
40
41
│ │ ├── hooks.server.ts # = atproto.handle (@svelte-atproto/oauth)
41
42
│ │ ├── lib/atproto/ # OAuth client config + oauth.remote.ts (login/logout)
42
43
│ │ ├── lib/server/relay.ts # calls the relay as the signed-in user
43
43
-
│ │ └── routes/ # landing, /dashboard, /docs
44
44
+
│ │ └── routes/ # (app)/ dashboard: apps, settings, inbox
45
45
+
│ ├── homepage/ # marketing landing + developer docs (docs.atmo.pub)
46
46
+
│ │ └── static/llms.txt # LLM-oriented integration guide
44
47
│ └── example-sender/ # one-page sender demo (example.atmo.pub)
45
48
│ ├── wrangler.jsonc
46
49
│ ├── scripts/generate-keys.js # P-256 keypair for `send` (sender:keygen)
···
124
124
125
125
| Capability | Methods |
126
126
|---|---|
127
127
-
| **`self`-read** | `getRouting` (own slice), `listNotifications` (own) |
128
128
-
| **`self`-write** | `setRouting`, `setAppRouting`, `markRead` (own), `revokeSelf`, `muteSelf` |
129
129
-
| **`full`-read** | `listGrants`, `listPending`, `listChannels`, `getSettings`, `listDevices`, `getRouting` (full config), `listNotifications` (all) |
130
130
-
| **`full`-write** | `grant`, `revoke` (any), `denyPending`, `muteGrant` (any), `linkChannel`, `unlinkChannel`, `updateSettings`, `registerWebPush`, `unregisterWebPush`, `renameDevice`, `setDefaultRoute` |
127
127
+
| **`self`-read** | `getRouting` (own slice + target catalog), `getCategories`, `listNotifications` (own) |
128
128
+
| **`self`-write** | `setRouting`, `setCategories`, `addCategory`, `removeCategory`, `markRead` (own), `revokeSelf`, `muteSelf` |
129
129
+
| **`full`-read** | `listGrants`, `listPending`, `getSettings`, `listTargets`, `getRouting` (full config), `listNotifications` (all) |
130
130
+
| **`full`-write** | `grant`, `revoke` (any), `denyPending`, `muteGrant` (any), `linkChannel`, `linkEmail`, `verifyEmail`, `renameTarget`, `removeTarget`, `registerWebPush`, `unregisterWebPush`, `updateSettings`, `clearNotificationsFromSender`, `setRouting`/`setAppRouting`/`setDefaultRoute` (any) |
131
131
| **infrastructure** (not user-data-scoped) | `verifyAppLogin` (token is self-authorizing), `listApps` (static catalog) |
132
132
133
133
Notes:
134
134
- `getRouting` / `listNotifications` exist at **both** scopes — they're distinct
135
135
lexicons/handlers: the `self` ones return only the calling app's slice (already
136
136
built); the `full` ones return the whole account.
137
137
-
- `revokeSelf` / `muteSelf` are **new** `self`-write methods so an app can let the
138
138
-
user turn it off / mute it from inside the app (target is implicitly the caller —
139
139
-
safe under fact #2). Whole-account `revoke`/`muteGrant` (any target) stay `full`.
140
140
-
- `registerWebPush` is `full`: a device belongs to the user, not to one app, so
141
141
-
registering it is a dashboard action.
137
137
+
- `revokeSelf` / `muteSelf` let an app turn itself off / mute itself from inside the
138
138
+
app (target is implicitly the caller — safe under fact #2). Whole-account
139
139
+
`revoke`/`muteGrant` (any target) stay `full`.
140
140
+
- **Category management** (`setCategories`/`addCategory`/`removeCategory`/`getCategories`)
141
141
+
is `self`-only — an app declares *its own* categories, never another app's — so it
142
142
+
is intentionally **not** in the `full` (`manage`) envelope.
143
143
+
- A delivery target (push device / Telegram chat / email / DM / webhook) belongs to
144
144
+
the user, not one app: listing/renaming/removing/registering them is `full`
145
145
+
(`listTargets`, `renameTarget`, `removeTarget`, `registerWebPush`). `getRouting`'s
146
146
+
target catalog exposes only privacy-safe labels + opaque ids (no raw address/handle).
147
147
+
- First-party only (service binding, never federated): `addWebhook`, `enableDM`,
148
148
+
`setGrantManage` (the user designates capabilities; an app can't grant itself one).
142
149
143
150
## Why third-party `full` is safe despite fact #2
144
151
···
219
226
- `full` requires **manager designation**; never reachable by scope or per-call
220
227
consent alone.
221
228
- `self` is **own-slice forever** — sender from the app token, not the body.
222
222
-
- **Vouch** (no user token) demands standing designation; that's also the *only*
223
223
-
management path that works for lite sessions. Undesignated apps need a real
224
224
-
session (dual-auth).
229
229
+
- **Every XRPC management call is dual-auth** — app token *and* a fresh user token,
230
230
+
both scoped to the method. There is no token-less vouch path over XRPC (→ 403);
231
231
+
the only vouch is the first-party service binding (atmo.pub's own UI).
225
232
- Manager-key compromise = an attacker manages that manager's users — bounded and
226
233
rotatable via the DID doc; the same exposure atmo.pub already carries.
227
234
228
235
## Build checklist
229
236
230
230
-
- [x] grants: `manage` column (`0008`); `apps.ts` `manage?: 'self'|'full'` + `relayManageFor`;
237
237
+
- [x] grants: `manage` column (now in the consolidated `0001_init.sql`); `apps.ts`
238
238
+
`manage?: 'self'|'full'` + `relayManageFor`;
231
239
`MANAGEMENT_SELF_READ_POLICY` + `MANAGEMENT_SELF_WRITE_POLICY` env *(Slice 1)*
232
232
-
- [x] `verifyManagementCall` helper (app-auth + user-token/vouch + capability resolution) *(Slice 1)*
240
240
+
- [x] `verifyManagementCall` helper (app-auth + required user token + capability resolution) *(Slice 1)*
233
241
- [x] new `self`-write ops `revokeSelf` + `muteSelf`; retrofit the 4 self methods onto the helper *(Slice 1)*
234
242
- [x] `full` surface: the `pub.atmo.notify.manage` envelope → `ops.*` (binding kept as fast path) *(Slice 2)*
235
235
-
- [x] tests: capability matrix (self read/write/designated; full vouch + dual-auth + unknown method) *(Slices 1–2)*
243
243
+
- [x] tests: capability matrix (self read/write/designated; full dual-auth + missing-token 403 + unknown method) *(Slices 1–2)*
236
244
- [x] dashboard: per-app capability control on the app detail page (`manage` on
237
245
`RoutingApp` + `setGrantManage` binding/command + selector UI) *(Slice 3)*
238
246
- [~] dashboard: dedicated "your managers" view (list apps where `manage` != none) — optional; per-app control above already covers designation
···
1
1
# Run your own relay
2
2
3
3
Want notifications for your own app(s) without depending on atmo.pub? Run the
4
4
-
relay yourself. You get web push, Telegram, an inbox, and per-app/category
5
5
-
routing — for whatever apps you register. **It runs on Cloudflare Workers** (D1 +
4
4
+
relay yourself. You get web push, Telegram, email, Bluesky DM, webhooks, an inbox,
5
5
+
and per-app/category routing — for whatever apps you register. **It runs on
6
6
+
Cloudflare Workers** (D1 +
6
7
KV + Queues); that's the only supported target for now.
7
8
8
9
For deeper details see [DEVELOPMENT.md](DEVELOPMENT.md).
···
53
54
# Telegram (optional — skip if you only want web push):
54
55
pnpm exec wrangler secret put TELEGRAM_BOT_TOKEN
55
56
pnpm exec wrangler secret put TELEGRAM_WEBHOOK_SECRET
57
57
+
# Email (optional — via comail.at):
58
58
+
pnpm exec wrangler secret put COMAIL_API_KEY # atmos_… from comail.at
56
59
```
57
57
-
If you use Telegram, also set `BOT_USERNAME` in `[vars]`.
60
60
+
If you use Telegram, also set `BOT_USERNAME` in `[vars]`. If you use email, set
61
61
+
`COMAIL_DID` (the account DID for the `X-Atmos-DID` header) and `COMAIL_FROM` (an
62
62
+
enrolled sender address) in `[vars]`; the `EMAIL_DAILY_*` caps there default to
63
63
+
10 / recipient / day and 100 global / day — keep them under your comail plan.
64
64
+
Bluesky DM and webhook channels need no extra config (DM uses the relay's own
65
65
+
identity; webhooks are user-supplied URLs).
58
66
59
67
6. **Apply migrations & deploy**
60
68
```bash
···
1
1
+
{
2
2
+
"lexicon": 1,
3
3
+
"id": "pub.atmo.notify.addCategory",
4
4
+
"defs": {
5
5
+
"main": {
6
6
+
"type": "procedure",
7
7
+
"description": "Declare (add or update) one of the calling app's categories for a user, without disturbing the others. Self-scoped management write (app JWT + a fresh `userToken`, like setRouting). Requires an active grant.",
8
8
+
"input": {
9
9
+
"encoding": "application/json",
10
10
+
"schema": {
11
11
+
"type": "object",
12
12
+
"required": ["userToken", "id"],
13
13
+
"properties": {
14
14
+
"userToken": {
15
15
+
"type": "string",
16
16
+
"description": "A user-issued service-auth JWT (aud = relay DID, lxm = pub.atmo.notify.addCategory)."
17
17
+
},
18
18
+
"id": { "type": "string", "maxLength": 64, "description": "The category key (matches `category` in send)." },
19
19
+
"title": { "type": "string", "maxLength": 128, "description": "Human display name (e.g. a webhook's name)." },
20
20
+
"description": { "type": "string", "maxLength": 200 },
21
21
+
"route": {
22
22
+
"type": "string",
23
23
+
"description": "Optional initial route: a '+'-joined token set, 'off', 'inbox', or 'app' to inherit the app-wide route. Omit to leave routing unchanged."
24
24
+
}
25
25
+
}
26
26
+
}
27
27
+
},
28
28
+
"output": {
29
29
+
"encoding": "application/json",
30
30
+
"schema": {
31
31
+
"type": "object",
32
32
+
"required": ["ok"],
33
33
+
"properties": { "ok": { "type": "boolean" } }
34
34
+
}
35
35
+
},
36
36
+
"errors": [{ "name": "NotAuthorized" }]
37
37
+
}
38
38
+
}
39
39
+
}
···
1
1
+
{
2
2
+
"lexicon": 1,
3
3
+
"id": "pub.atmo.notify.getCategories",
4
4
+
"defs": {
5
5
+
"main": {
6
6
+
"type": "procedure",
7
7
+
"description": "List the calling app's categories for a user (its own catalog only — never another app's, never another user's), with each category's current route. Self-scoped management read (app JWT + a fresh `userToken`). Requires an active grant.",
8
8
+
"input": {
9
9
+
"encoding": "application/json",
10
10
+
"schema": {
11
11
+
"type": "object",
12
12
+
"required": ["userToken"],
13
13
+
"properties": {
14
14
+
"userToken": {
15
15
+
"type": "string",
16
16
+
"description": "A user-issued service-auth JWT (aud = relay DID, lxm = pub.atmo.notify.getCategories)."
17
17
+
}
18
18
+
}
19
19
+
}
20
20
+
},
21
21
+
"output": {
22
22
+
"encoding": "application/json",
23
23
+
"schema": {
24
24
+
"type": "object",
25
25
+
"required": ["categories"],
26
26
+
"properties": {
27
27
+
"categories": {
28
28
+
"type": "array",
29
29
+
"items": { "type": "ref", "ref": "#category" }
30
30
+
}
31
31
+
}
32
32
+
}
33
33
+
},
34
34
+
"errors": [{ "name": "NotAuthorized" }]
35
35
+
},
36
36
+
"category": {
37
37
+
"type": "object",
38
38
+
"required": ["id", "route"],
39
39
+
"properties": {
40
40
+
"id": { "type": "string", "description": "The category key." },
41
41
+
"title": { "type": "string", "description": "Human display name, if set." },
42
42
+
"description": { "type": "string", "description": "Description, if set." },
43
43
+
"route": {
44
44
+
"type": "string",
45
45
+
"description": "Current route for this category: a '+'-joined token set, 'off', 'inbox', or 'app' (inherit the app-wide route)."
46
46
+
}
47
47
+
}
48
48
+
}
49
49
+
}
50
50
+
}
···
67
67
"required": ["id", "route"],
68
68
"properties": {
69
69
"id": { "type": "string", "description": "The category key." },
70
70
+
"title": {
71
71
+
"type": "string",
72
72
+
"description": "Human display name for this category, if the app set one."
73
73
+
},
70
74
"description": {
71
75
"type": "string",
72
76
"description": "Human description last seen for this category, if any."
···
9
9
"encoding": "application/json",
10
10
"schema": {
11
11
"type": "object",
12
12
-
"required": ["notifyPendingViaTelegram", "autoAllow", "pushDevices"],
12
12
+
"required": ["pendingRoute", "autoAllow", "pushDevices"],
13
13
"properties": {
14
14
-
"notifyPendingViaTelegram": { "type": "boolean" },
14
14
+
"pendingRoute": {
15
15
+
"type": "string",
16
16
+
"description": "Where permission-request alerts go: a '+'-joined token set (push|telegram|email|dm|webhook, optionally channel:<id>) or 'off'."
17
17
+
},
15
18
"autoAllow": {
16
19
"type": "string",
17
20
"enum": ["all", "trusted", "none"],
···
1
1
-
{
2
2
-
"lexicon": 1,
3
3
-
"id": "pub.atmo.notify.listChannels",
4
4
-
"defs": {
5
5
-
"main": {
6
6
-
"type": "query",
7
7
-
"description": "List the delivery channels linked by the authenticated user. User-authenticated.",
8
8
-
"output": {
9
9
-
"encoding": "application/json",
10
10
-
"schema": {
11
11
-
"type": "object",
12
12
-
"required": ["channels"],
13
13
-
"properties": {
14
14
-
"channels": {
15
15
-
"type": "array",
16
16
-
"items": { "type": "ref", "ref": "#channelView" }
17
17
-
}
18
18
-
}
19
19
-
}
20
20
-
}
21
21
-
},
22
22
-
"channelView": {
23
23
-
"type": "object",
24
24
-
"required": ["platform", "linkedAt"],
25
25
-
"properties": {
26
26
-
"platform": { "type": "string" },
27
27
-
"linkedAt": { "type": "string", "format": "datetime" },
28
28
-
"displayName": { "type": "string" }
29
29
-
}
30
30
-
}
31
31
-
}
32
32
-
}
···
1
1
+
{
2
2
+
"lexicon": 1,
3
3
+
"id": "pub.atmo.notify.removeCategory",
4
4
+
"defs": {
5
5
+
"main": {
6
6
+
"type": "procedure",
7
7
+
"description": "Remove one of the calling app's categories for a user, along with its per-category routing. Self-scoped management write (app JWT + a fresh `userToken`). Requires an active grant. Past notifications keep their category string (they are historical).",
8
8
+
"input": {
9
9
+
"encoding": "application/json",
10
10
+
"schema": {
11
11
+
"type": "object",
12
12
+
"required": ["userToken", "id"],
13
13
+
"properties": {
14
14
+
"userToken": {
15
15
+
"type": "string",
16
16
+
"description": "A user-issued service-auth JWT (aud = relay DID, lxm = pub.atmo.notify.removeCategory)."
17
17
+
},
18
18
+
"id": { "type": "string", "maxLength": 64, "description": "The category key to remove." }
19
19
+
}
20
20
+
}
21
21
+
},
22
22
+
"output": {
23
23
+
"encoding": "application/json",
24
24
+
"schema": {
25
25
+
"type": "object",
26
26
+
"required": ["removed"],
27
27
+
"properties": { "removed": { "type": "boolean" } }
28
28
+
}
29
29
+
},
30
30
+
"errors": [{ "name": "NotAuthorized" }]
31
31
+
}
32
32
+
}
33
33
+
}
···
1
1
+
{
2
2
+
"lexicon": 1,
3
3
+
"id": "pub.atmo.notify.setCategories",
4
4
+
"defs": {
5
5
+
"main": {
6
6
+
"type": "procedure",
7
7
+
"description": "Replace the calling app's category catalog for a user (full sync). Listed categories are upserted (key + optional title/description/initial route); any the app previously had that are NOT listed are removed, along with their per-category routing. Categories are per (user, app) — never shared across users. Self-scoped management write: app JWT in the Authorization header + a fresh user-issued `userToken` (like setRouting). Requires an active grant.",
8
8
+
"input": {
9
9
+
"encoding": "application/json",
10
10
+
"schema": {
11
11
+
"type": "object",
12
12
+
"required": ["userToken", "categories"],
13
13
+
"properties": {
14
14
+
"userToken": {
15
15
+
"type": "string",
16
16
+
"description": "A user-issued service-auth JWT (aud = relay DID, lxm = pub.atmo.notify.setCategories)."
17
17
+
},
18
18
+
"categories": {
19
19
+
"type": "array",
20
20
+
"maxLength": 500,
21
21
+
"description": "The app's complete category set for this user. Anything omitted is removed.",
22
22
+
"items": { "type": "ref", "ref": "#categoryInput" }
23
23
+
}
24
24
+
}
25
25
+
}
26
26
+
},
27
27
+
"output": {
28
28
+
"encoding": "application/json",
29
29
+
"schema": {
30
30
+
"type": "object",
31
31
+
"required": ["ok"],
32
32
+
"properties": { "ok": { "type": "boolean" } }
33
33
+
}
34
34
+
},
35
35
+
"errors": [{ "name": "NotAuthorized" }]
36
36
+
},
37
37
+
"categoryInput": {
38
38
+
"type": "object",
39
39
+
"required": ["id"],
40
40
+
"properties": {
41
41
+
"id": { "type": "string", "maxLength": 64, "description": "The category key (matches `category` in send)." },
42
42
+
"title": { "type": "string", "maxLength": 128, "description": "Human display name (e.g. a webhook's name)." },
43
43
+
"description": { "type": "string", "maxLength": 200 },
44
44
+
"route": {
45
45
+
"type": "string",
46
46
+
"description": "Optional initial route: a '+'-joined token set, 'off', 'inbox', or 'app' to inherit the app-wide route. Omit to leave this category's routing unchanged."
47
47
+
}
48
48
+
}
49
49
+
}
50
50
+
}
51
51
+
}
···
1
1
-
{
2
2
-
"lexicon": 1,
3
3
-
"id": "pub.atmo.notify.unlinkChannel",
4
4
-
"defs": {
5
5
-
"main": {
6
6
-
"type": "procedure",
7
7
-
"description": "Unlink a delivery channel from the authenticated user. User-authenticated.",
8
8
-
"input": {
9
9
-
"encoding": "application/json",
10
10
-
"schema": {
11
11
-
"type": "object",
12
12
-
"required": ["platform"],
13
13
-
"properties": {
14
14
-
"platform": { "type": "string", "enum": ["telegram"] }
15
15
-
}
16
16
-
}
17
17
-
},
18
18
-
"output": {
19
19
-
"encoding": "application/json",
20
20
-
"schema": {
21
21
-
"type": "object",
22
22
-
"required": ["unlinked"],
23
23
-
"properties": {
24
24
-
"unlinked": { "type": "boolean" }
25
25
-
}
26
26
-
}
27
27
-
}
28
28
-
}
29
29
-
}
30
30
-
}
···
10
10
"schema": {
11
11
"type": "object",
12
12
"properties": {
13
13
-
"notifyPendingViaTelegram": { "type": "boolean" },
13
13
+
"pendingRoute": {
14
14
+
"type": "string",
15
15
+
"description": "Where permission-request alerts go: a '+'-joined token set or 'off'."
16
16
+
},
14
17
"autoAllow": {
15
18
"type": "string",
16
19
"enum": ["all", "trusted", "none"],
···
23
26
"encoding": "application/json",
24
27
"schema": {
25
28
"type": "object",
26
26
-
"required": ["notifyPendingViaTelegram"],
29
29
+
"required": ["pendingRoute"],
27
30
"properties": {
28
28
-
"notifyPendingViaTelegram": { "type": "boolean" }
31
31
+
"pendingRoute": { "type": "string" }
29
32
}
30
33
}
31
34
}
···
108
108
instance?: string;
109
109
}
110
110
111
111
-
/** Instance-id format: lowercase hex/alphanumeric, kept short. */
112
112
-
const INSTANCE_ID_RE = /^[a-z0-9]+$/i;
111
111
+
/** Instance-id format: nanoid's url-safe alphabet (A–Z a–z 0–9 _ -); see lib/ids newTargetId. */
112
112
+
const INSTANCE_ID_RE = /^[A-Za-z0-9_-]+$/;
113
113
114
114
/** True for routes that carry no channel tokens: the inherit sentinels, plus
115
115
* 'off' (drop entirely) and 'inbox' (inbox only, no alerts), and ''. */
···
202
202
});
203
203
}
204
204
205
205
+
/** Valid app-wide route value: a concrete route, or 'default' (inherit account default). */
206
206
+
export function isAppRoute(route: string): boolean {
207
207
+
return route === 'default' || isConcreteRoute(route);
208
208
+
}
209
209
+
210
210
+
/** Valid per-category route value: a concrete route, or 'app' (inherit app-wide). */
211
211
+
export function isCategoryRoute(route: string): boolean {
212
212
+
return route === 'app' || isConcreteRoute(route);
213
213
+
}
214
214
+
205
215
/** Management capability the user designated for an app. See MANAGEMENT-AUTH.md. */
206
216
export type Capability = 'none' | 'self' | 'full';
207
217
208
218
export interface RoutingCategory {
209
219
category: string;
220
220
+
/** Human display name (e.g. a webhook's name), if the app set one. */
221
221
+
title?: string;
210
222
description?: string;
211
223
route: CategoryRoute;
212
224
}