diff --git a/src/lib/reddit/bot.ts b/src/lib/reddit/bot.ts
index 200075c..8420187 100644
--- a/src/lib/reddit/bot.ts
+++ b/src/lib/reddit/bot.ts
@@ -72,7 +72,7 @@ const DEFAULT_COMMUNITY_CONFIG: CommunityConfig = {
* UI-relevant fields. Falls back to sensible defaults on any failure so the
* community still works without a record.
*/
-async function fetchCommunityConfig(pds: string, did: string): Promise {
+export async function fetchCommunityConfig(pds: string, did: string): Promise {
try {
const rec = await getRecord(pds, did, 'garden.atmo.community', 'self');
const value = (rec?.value ?? {}) as {
@@ -312,6 +312,96 @@ async function loadClient(
return WelcomeMatClient.forAccount(account);
}
+/**
+ * Partial update of a community's on-network presence. Fields that are
+ * omitted are left untouched (both on the profile record and on
+ * garden.atmo.community/self). The D1 cache picks up display-name /
+ * avatar / description / accent from the next cron tick; accent_color
+ * can be updated inline since we know the new value.
+ */
+export type UpdateCommunityOptions = {
+ avatar?: { bytes: Uint8Array; mimeType: string };
+ /** User-facing description (we prepend the `https://` link). */
+ description?: string;
+ accentColor?: string;
+};
+
+export async function updateCommunity(
+ env: App.Platform['env'],
+ row: CommunityRow,
+ opts: UpdateCommunityOptions
+): Promise {
+ const client = await loadClient(env, row);
+
+ // Upload new avatar blob if provided.
+ let avatarBlob:
+ | { $type: 'blob'; ref: { $link: string }; mimeType: string; size: number }
+ | null = null;
+ if (opts.avatar) {
+ avatarBlob = await client.uploadBlob(opts.avatar.bytes, opts.avatar.mimeType);
+ }
+
+ // Merge into the existing profile record. We read it first so we
+ // don't clobber fields we don't touch (displayName etc.).
+ if (avatarBlob || opts.description !== undefined) {
+ try {
+ const existing = await getRecord(
+ row.pds,
+ row.did,
+ 'app.bsky.actor.profile',
+ 'self'
+ );
+ const baseValue =
+ existing?.value ?? ({ $type: 'app.bsky.actor.profile' } as Record);
+
+ const next: Record = { ...baseValue, $type: 'app.bsky.actor.profile' };
+
+ if (avatarBlob) {
+ next.avatar = avatarBlob;
+ }
+ if (opts.description !== undefined) {
+ const trimmed = opts.description.trim();
+ const full = trimmed
+ ? `https://${row.handle}\n\n${trimmed}`
+ : `https://${row.handle}`;
+ if (countGraphemes(full) > PROFILE_DESCRIPTION_MAX_GRAPHEMES) {
+ throw new Error(
+ `Description too long: ${PROFILE_DESCRIPTION_MAX_GRAPHEMES} graphemes max (including the https://${row.handle} prefix)`
+ );
+ }
+ next.description = full;
+ }
+
+ await client.putRecord('app.bsky.actor.profile', 'self', next);
+ } catch (e) {
+ console.error('[updateCommunity] failed to update profile', e);
+ throw e;
+ }
+ }
+
+ // Merge into garden.atmo.community/self for accent color changes.
+ if (opts.accentColor !== undefined) {
+ try {
+ const existing = await getRecord(
+ row.pds,
+ row.did,
+ 'garden.atmo.community',
+ 'self'
+ );
+ const baseValue =
+ existing?.value ?? ({ $type: 'garden.atmo.community' } as Record);
+ await client.putRecord('garden.atmo.community', 'self', {
+ ...baseValue,
+ $type: 'garden.atmo.community',
+ accentColor: opts.accentColor
+ });
+ } catch (e) {
+ console.error('[updateCommunity] failed to update community record', e);
+ throw e;
+ }
+ }
+}
+
// -------------------------------------------------------------------------
// Registration
// -------------------------------------------------------------------------
@@ -901,12 +991,15 @@ async function createSubmissionPost(
* hiding the button from signed-out visitors who would otherwise be
* allowed once they log in.
*/
-export async function canUserSubmit(
- pds: string,
- communityDid: string,
+/**
+ * Pure check: given an already-fetched config, is `viewerDid` allowed to
+ * submit? Lets callers that already have the config (e.g. the community
+ * page remote, which also needs `creator` from it) skip a second fetch.
+ */
+export async function checkCanSubmit(
+ config: CommunityConfig,
viewerDid: string | null
): Promise {
- const config = await fetchCommunityConfig(pds, communityDid);
if (config.whoCanSubmit !== 'list' || !config.listUri) return true;
if (!viewerDid) return true;
if (config.creator && config.creator === viewerDid) return true;
@@ -914,6 +1007,15 @@ export async function canUserSubmit(
return members.has(viewerDid);
}
+export async function canUserSubmit(
+ pds: string,
+ communityDid: string,
+ viewerDid: string | null
+): Promise {
+ const config = await fetchCommunityConfig(pds, communityDid);
+ return checkCanSubmit(config, viewerDid);
+}
+
/**
* Reason a web submission was dropped. Surfaced for logging; callers don't
* branch on these today.
diff --git a/src/lib/reddit/server/communities.remote.ts b/src/lib/reddit/server/communities.remote.ts
index 6c87cdd..179fe61 100644
--- a/src/lib/reddit/server/communities.remote.ts
+++ b/src/lib/reddit/server/communities.remote.ts
@@ -11,7 +11,12 @@ import {
type PostWithCommunity,
type PostSort
} from '../db';
-import { canUserSubmit, registerCommunity } from '../bot';
+import {
+ registerCommunity,
+ updateCommunity,
+ fetchCommunityConfig,
+ checkCanSubmit
+} from '../bot';
import { parseListUri } from '../list-uri';
import {
ACCENT_COLORS,
@@ -44,9 +49,11 @@ type PublicCommunity = Omit<
postCount: number;
};
-/** PublicCommunity + viewer-specific access state. */
+/** PublicCommunity + viewer-specific access state + creator (from the
+ * on-network garden.atmo.community/self record). */
type CommunityWithAccess = PublicCommunity & {
canSubmit: boolean;
+ creator: string | null;
};
function sanitize(row: CommunityRow & { post_count?: number }): PublicCommunity {
@@ -180,15 +187,24 @@ export const getCommunity = command(
const row = await getCommunityByHandle(env.DB, fullHandle(input.handle));
if (!row) return null;
- // Viewer-specific: can this viewer submit to the community? Reads the
- // allowlist off the community's PDS and (if gated) checks membership.
- // Fails open — if the config fetch blows up, we don't want to hide the
- // submit button from everyone.
- const canSubmit = await canUserSubmit(row.pds, row.did, locals.did ?? null).catch(
- () => true
- );
+ // One-shot fetch of the community's garden.atmo.community/self record.
+ // We need both `creator` (to gate the Edit UI) and `canSubmit` (to
+ // gate the Submit UI) — both derive from this same record + list
+ // membership, so fetch once and reuse. Fails open if the record is
+ // missing or the fetch fails: creator=null (no edit permission,
+ // which is the safe default) and canSubmit=true (don't hide submit
+ // from everyone).
+ let creator: string | null = null;
+ let canSubmit = true;
+ try {
+ const config = await fetchCommunityConfig(row.pds, row.did);
+ creator = config.creator;
+ canSubmit = await checkCanSubmit(config, locals.did ?? null);
+ } catch (e) {
+ console.error('[getCommunity] config fetch failed', e);
+ }
- return { ...sanitize(row), canSubmit };
+ return { ...sanitize(row), canSubmit, creator };
}
);
@@ -247,3 +263,69 @@ export const getHomeFeed = command(
);
}
);
+
+export const editCommunity = command(
+ v.object({
+ handle: v.string(),
+ description: v.optional(v.pipe(v.string(), v.maxLength(2048))),
+ accentColor: v.optional(v.picklist(ACCENT_COLORS)),
+ avatar: v.optional(
+ v.object({
+ base64: v.pipe(v.string(), v.maxLength(2 * 1024 * 1024)),
+ mimeType: v.picklist(ALLOWED_AVATAR_MIMES)
+ })
+ )
+ }),
+ async (input) => {
+ const { platform, locals } = getRequestEvent();
+ const env = platform?.env;
+ if (!env || !env.DB) error(500, 'DB binding unavailable');
+ if (!locals.did) error(401, 'You must be signed in to edit a community');
+
+ const row = await getCommunityByHandle(env.DB, fullHandle(input.handle));
+ if (!row) error(404, 'Community not found');
+
+ // Creator-only gate: fetch the community record and verify the
+ // caller is the recorded creator. If the record has no creator
+ // (legacy rows from before we started writing it), no one can
+ // edit via this endpoint — they'd need the admin secret route.
+ const config = await fetchCommunityConfig(row.pds, row.did);
+ if (!config.creator) {
+ error(403, 'This community has no recorded creator — edit via admin tooling');
+ }
+ if (config.creator !== locals.did) {
+ error(403, 'Only the community creator can edit');
+ }
+
+ let avatarPayload: { bytes: Uint8Array; mimeType: string } | undefined;
+ if (input.avatar) {
+ const bytes = decodeBase64(input.avatar.base64);
+ if (bytes.byteLength > MAX_AVATAR_BYTES) {
+ error(400, `Avatar too large: max ${MAX_AVATAR_BYTES} bytes`);
+ }
+ avatarPayload = { bytes, mimeType: input.avatar.mimeType };
+ }
+
+ if (
+ avatarPayload === undefined &&
+ input.description === undefined &&
+ input.accentColor === undefined
+ ) {
+ error(400, 'No fields to update');
+ }
+
+ try {
+ await updateCommunity(env, row, {
+ avatar: avatarPayload,
+ description: input.description,
+ accentColor: input.accentColor
+ });
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ console.error('[editCommunity]', e);
+ error(400, `Update failed: ${msg}`);
+ }
+
+ return { ok: true, did: row.did, handle: row.handle };
+ }
+);
diff --git a/src/routes/c/[handle]/+page.svelte b/src/routes/c/[handle]/+page.svelte
index cbce59c..01fc204 100644
--- a/src/routes/c/[handle]/+page.svelte
+++ b/src/routes/c/[handle]/+page.svelte
@@ -1,8 +1,10 @@
+
+
+
+
+
Edit community
+
+ {#if loading}
+
+
+
+ {:else if loadError}
+
{loadError}
+ {:else if !user.did}
+
+
You need to be signed in to edit a community.
+
+
+ {:else if !community?.creator}
+
+ This community was created before edits were supported. Ask an admin to update it.
+
+ {:else if !isCreator}
+
+ Only the community creator can edit this.
+
+ {:else if community}
+
+ {/if}
+
diff --git a/src/routes/c/[handle]/update/+server.ts b/src/routes/c/[handle]/update/+server.ts
new file mode 100644
index 0000000..2c1e816
--- /dev/null
+++ b/src/routes/c/[handle]/update/+server.ts
@@ -0,0 +1,99 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { getCommunityByHandle } from '$lib/reddit/db';
+import { updateCommunity } from '$lib/reddit/bot';
+import { ACCENT_COLORS, isAccentColor } from '$lib/reddit/accent-colors';
+
+const HANDLE_DOMAIN = '.atmo.garden';
+const ALLOWED_AVATAR_MIMES = new Set(['image/jpeg', 'image/png', 'image/webp']);
+const MAX_AVATAR_BYTES = 1024 * 1024;
+
+function fullHandle(input: string): string {
+ return input.includes('.') ? input : input + HANDLE_DOMAIN;
+}
+
+function decodeBase64(b64: string): Uint8Array {
+ const bin = atob(b64);
+ const out = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
+ return out;
+}
+
+/**
+ * POST /c/{handle}/update
+ *
+ * Admin-only update of a community's avatar / description / accent color.
+ * Gated by the `X-Rookery-Signup-Secret` header (same shared secret used by
+ * the delete endpoint and by /api/signup on the Rookery side). No session
+ * auth — intended for curl-style admin tooling.
+ *
+ * Body (application/json, all fields optional):
+ * {
+ * "avatar": { "base64": "", "mimeType": "image/jpeg" },
+ * "description": "new bio text",
+ * "accentColor": "blue"
+ * }
+ */
+export const POST: RequestHandler = async ({ request, params, platform }) => {
+ const env = platform?.env;
+ if (!env?.DB) error(500, 'DB binding unavailable');
+ if (!env.ROOKERY_SIGNUP_SECRET) error(500, 'ROOKERY_SIGNUP_SECRET not configured');
+
+ const provided = request.headers.get('x-rookery-signup-secret');
+ if (!provided || provided !== env.ROOKERY_SIGNUP_SECRET) {
+ error(403, 'Admin auth required');
+ }
+
+ let body: {
+ avatar?: { base64?: string; mimeType?: string };
+ description?: string;
+ accentColor?: string;
+ };
+ try {
+ body = await request.json();
+ } catch {
+ error(400, 'Invalid JSON body');
+ }
+
+ const row = await getCommunityByHandle(env.DB, fullHandle(params.handle));
+ if (!row) error(404, 'Community not found');
+
+ // Validate + decode avatar if present.
+ let avatarPayload: { bytes: Uint8Array; mimeType: string } | undefined;
+ if (body.avatar) {
+ if (!body.avatar.base64 || !body.avatar.mimeType) {
+ error(400, 'avatar requires base64 and mimeType');
+ }
+ if (!ALLOWED_AVATAR_MIMES.has(body.avatar.mimeType)) {
+ error(400, `Unsupported mime type: ${body.avatar.mimeType}`);
+ }
+ const bytes = decodeBase64(body.avatar.base64);
+ if (bytes.byteLength > MAX_AVATAR_BYTES) {
+ error(400, `Avatar too large: max ${MAX_AVATAR_BYTES} bytes`);
+ }
+ avatarPayload = { bytes, mimeType: body.avatar.mimeType };
+ }
+
+ // Validate accent color.
+ if (body.accentColor !== undefined && !isAccentColor(body.accentColor)) {
+ error(400, `Invalid accentColor. Allowed: ${ACCENT_COLORS.join(', ')}`);
+ }
+
+ if (!avatarPayload && body.description === undefined && body.accentColor === undefined) {
+ error(400, 'No fields to update (provide avatar, description, and/or accentColor)');
+ }
+
+ try {
+ await updateCommunity(env, row, {
+ avatar: avatarPayload,
+ description: body.description,
+ accentColor: body.accentColor
+ });
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ console.error('[c/update] failed', e);
+ error(500, `Update failed: ${msg}`);
+ }
+
+ return json({ ok: true, did: row.did, handle: row.handle });
+};
diff --git a/src/routes/communities/+page.svelte b/src/routes/communities/+page.svelte
index 3754c66..6c7c07e 100644
--- a/src/routes/communities/+page.svelte
+++ b/src/routes/communities/+page.svelte
@@ -118,7 +118,7 @@
No communities yet. Create the first one.
{:else}
-
+
{#each communities as c (c.did)}
{@const short = c.handle.split('.')[0]}
{@const state = followStates[c.did] ?? { followUri: null, loading: false }}