···44 "defs": {
55 "main": {
66 "type": "query",
77- "description": "Get metadata for a single space. Caller must be a member or the owner.",
77+ "description": "Get metadata for a single space. Caller must be a member, the owner, or hold a read-grant invite token.",
88 "parameters": {
99 "type": "params",
1010 "required": [
···1414 "uri": {
1515 "type": "string",
1616 "format": "at-uri"
1717+ },
1818+ "inviteToken": {
1919+ "type": "string",
2020+ "description": "Read-grant invite token. When supplied, replaces JWT auth for this read."
1721 }
1822 }
1923 },
···7575 rsvpsGoingCountMin?: number;
7676 hydrateRsvps?: number;
7777 profiles?: boolean;
7878+ preferencesShowInDiscovery?: string;
7879 sort?: string;
7980 order?: 'asc' | 'desc';
8081 limit?: number;
···248249 const response = await client.get('rsvp.atmo.event.listRecords', {
249250 params
250251 });
252252+253253+ if (!response.ok) return null;
254254+ return response.data;
255255+}
256256+257257+/**
258258+ * Hits the `listDiscoverable` pipelineQuery, which reuses the listRecords
259259+ * pipeline but adds a WHERE condition excluding events where
260260+ * `preferences.showInDiscovery === false`. Missing field is treated as true.
261261+ * Response shape is identical to listRecords.
262262+ */
263263+export async function listDiscoverableEventsFromContrail(
264264+ client: Client,
265265+ params: Omit<ListEventsParams, 'preferencesShowInDiscovery'>
266266+): Promise<EventListOutput | null> {
267267+ const response = await client.get(
268268+ 'rsvp.atmo.event.listDiscoverable' as 'rsvp.atmo.event.listRecords',
269269+ { params }
270270+ );
251271252272 if (!response.ok) return null;
253273 return response.data;
+17
src/lib/contrail/config.ts
···3344export const config: ContrailConfig = {
55 namespace: 'rsvp.atmo',
66+ // Enable the rsvp.atmo.notifyOfUpdate endpoint. The client calls it after
77+ // writing records to the PDS so contrail re-fetches and indexes them
88+ // immediately instead of waiting for the jetstream.
99+ notify: true,
610 // `spaces` is declared statically so `pnpm generate` emits the `rsvp.atmo.space.*`
711 // lexicons. The real serviceDid is injected at runtime in `$lib/contrail/index.ts`
812 // via `getSpacesConfig()` — generate doesn't serialize it.
···2327 name: {},
2428 status: {},
2529 description: {},
3030+ 'preferences.showInDiscovery': {},
2631 startsAt: { type: 'range' },
2732 endsAt: { type: 'range' },
2833 createdAt: { type: 'range' }
···3843 notgoing: 'community.lexicon.calendar.rsvp#notgoing'
3944 }
4045 }
4646+ },
4747+ pipelineQueries: {
4848+ // Endpoint: rsvp.atmo.event.listDiscoverable
4949+ // Same shape as listRecords, but filters out unlisted events
5050+ // (preferences.showInDiscovery === false). Missing field defaults
5151+ // to true, so pre-existing records without `preferences` are included.
5252+ listDiscoverable: async () => ({
5353+ conditions: [
5454+ `(json_extract(r.record, '$.preferences.showInDiscovery') IS NULL
5555+ OR json_extract(r.record, '$.preferences.showInDiscovery') != 0)`
5656+ ]
5757+ })
4158 }
4259 },
4360 rsvp: {
+3
src/lib/spaces/server/spaces.remote.ts
···11import { error } from '@sveltejs/kit';
22import { command, query, getRequestEvent } from '$app/server';
33+import { dev } from '$app/environment';
34import * as v from 'valibot';
45import '../../../lexicon-types/index.js';
56import { getSpacesClient } from './client';
···2627 record: v.record(v.string(), v.unknown())
2728 }),
2829 async (input) => {
3030+ if (!dev) error(403, 'Private events are not available yet');
2931 const { client } = getClient();
30323133 const createRes = await client.post('rsvp.atmo.space.admin.createSpace', {
···149151export const createInvite = command(
150152 v.object({
151153 spaceUri: atUriSchema,
154154+ kind: v.optional(v.picklist(['join', 'read', 'read-join'])),
152155 perms: v.optional(v.string()),
153156 expiresAt: v.optional(v.number()),
154157 maxUses: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1))),
+2-2
src/lib/spaces/tunnel-service.generated.ts
···22 * When the tunnel is running, this file is rewritten with the tunnel's
33 * service DID + URL; when the tunnel stops, it is reset to null values. */
4455-export const SERVICE_DID: string | null = "did:web:kid-retention-reservation-proc.trycloudflare.com";
66-export const SERVICE_URL: string | null = "https://kid-retention-reservation-proc.trycloudflare.com";
55+export const SERVICE_DID: string | null = "did:web:salary-lists-seas-species.trycloudflare.com";
66+export const SERVICE_URL: string | null = "https://salary-lists-seas-species.trycloudflare.com";
···1515 const token = page.url.searchParams.get('invite');
1616 if (!token) return;
1717 if (data.authState === 'anon') return;
1818+ // Anonymous viewer who got in via the read-token bearer path — no
1919+ // redemption to do (they're not logged in, the link is just for reading).
2020+ if ('viaInviteToken' in data && data.viaInviteToken) return;
18211922 inviteBusy = true;
2023 try {