This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

web: wire notification and site settings to the appview xrpc

Adds an authenticated appview xrpc client (mints an atproto service-auth jwt per
call) and wires the notifications and sites settings tabs to the org.tangled.temp.*
methods, replacing their mocked data. Input gains an optional suffix for the
<subdomain>.<sites-domain> claim field.

author
Anirudh Oppiliappan
committer
dawn
date (Jul 31, 2026, 10:57 PM +0300) commit f695ef4b parent 9a4b4885 change-id umoorxvq
+401 -68
+1 -1
Cargo.lock
··· 687 687 "clap", 688 688 "confique", 689 689 "futures", 690 - "rustls", 691 690 "jacquard-common", 692 691 "jacquard-identity", 692 + "rustls", 693 693 "serde", 694 694 "socket2", 695 695 "thiserror 2.0.18",
+1
web/src/app.d.ts
··· 5 5 bobbinUrl: string; 6 6 knotMirrorUrl: string; 7 7 apiUrl: string; 8 + sitesDomain: string; 8 9 camoEnabled: boolean; 9 10 }; 10 11 }
+80
web/src/lib/api/appview.ts
··· 1 + import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; 2 + import { mintServiceAuth, serviceDidForHost } from "$lib/auth/agent"; 3 + import { buildUrl, toResponseError } from "./_request"; 4 + import type { QueryValue, XrpcRequestInit } from "./client"; 5 + 6 + // Authenticated client for the go appview's org.tangled.temp.* xrpc methods. 7 + // Unlike bobbin (public reads) and the pds (record writes), these are authed 8 + // with an atproto service-auth jwt: the browser mints a per-call token via the 9 + // user's pds (getServiceAuth), scoped to the method (lxm) and the appview's did 10 + // (aud). The appview verifies the issuer's signature and the aud/lxm claims. 11 + export interface AppviewContext { 12 + readonly serviceUrl: string; 13 + readonly aud: string; 14 + readonly agent: OAuthUserAgent; 15 + readonly fetch: typeof globalThis.fetch; 16 + } 17 + 18 + export interface CreateAppviewOptions { 19 + apiUrl: string; 20 + agent: OAuthUserAgent; 21 + fetch?: typeof globalThis.fetch; 22 + } 23 + 24 + export const createAppviewClient = ({ 25 + apiUrl, 26 + agent, 27 + fetch 28 + }: CreateAppviewOptions): AppviewContext => { 29 + const serviceUrl = apiUrl.replace(/\/+$/, ""); 30 + // aud host must match the appview's TANGLED_APPVIEW_HOST, which is the host 31 + // it's reached at; derive both from the same url so they can't drift. 32 + const aud = serviceDidForHost(new URL(serviceUrl).host); 33 + return { serviceUrl, aud, agent, fetch: fetch ?? globalThis.fetch }; 34 + }; 35 + 36 + const authHeader = async (ctx: AppviewContext, nsid: string): Promise<string> => { 37 + const token = await mintServiceAuth(ctx.agent, { aud: ctx.aud, lxm: nsid }); 38 + return `Bearer ${token}`; 39 + }; 40 + 41 + export const authedGet = async <T>( 42 + ctx: AppviewContext, 43 + nsid: string, 44 + params?: Record<string, QueryValue>, 45 + init?: XrpcRequestInit 46 + ): Promise<T> => { 47 + const response = await ctx.fetch(buildUrl(ctx.serviceUrl, nsid, params), { 48 + headers: { 49 + accept: "application/json", 50 + authorization: await authHeader(ctx, nsid), 51 + ...init?.headers 52 + }, 53 + signal: init?.signal 54 + }); 55 + if (!response.ok) throw await toResponseError(response); 56 + return (await response.json()) as T; 57 + }; 58 + 59 + export const authedPost = async <T>( 60 + ctx: AppviewContext, 61 + nsid: string, 62 + body: unknown, 63 + init?: XrpcRequestInit 64 + ): Promise<T | null> => { 65 + const response = await ctx.fetch(buildUrl(ctx.serviceUrl, nsid), { 66 + method: "POST", 67 + headers: { 68 + "content-type": "application/json", 69 + accept: "application/json", 70 + authorization: await authHeader(ctx, nsid), 71 + ...init?.headers 72 + }, 73 + body: JSON.stringify(body ?? {}), 74 + signal: init?.signal 75 + }); 76 + if (!response.ok) throw await toResponseError(response); 77 + // most temp procedures return no body (200 with empty payload) 78 + const text = await response.text(); 79 + return text ? (JSON.parse(text) as T) : null; 80 + };
+32
web/src/lib/api/notifications.ts
··· 1 + import { authedGet, authedPost, type AppviewContext } from "./appview"; 2 + import type { XrpcRequestInit } from "./client"; 3 + 4 + // mirrors org.tangled.temp.notification.getPreferences#preferences 5 + export interface NotificationPreferences { 6 + repoStarred: boolean; 7 + issueCreated: boolean; 8 + issueCommented: boolean; 9 + issueClosed: boolean; 10 + pullCreated: boolean; 11 + pullCommented: boolean; 12 + pullMerged: boolean; 13 + followed: boolean; 14 + userMentioned: boolean; 15 + emailNotifications: boolean; 16 + } 17 + 18 + const GET_PREFERENCES = "org.tangled.temp.notification.getPreferences"; 19 + const UPDATE_PREFERENCES = "org.tangled.temp.notification.updatePreferences"; 20 + 21 + export const getNotificationPreferences = ( 22 + ctx: AppviewContext, 23 + init?: XrpcRequestInit 24 + ): Promise<NotificationPreferences> => 25 + authedGet<NotificationPreferences>(ctx, GET_PREFERENCES, undefined, init); 26 + 27 + // only the provided fields are updated server-side. 28 + export const updateNotificationPreferences = ( 29 + ctx: AppviewContext, 30 + patch: Partial<NotificationPreferences>, 31 + init?: XrpcRequestInit 32 + ): Promise<void> => authedPost(ctx, UPDATE_PREFERENCES, patch, init).then(() => undefined);
+33
web/src/lib/api/sites.ts
··· 1 + import { authedGet, authedPost, type AppviewContext } from "./appview"; 2 + import type { XrpcRequestInit } from "./client"; 3 + 4 + // org.tangled.temp.site.getDomainClaim — domain is absent when unclaimed. 5 + interface DomainClaimResponse { 6 + domain?: string; 7 + } 8 + 9 + const GET_DOMAIN_CLAIM = "org.tangled.temp.site.getDomainClaim"; 10 + const CLAIM_DOMAIN = "org.tangled.temp.site.claimDomain"; 11 + const RELEASE_DOMAIN = "org.tangled.temp.site.releaseDomain"; 12 + 13 + // returns the user's active sites domain, or null when none is claimed. 14 + export const getDomainClaim = async ( 15 + ctx: AppviewContext, 16 + init?: XrpcRequestInit 17 + ): Promise<string | null> => { 18 + const res = await authedGet<DomainClaimResponse>(ctx, GET_DOMAIN_CLAIM, undefined, init); 19 + return res.domain ?? null; 20 + }; 21 + 22 + // claims <subdomain>.<sites domain>; the server appends the configured suffix. 23 + export const claimDomain = ( 24 + ctx: AppviewContext, 25 + subdomain: string, 26 + init?: XrpcRequestInit 27 + ): Promise<void> => authedPost(ctx, CLAIM_DOMAIN, { subdomain }, init).then(() => undefined); 28 + 29 + export const releaseDomain = ( 30 + ctx: AppviewContext, 31 + domain: string, 32 + init?: XrpcRequestInit 33 + ): Promise<void> => authedPost(ctx, RELEASE_DOMAIN, { domain }, init).then(() => undefined);
+116 -40
web/src/lib/components/settings/tabs/NotificationsTab.svelte
··· 1 1 <script lang="ts"> 2 2 import type { Component } from "svelte"; 3 3 import type { SvelteHTMLElements } from "svelte/elements"; 4 + import { page } from "$app/state"; 5 + import { getAuth } from "$lib/auth.svelte"; 6 + import { createAppviewClient } from "$lib/api/appview"; 7 + import { 8 + getNotificationPreferences, 9 + updateNotificationPreferences, 10 + type NotificationPreferences 11 + } from "$lib/api/notifications"; 4 12 import Button from "$lib/components/ui/Button.svelte"; 5 13 import Toggle from "$lib/components/ui/Toggle.svelte"; 14 + import Spinner from "$lib/components/ui/Spinner.svelte"; 15 + import ErrorAlert from "$lib/components/ui/Error.svelte"; 6 16 import SettingsHeader from "../SettingsHeader.svelte"; 7 17 import SettingsList from "../SettingsList.svelte"; 8 18 import Save from "$icon/save"; ··· 16 26 import AtSign from "$icon/at-sign"; 17 27 import Mail from "$icon/mail"; 18 28 29 + type PrefKey = keyof NotificationPreferences; 30 + 19 31 interface Pref { 20 - id: string; 32 + key: PrefKey; 21 33 icon: Component<SvelteHTMLElements["svg"]>; 22 34 label: string; 23 35 description: string; 24 36 enabled: boolean; 25 37 } 26 38 27 - // mocked — notification preferences are backed by G2 (see TAN-575) 39 + const auth = getAuth(); 40 + const apiUrl = $derived(page.data.publicConfig?.apiUrl as string | undefined); 41 + 42 + // row definitions map the design-system UI onto the api's preference keys. 28 43 let prefs = $state<Pref[]>([ 29 44 { 30 - id: "starred", 45 + key: "repoStarred", 31 46 icon: Star, 32 47 label: "Repository starred", 33 48 description: "When someone stars your repository.", 34 - enabled: true 49 + enabled: false 35 50 }, 36 51 { 37 - id: "new-issues", 52 + key: "issueCreated", 38 53 icon: CircleDot, 39 54 label: "New issues", 40 55 description: "When someone creates an issue on your repository.", 41 - enabled: true 56 + enabled: false 42 57 }, 43 58 { 44 - id: "issue-comments", 59 + key: "issueCommented", 45 60 icon: MessageSquare, 46 61 label: "Issue comments", 47 62 description: "When someone comments on an issue you're involved with.", 48 - enabled: true 63 + enabled: false 49 64 }, 50 65 { 51 - id: "issue-closed", 66 + key: "issueClosed", 52 67 icon: CircleSlash, 53 68 label: "Issue closed", 54 69 description: "When an issue on your repository is closed.", 55 - enabled: true 70 + enabled: false 56 71 }, 57 72 { 58 - id: "new-pulls", 73 + key: "pullCreated", 59 74 icon: GitPullRequest, 60 75 label: "New pull requests", 61 76 description: "When someone creates a pull request on your repository.", 62 - enabled: true 77 + enabled: false 63 78 }, 64 79 { 65 - id: "pull-comments", 80 + key: "pullCommented", 66 81 icon: MessageSquare, 67 82 label: "Pull request comments", 68 83 description: "When someone comments on a pull request you're involved with.", 69 - enabled: true 84 + enabled: false 70 85 }, 71 86 { 72 - id: "pull-merged", 87 + key: "pullMerged", 73 88 icon: GitMerge, 74 89 label: "Pull request merged", 75 90 description: "When your pull request is merged.", 76 - enabled: true 91 + enabled: false 77 92 }, 78 93 { 79 - id: "followers", 94 + key: "followed", 80 95 icon: UserRound, 81 96 label: "New followers", 82 97 description: "When someone follows you.", 83 - enabled: true 98 + enabled: false 84 99 }, 85 100 { 86 - id: "mentions", 101 + key: "userMentioned", 87 102 icon: AtSign, 88 103 label: "Mentions", 89 104 description: "When someone mentions you.", 90 - enabled: true 105 + enabled: false 91 106 }, 92 107 { 93 - id: "email", 108 + key: "emailNotifications", 94 109 icon: Mail, 95 110 label: "Email notifications", 96 111 description: "Receive digest emails for issue and pull request activity.", 97 - enabled: true 112 + enabled: false 98 113 } 99 114 ]); 100 115 101 - // Figma shows Save disabled on load — it only lights up once something changed 102 - const initial = prefs.map((p) => p.enabled); 103 - const dirty = $derived(prefs.some((p, i) => p.enabled !== initial[i])); 116 + let loading = $state(true); 117 + let saving = $state(false); 118 + let loadError = $state<string | null>(null); 119 + let saveError = $state<string | null>(null); 120 + // last-saved values; Save lights up when the toggles drift from these. 121 + let baseline = $state<NotificationPreferences | null>(null); 122 + 123 + const dirty = $derived(baseline !== null && prefs.some((p) => p.enabled !== baseline![p.key])); 124 + 125 + const load = async () => { 126 + const agent = auth.agent; 127 + if (!agent || !apiUrl) return; 128 + loading = true; 129 + loadError = null; 130 + try { 131 + const ctx = createAppviewClient({ apiUrl, agent }); 132 + const values = await getNotificationPreferences(ctx); 133 + for (const pref of prefs) pref.enabled = values[pref.key]; 134 + baseline = values; 135 + } catch (err) { 136 + loadError = 137 + err instanceof Error ? err.message : "Failed to load notification preferences."; 138 + } finally { 139 + loading = false; 140 + } 141 + }; 142 + 143 + $effect(() => { 144 + if (auth.agent && apiUrl) void load(); 145 + }); 146 + 147 + const save = async () => { 148 + const agent = auth.agent; 149 + if (!agent || !apiUrl || !baseline || !dirty) return; 150 + saving = true; 151 + saveError = null; 152 + // send only the toggles that changed since the last save. 153 + const patch: Partial<NotificationPreferences> = {}; 154 + for (const pref of prefs) { 155 + if (pref.enabled !== baseline[pref.key]) patch[pref.key] = pref.enabled; 156 + } 157 + try { 158 + const ctx = createAppviewClient({ apiUrl, agent }); 159 + await updateNotificationPreferences(ctx, patch); 160 + baseline = { ...baseline, ...patch }; 161 + } catch (err) { 162 + saveError = err instanceof Error ? err.message : "Failed to save preferences."; 163 + } finally { 164 + saving = false; 165 + } 166 + }; 104 167 </script> 105 168 106 169 <SettingsHeader ··· 108 171 description="Choose which notifications you want to receive when activity happens on your repositories and profile." 109 172 > 110 173 {#snippet action()} 111 - <Button variant="primary" icon={Save} disabled={!dirty}>Save</Button> 174 + <Button variant="primary" icon={Save} disabled={!dirty || saving} onclick={save}>Save</Button> 112 175 {/snippet} 113 176 </SettingsHeader> 114 177 115 - <SettingsList> 116 - {#each prefs as pref (pref.id)} 117 - {@const Glyph = pref.icon} 118 - <div class="flex w-full items-center justify-between gap-4"> 119 - <div class="flex min-w-0 flex-col gap-1"> 120 - <span class="flex items-center gap-2"> 121 - <Glyph class="size-3.5 shrink-0 text-foreground-default" aria-hidden="true" /> 122 - <span class="typography-paragraph-regular text-foreground-default">{pref.label}</span> 123 - </span> 124 - <span class="typography-paragraph-regular text-foreground-muted">{pref.description}</span> 178 + {#if loadError} 179 + <ErrorAlert label={loadError} /> 180 + {/if} 181 + {#if saveError} 182 + <ErrorAlert label={saveError} /> 183 + {/if} 184 + 185 + {#if loading} 186 + <div class="flex items-center justify-center gap-2 p-4 text-sm text-foreground-subtle"> 187 + <Spinner /> Loading… 188 + </div> 189 + {:else} 190 + <SettingsList> 191 + {#each prefs as pref (pref.key)} 192 + {@const Glyph = pref.icon} 193 + <div class="flex w-full items-center justify-between gap-4"> 194 + <div class="flex min-w-0 flex-col gap-1"> 195 + <span class="flex items-center gap-2"> 196 + <Glyph class="size-3.5 shrink-0 text-foreground-default" aria-hidden="true" /> 197 + <span class="typography-paragraph-regular text-foreground-default">{pref.label}</span> 198 + </span> 199 + <span class="typography-paragraph-regular text-foreground-muted">{pref.description}</span> 200 + </div> 201 + <Toggle bind:checked={pref.enabled} disabled={saving} aria-label={pref.label} /> 125 202 </div> 126 - <Toggle bind:checked={pref.enabled} aria-label={pref.label} /> 127 - </div> 128 - {/each} 129 - </SettingsList> 203 + {/each} 204 + </SettingsList> 205 + {/if}
+125 -25
web/src/lib/components/settings/tabs/SitesTab.svelte
··· 1 1 <script lang="ts"> 2 + import { page } from "$app/state"; 3 + import { getAuth } from "$lib/auth.svelte"; 4 + import { createAppviewClient } from "$lib/api/appview"; 5 + import { ClientResponseError } from "$lib/api/client"; 6 + import { getDomainClaim, claimDomain, releaseDomain } from "$lib/api/sites"; 2 7 import Button from "$lib/components/ui/Button.svelte"; 8 + import Input from "$lib/components/ui/Input.svelte"; 3 9 import Tag from "$lib/components/ui/Tag.svelte"; 10 + import Spinner from "$lib/components/ui/Spinner.svelte"; 11 + import ErrorAlert from "$lib/components/ui/Error.svelte"; 4 12 import SettingsEmpty from "../SettingsEmpty.svelte"; 5 13 import SettingsHeader from "../SettingsHeader.svelte"; 6 14 import SettingsToolbar from "../SettingsToolbar.svelte"; 7 15 import SettingsList from "../SettingsList.svelte"; 8 16 import SettingsEntry from "../SettingsEntry.svelte"; 17 + import FormRow from "../FormRow.svelte"; 9 18 import DocsButton from "../DocsButton.svelte"; 10 19 import Globe from "$icon/globe"; 11 20 import Check from "$icon/check"; 12 21 import Unlink from "$icon/unlink"; 22 + import Plus from "$icon/plus"; 13 23 14 - interface MockSite { 15 - id: string; 16 - domain: string; 17 - active: boolean; 18 - } 24 + const auth = getAuth(); 25 + const apiUrl = $derived(page.data.publicConfig?.apiUrl as string | undefined); 26 + const sitesDomain = $derived((page.data.publicConfig?.sitesDomain as string | undefined) ?? ""); 27 + 28 + let domain = $state<string | null>(null); 29 + let loading = $state(true); 30 + let loadError = $state<string | null>(null); 31 + let actionError = $state<string | null>(null); 32 + let subdomain = $state(""); 33 + let claiming = $state(false); 34 + let releasing = $state(false); 35 + 36 + // prefer the xrpc error's message/name over a generic client error string. 37 + const errMessage = (err: unknown, fallback: string): string => { 38 + if (err instanceof ClientResponseError) return err.description || err.error || fallback; 39 + return err instanceof Error ? err.message : fallback; 40 + }; 41 + 42 + const load = async () => { 43 + const agent = auth.agent; 44 + if (!agent || !apiUrl) return; 45 + loading = true; 46 + loadError = null; 47 + try { 48 + const ctx = createAppviewClient({ apiUrl, agent }); 49 + domain = await getDomainClaim(ctx); 50 + } catch (err) { 51 + loadError = errMessage(err, "Failed to load your sites domain."); 52 + } finally { 53 + loading = false; 54 + } 55 + }; 19 56 20 - // mocked — domain claims are backed by G7 (see TAN-575) 21 - let sites = $state<MockSite[]>([{ id: "1", domain: "user.tngl.sh", active: true }]); 57 + $effect(() => { 58 + if (auth.agent && apiUrl) void load(); 59 + }); 22 60 23 - const release = (id: string) => { 24 - sites = sites.filter((s) => s.id !== id); 61 + const claim = async () => { 62 + const agent = auth.agent; 63 + const sd = subdomain.trim(); 64 + if (!agent || !apiUrl || !sd || claiming) return; 65 + claiming = true; 66 + actionError = null; 67 + try { 68 + const ctx = createAppviewClient({ apiUrl, agent }); 69 + await claimDomain(ctx, sd); 70 + subdomain = ""; 71 + await load(); 72 + } catch (err) { 73 + actionError = errMessage(err, "Failed to claim domain."); 74 + } finally { 75 + claiming = false; 76 + } 77 + }; 78 + 79 + const release = async () => { 80 + const agent = auth.agent; 81 + if (!agent || !apiUrl || !domain || releasing) return; 82 + if (!confirm(`Release ${domain}? This removes all site data served from it.`)) return; 83 + releasing = true; 84 + actionError = null; 85 + try { 86 + const ctx = createAppviewClient({ apiUrl, agent }); 87 + await releaseDomain(ctx, domain); 88 + await load(); 89 + } catch (err) { 90 + actionError = errMessage(err, "Failed to release domain."); 91 + } finally { 92 + releasing = false; 93 + } 25 94 }; 26 95 </script> 27 96 28 97 <SettingsHeader 29 98 title="Sites" 30 - description="Since your handle is on tngl.sh, it doubles as your sites domain—your site will be served from that subdomain automatically." 99 + description="Claim a subdomain to serve static sites built from your repositories." 31 100 /> 32 101 33 102 <div class="flex w-full flex-col gap-4"> ··· 37 106 {/snippet} 38 107 </SettingsToolbar> 39 108 40 - {#if sites.length === 0} 41 - <SettingsEmpty message="No sites claimed yet" /> 42 - {:else} 109 + {#if loadError} 110 + <ErrorAlert label={loadError} /> 111 + {/if} 112 + {#if actionError} 113 + <ErrorAlert label={actionError} /> 114 + {/if} 115 + 116 + {#if loading} 117 + <div class="flex items-center justify-center gap-2 p-4 text-sm text-foreground-subtle"> 118 + <Spinner /> Loading… 119 + </div> 120 + {:else if domain} 43 121 <SettingsList> 44 - {#each sites as site (site.id)} 45 - <SettingsEntry icon={Globe} title={site.domain}> 46 - {#snippet tags()} 47 - {#if site.active} 48 - <Tag color="success" size="sm" icon={Check}>Active</Tag> 49 - {/if} 50 - {/snippet} 51 - {#snippet actions()} 52 - <Button variant="danger" icon={Unlink} onclick={() => release(site.id)}>Release</Button> 53 - {/snippet} 54 - </SettingsEntry> 55 - {/each} 122 + <SettingsEntry icon={Globe} title={domain}> 123 + {#snippet tags()} 124 + <Tag color="success" size="sm" icon={Check}>Active</Tag> 125 + {/snippet} 126 + {#snippet actions()} 127 + <Button variant="danger" icon={Unlink} disabled={releasing} onclick={release}> 128 + Release 129 + </Button> 130 + {/snippet} 131 + </SettingsEntry> 56 132 </SettingsList> 133 + {:else} 134 + <form 135 + class="flex w-full flex-col gap-4" 136 + onsubmit={(e) => { 137 + e.preventDefault(); 138 + void claim(); 139 + }} 140 + > 141 + <FormRow label="Subdomain" description="Lowercase letters, digits, and hyphens; 4–63 characters."> 142 + <div class="flex items-center gap-2"> 143 + <Input 144 + bind:value={subdomain} 145 + placeholder="floo" 146 + suffix={sitesDomain ? `.${sitesDomain}` : undefined} 147 + disabled={claiming} 148 + class="flex-1" 149 + /> 150 + <Button type="submit" variant="primary" icon={Plus} disabled={claiming || !subdomain.trim()}> 151 + Claim 152 + </Button> 153 + </div> 154 + </FormRow> 155 + <SettingsEmpty message="No sites domain claimed yet" /> 156 + </form> 57 157 {/if} 58 158 </div>
+6
web/src/lib/components/ui/Input.svelte
··· 35 35 loading?: boolean; 36 36 iconLeft?: Component<SvelteHTMLElements["svg"]>; 37 37 iconRight?: Component<SvelteHTMLElements["svg"]>; 38 + /** static, non-editable trailing text shown inside the field, e.g. a domain suffix */ 39 + suffix?: string; 38 40 class?: string; 39 41 } 40 42 ··· 45 47 loading = false, 46 48 iconLeft, 47 49 iconRight, 50 + suffix, 48 51 class: className, 49 52 ...rest 50 53 }: Props = $props(); ··· 65 68 class="flex-1 bg-transparent text-sm outline-none placeholder:text-foreground-placeholder disabled:cursor-not-allowed" 66 69 {...rest} 67 70 /> 71 + {#if suffix} 72 + <span class="shrink-0 text-sm text-foreground-muted select-none">{suffix}</span> 73 + {/if} 68 74 {#if loading} 69 75 <Spinner class="size-4 shrink-0" /> 70 76 {:else if iconRight}
+6 -1
web/src/lib/server/config.ts
··· 9 9 bobbinUrl: string; 10 10 knotMirrorUrl: string; 11 11 apiUrl: string; 12 + /** the domain user sites are served under, e.g. "tngl.io" */ 13 + sitesDomain: string; 12 14 camoUrl: string; 13 15 avatarUrl: string; 14 16 /** the secrets camo and avatar sign with, so neither leaves the server */ ··· 18 20 19 21 export type PublicWebConfig = Pick< 20 22 WebConfig, 21 - "bobbinUrl" | "knotMirrorUrl" | "apiUrl" 23 + "bobbinUrl" | "knotMirrorUrl" | "apiUrl" | "sitesDomain" 22 24 > & { 23 25 /** camo has a secret, so markup can route images through it */ 24 26 camoEnabled: boolean; ··· 30 32 TANGLED_API_URL?: string; 31 33 API_URL?: string; 32 34 KNOT_RESOLVER_URL?: string; 35 + SITES_DOMAIN?: string; 33 36 CAMO_URL?: string; 34 37 CAMO_SHARED_SECRET?: string; 35 38 AVATAR_URL?: string; ··· 40 43 bobbinUrl: cleanUrl(values.BOBBIN_URL, "http://127.0.0.1:8090"), 41 44 knotMirrorUrl: cleanUrl(values.KNOTMIRROR_URL, ""), 42 45 apiUrl: cleanUrl(values.TANGLED_API_URL ?? values.API_URL, "http://127.0.0.1:8080"), 46 + sitesDomain: values.SITES_DOMAIN?.trim() || "tngl.io", 43 47 camoUrl: cleanUrl(values.CAMO_URL, "https://camo.tangled.sh"), 44 48 avatarUrl: cleanUrl(values.AVATAR_URL, "https://avatar.tangled.sh"), 45 49 camoSecret: values.CAMO_SHARED_SECRET?.trim() ?? "", ··· 56 60 bobbinUrl: config.bobbinUrl, 57 61 knotMirrorUrl: config.knotMirrorUrl, 58 62 apiUrl: config.apiUrl, 63 + sitesDomain: config.sitesDomain, 59 64 camoEnabled: config.camoSecret !== "" 60 65 }; 61 66 };
+1 -1
web/static/oauth-client-metadata.json
··· 3 3 "client_name": "Tangled", 4 4 "client_uri": "https://tangled.org", 5 5 "redirect_uris": ["https://tangled.org/oauth/callback"], 6 - "scope": "atproto repo:sh.tangled.actor.profile repo:sh.tangled.feed.comment repo:sh.tangled.feed.reaction repo:sh.tangled.feed.star repo:sh.tangled.graph.follow repo:sh.tangled.graph.vouch repo:sh.tangled.knot repo:sh.tangled.knot.member repo:sh.tangled.label.definition repo:sh.tangled.label.op repo:sh.tangled.publicKey repo:sh.tangled.repo repo:sh.tangled.repo.artifact repo:sh.tangled.repo.collaborator repo:sh.tangled.repo.issue repo:sh.tangled.repo.issue.comment repo:sh.tangled.repo.issue.state repo:sh.tangled.repo.pull repo:sh.tangled.repo.pull.comment repo:sh.tangled.repo.pull.status repo:sh.tangled.spindle repo:sh.tangled.spindle.member repo:sh.tangled.string blob:*/* rpc:sh.tangled.knot.addMember?aud=* rpc:sh.tangled.knot.removeMember?aud=* rpc:sh.tangled.ci.triggerPipeline?aud=* rpc:sh.tangled.ci.cancelPipeline?aud=* rpc:sh.tangled.repo.addCollaborator?aud=* rpc:sh.tangled.repo.addSecret?aud=* rpc:sh.tangled.repo.create?aud=* rpc:sh.tangled.repo.delete?aud=* rpc:sh.tangled.repo.deleteBranch?aud=* rpc:sh.tangled.repo.forkStatus?aud=* rpc:sh.tangled.repo.forkSync?aud=* rpc:sh.tangled.repo.hiddenRef?aud=* rpc:sh.tangled.repo.listSecrets?aud=* rpc:sh.tangled.repo.merge?aud=* rpc:sh.tangled.repo.mergeCheck?aud=* rpc:sh.tangled.repo.removeCollaborator?aud=* rpc:sh.tangled.repo.removeSecret?aud=* rpc:sh.tangled.repo.setDefaultBranch?aud=*", 6 + "scope": "atproto repo:sh.tangled.actor.profile repo:sh.tangled.feed.comment repo:sh.tangled.feed.reaction repo:sh.tangled.feed.star repo:sh.tangled.graph.follow repo:sh.tangled.graph.vouch repo:sh.tangled.knot repo:sh.tangled.knot.member repo:sh.tangled.label.definition repo:sh.tangled.label.op repo:sh.tangled.publicKey repo:sh.tangled.repo repo:sh.tangled.repo.artifact repo:sh.tangled.repo.collaborator repo:sh.tangled.repo.issue repo:sh.tangled.repo.issue.comment repo:sh.tangled.repo.issue.state repo:sh.tangled.repo.pull repo:sh.tangled.repo.pull.comment repo:sh.tangled.repo.pull.status repo:sh.tangled.spindle repo:sh.tangled.spindle.member repo:sh.tangled.string blob:*/* rpc:sh.tangled.knot.addMember?aud=* rpc:sh.tangled.knot.removeMember?aud=* rpc:sh.tangled.ci.triggerPipeline?aud=* rpc:sh.tangled.ci.cancelPipeline?aud=* rpc:sh.tangled.repo.addCollaborator?aud=* rpc:sh.tangled.repo.addSecret?aud=* rpc:sh.tangled.repo.create?aud=* rpc:sh.tangled.repo.delete?aud=* rpc:sh.tangled.repo.deleteBranch?aud=* rpc:sh.tangled.repo.forkStatus?aud=* rpc:sh.tangled.repo.forkSync?aud=* rpc:sh.tangled.repo.hiddenRef?aud=* rpc:sh.tangled.repo.listSecrets?aud=* rpc:sh.tangled.repo.merge?aud=* rpc:sh.tangled.repo.mergeCheck?aud=* rpc:sh.tangled.repo.removeCollaborator?aud=* rpc:sh.tangled.repo.removeSecret?aud=* rpc:sh.tangled.repo.setDefaultBranch?aud=* rpc:org.tangled.temp.notification.getPreferences?aud=* rpc:org.tangled.temp.notification.updatePreferences?aud=* rpc:org.tangled.temp.site.getDomainClaim?aud=* rpc:org.tangled.temp.site.claimDomain?aud=* rpc:org.tangled.temp.site.releaseDomain?aud=*", 7 7 "grant_types": ["authorization_code", "refresh_token"], 8 8 "response_types": ["code"], 9 9 "token_endpoint_auth_method": "none",