0
2
// Demo data for the groups branch against the opensocial dev stack: the
// cycling club, in Grain. The riders already exist there — apps/group's
// seed founds the club and its members — so this only gives them a Grain
// profile and their galleries of ride photos.
//
// Most of those go straight into the club's pool, which is a permissioned
// space the club owns: the records are the rider's, in the rider's own repo,
// but only the club's members can read them. Nothing is offered and nothing is
// accepted — being a member is the whole permission. One gallery is published
// the ordinary public way, so the difference is visible in one seed.
//
// The club (Rain Shadow Riders) is looked up on the host by handle. Without it
// every gallery falls back to public.
//
// PDS_URL=http://localhost:2583 HOST_URL=http://localhost:4000 \
// npx tsx seeds/opensocial-demo.ts
//
// Ride photos are Bryan Newbold's, from bnewbold.net — see
// seeds/images/rides/CREDITS.md.
import exifr from "exifr";
import { seed } from "@hatk/hatk/seed";
const PDS = process.env.PDS_URL ?? "http://localhost:2583";
const { createAccount, createRecord, uploadBlob } = seed({
pds: PDS,
password: process.env.SEED_PASSWORD ?? "demo-pass",
});
const now = Date.now();
const ago = (minutes: number) => new Date(now - minutes * 60_000).toISOString();
// What the app records when you upload a photo, read here from the file
// instead of the browser: make, model, lens, exposure. Every number is scaled
// by a million, the way social.grain.photo.exif asks for it. Photos without
// EXIF simply get no record.
const SCALE = 1_000_000;
async function exifOf(path: string) {
const raw = await exifr
.parse(path, {
pick: [
"Make",
"Model",
"LensMake",
"LensModel",
"ExposureTime",
"FNumber",
"ISO",
"FocalLengthIn35mmFormat",
"Flash",
"DateTimeOriginal",
],
})
.catch(() => null);
if (!raw) return null;
const out: Record<string, string | number> = {};
for (const [from, to] of [
["Make", "make"],
["Model", "model"],
["LensMake", "lensMake"],
["LensModel", "lensModel"],
] as const) {
if (raw[from]) out[to] = String(raw[from]).trim();
}
for (const [from, to] of [
["ExposureTime", "exposureTime"],
["FNumber", "fNumber"],
["ISO", "iSO"],
["FocalLengthIn35mmFormat", "focalLengthIn35mmFormat"],
] as const) {
if (raw[from]) out[to] = Math.round(raw[from] * SCALE);
}
if (raw.Flash != null) out.flash = String(raw.Flash);
if (raw.DateTimeOriginal instanceof Date)
out.dateTimeOriginal = raw.DateTimeOriginal.toISOString();
return Object.keys(out).length ? out : null;
}
type Rider = Awaited<ReturnType<typeof createAccount>>;
/**
* Write records into a space, as the account whose repo they land in.
*
* Not `createRecord`: that writes to the public repo, and the whole point of a
* pool gallery is that it does not go there. A space write is the same commit
* through a different door — `com.atproto.space.applyWrites`, naming the space
* — and the PDS asks the space's authority whether this account may.
*/
async function applyWrites(who: Rider, space: string, writes: Record<string, unknown>[]) {
const call = (nsid: string, body: unknown) =>
fetch(`${PDS}/xrpc/${nsid}`, {
method: "POST",
headers: {
authorization: `Bearer ${who.accessJwt}`,
"content-type": "application/json",
},
body: JSON.stringify(body),
});
const res = await call("com.atproto.space.applyWrites", { space, repo: who.did, writes });
if (res.ok) return;
const body = await res.text();
// Everything here has a stable rkey, so a second run finds its own records
// already there. One commit is the nice path; re-runnability is the one that
// matters, so fall back to overwriting them one at a time.
if (!body.includes("RecordAlreadyExists")) {
throw new Error(`space.applyWrites ${res.status}: ${body}`);
}
for (const w of writes) {
const put = await call("com.atproto.space.putRecord", {
space,
repo: who.did,
collection: w.collection,
rkey: w.rkey,
validate: false,
record: w.value,
});
if (!put.ok) throw new Error(`space.putRecord ${put.status}: ${await put.text()}`);
}
}
async function gallery(
who: Rider,
rkey: string,
title: string,
description: string,
photos: { file: string; alt: string; ratio: [number, number] }[],
minutesAgo: number,
) {
const g = await createRecord(
who,
"social.grain.gallery",
{ title, description, createdAt: ago(minutesAgo) },
{ rkey },
);
for (const [i, p] of photos.entries()) {
const blob = await uploadBlob(who, `./seeds/images/${p.file}`);
const photo = await createRecord(
who,
"social.grain.photo",
{
photo: blob,
alt: p.alt,
aspectRatio: { width: p.ratio[0], height: p.ratio[1] },
createdAt: ago(minutesAgo),
},
{ rkey: `${rkey}-p${i}` },
);
await createRecord(
who,
"social.grain.gallery.item",
{ gallery: g.uri, item: photo.uri, position: i, createdAt: ago(minutesAgo) },
{ rkey: `${rkey}-i${i}` },
);
const exif = await exifOf(`./seeds/images/${p.file}`);
if (exif) {
await createRecord(
who,
"social.grain.photo.exif",
{ photo: photo.uri, ...exif, createdAt: ago(minutesAgo) },
{ rkey: `${rkey}-x${i}` },
);
}
}
console.log(`[seed] ${who.handle}: ${title} (${photos.length} photos) ${g.uri}`);
return g;
}
// ------------------------------------------------- the club and its riders
//
// Rain Shadow Riders is founded by apps/group's seed on the opensocial host:
// its members, roles, events and rules all live there. Grain knows it only as
// a DID with a fyi.opensocial.declaration — the one discoverable fact a
// third-party app gates on — so all it needs from the host is that DID.
const HOST = process.env.HOST_URL ?? "http://localhost:4000";
const CLUB_HANDLE = process.env.CLUB_HANDLE ?? "rain-shadow-riders.opensocial.test";
/** The club's DID, or null if this network has no such group. */
async function findClub(handle: string): Promise<string | null> {
try {
const res = await fetch(`${HOST}/xrpc/fyi.opensocial.listGroups`);
if (!res.ok) return null;
const { groups } = (await res.json()) as {
groups: { handle: string; did: string }[];
};
return groups.find((c) => c.handle === handle)?.did ?? null;
} catch {
return null;
}
}
const club = await findClub(CLUB_HANDLE);
console.log(
club
? `[seed] club ${CLUB_HANDLE} ${club}`
: `[seed] no ${CLUB_HANDLE} on ${HOST} — every gallery falls back to public`,
);
/** A club member with a Grain face: display name, bio, and an avatar. */
async function rider(handle: string, displayName: string, description: string, avatar: string) {
const who = await createAccount(handle);
const blob = await uploadBlob(who, `./seeds/images/rides/${avatar}`);
await createRecord(
who,
"social.grain.actor.profile",
{ displayName, description, avatar: blob, createdAt: ago(60 * 24 * 30) },
{ rkey: "self" },
);
return who;
}
/** The club's pool, by the convention every app here uses: one space, skey `self`. */
const POOL = club ? `at://${club}/space/social.grain.group/self` : null;
/**
* The same gallery, written into the club's pool instead of the public repo.
*
* Records in a space have no uri of their own — the address is the space, the
* repo, the collection and the rkey — so an item names its gallery and its
* photo by the uri each would have had in this rider's public repo. Every
* reader, here and in apps/group, puts them back together that way.
*
* No EXIF record: the space declares the three collections a gallery is made
* of, and a camera's serial number is not one of the things the club asked to
* keep for its members.
*/
async function poolGallery(
who: Rider,
rkey: string,
title: string,
description: string,
photos: { file: string; alt: string; ratio: [number, number] }[],
minutesAgo: number,
) {
if (!POOL) return gallery(who, rkey, title, description, photos, minutesAgo);
const createdAt = ago(minutesAgo);
const self = (collection: string, key: string) => `at://${who.did}/${collection}/${key}`;
const writes: Record<string, unknown>[] = [
{
$type: "com.atproto.space.applyWrites#create",
collection: "social.grain.gallery",
rkey,
value: { $type: "social.grain.gallery", title, description, createdAt },
},
];
for (const [i, p] of photos.entries()) {
const blob = await uploadBlob(who, `./seeds/images/${p.file}`);
const photoRkey = `${rkey}-p${i}`;
writes.push({
$type: "com.atproto.space.applyWrites#create",
collection: "social.grain.photo",
rkey: photoRkey,
value: {
$type: "social.grain.photo",
photo: blob,
alt: p.alt,
aspectRatio: { width: p.ratio[0], height: p.ratio[1] },
createdAt,
},
});
writes.push({
$type: "com.atproto.space.applyWrites#create",
collection: "social.grain.gallery.item",
rkey: photoRkey,
value: {
$type: "social.grain.gallery.item",
gallery: self("social.grain.gallery", rkey),
item: self("social.grain.photo", photoRkey),
position: i,
createdAt,
},
});
}
await applyWrites(who, POOL, writes);
console.log(`[seed] ${who.handle}: ${title} (${photos.length} photos) → the club's pool`);
return { uri: self("social.grain.gallery", rkey) };
}
/**
* A favourite and a comment on somebody else's pool gallery.
*
* Both are the reader's own records, and both go into the pool rather than
* their public repo — in the public repo they would name a private gallery to
* the whole network, which is what the pool exists to prevent. It also means a
* thread is spread across the repos of everyone in it: your reply to my gallery
* was never mine to hold.
*/
async function poolFavorite(who: Rider, g: { uri: string }, rkey: string, minutesAgo: number) {
if (!POOL) return;
await applyWrites(who, POOL, [
{
$type: "com.atproto.space.applyWrites#create",
collection: "social.grain.favorite",
rkey,
value: {
$type: "social.grain.favorite",
subject: g.uri,
createdAt: ago(minutesAgo),
},
},
]);
}
async function poolComment(
who: Rider,
g: { uri: string },
rkey: string,
text: string,
minutesAgo: number,
) {
if (!POOL) return;
await applyWrites(who, POOL, [
{
$type: "com.atproto.space.applyWrites#create",
collection: "social.grain.comment",
rkey,
value: {
$type: "social.grain.comment",
subject: g.uri,
text,
createdAt: ago(minutesAgo),
},
},
]);
console.log(`[seed] ${who.handle} commented on ${g.uri.split("/").pop()}`);
}
// Everyone on the club's roster gets a face, not just the riders whose photos
// are in the pool: the club's own site reads member avatars from this same
// `social.grain.actor.profile` record, so an organiser without one is a grey
// circle in both apps.
const alex = await rider(
"alex-kim.test",
"Alex Kim",
"Founded Rain Shadow Riders in 2019. Still sweeps the Saturday loop.",
"avatar-alex.jpg",
);
const dana = await rider(
"dana-whitfield.test",
"Dana Whitfield",
"Moderates the club. Fixes your derailleur at the roadside, unasked.",
"avatar-dana.jpg",
);
const sam = await rider(
"sam-ortiz.test",
"Sam Ortiz",
"Newest on the Tuesday spin. Asks the tyre questions everyone else is thinking.",
"avatar-sam.jpg",
);
const priya = await rider(
"priya-nair.test",
"Priya Nair",
"Organizer and moderator at Rain Shadow Riders. Tours when the club is off.",
"avatar-priya.jpg",
);
const marcus = await rider(
"marcus-oyelaran.test",
"Marcus Oyelaran",
"Posts the rides. Long way round, always.",
"avatar-marcus.jpg",
);
const lena = await rider(
"lena-fox.test",
"Lena Fox",
"Tuesday spin regular. Rain is just weather.",
"avatar-lena.jpg",
);
const jo = await rider(
"jo-tanaka.test",
"Jo Tanaka",
"Rides for the coffee. Hauls the rest of it.",
"avatar-jo.jpg",
);
const tom = await rider(
"tom-reyes.test",
"Tom Reyes",
"Fixes everyone's bike at the roadside. Owns four.",
"avatar-tom.jpg",
);
const WIDE: [number, number] = [3, 2];
const PHONE: [number, number] = [4, 3];
const TALL: [number, number] = [3, 4];
// The galleries are dated to the club's rides — apps/group's seed puts a
// past ride at each of these distances — because the club's site files a
// gallery under the ride it came from by when it was made. Titles say which
// ride; the photos are members' own.
const DAY = 60 * 24;
const century = await poolGallery(
priya,
"century-from-the-sweep",
"Century, from the sweep",
"Riding sweep on the spring century: everything behind the second group, at the second group's pace. Loaded, slow, and worth it.",
[
{
file: "rides/olympics-loaded-bike.jpg",
alt: "A loaded touring bike leaning against a stone wall at the start",
ratio: WIDE,
},
{
file: "rides/olympics-three-up.jpg",
alt: "Three riders with panniers riding a forest road together",
ratio: WIDE,
},
{
file: "rides/olympics-alder-tunnel.jpg",
alt: "A rider disappearing into a tunnel of pale alder trunks",
ratio: WIDE,
},
{
file: "rides/olympics-lake-camp.jpg",
alt: "A lake under cloud and mountains, from the second aid stop",
ratio: WIDE,
},
{
file: "rides/olympics-ferry-deck.jpg",
alt: "Loaded bikes strapped to the rail on the ferry home",
ratio: WIDE,
},
],
23 * DAY,
);
const tunnel = await poolGallery(
marcus,
"tunnel-hill-the-long-way",
"Tunnel Hill, the long way",
"Six hours, a tunnel, and about a hundred miles of nothing. Ask me about the headwind.",
[
{
file: "rides/yakima-autumn-hillside.jpg",
alt: "A hillside turning orange and red above the road",
ratio: WIDE,
},
{
file: "rides/yakima-tunnel-arches.jpg",
alt: "The ribbed arches of the old railway tunnel, lit at the far end",
ratio: TALL,
},
{
file: "rides/yakima-empty-road.jpg",
alt: "Empty two-lane road curving through dry golden hills",
ratio: WIDE,
},
{
file: "rides/yakima-stonehenge.jpg",
alt: "A concrete Stonehenge replica on a bluff, one person for scale",
ratio: WIDE,
},
{
file: "rides/yakima-river-bend.jpg",
alt: "The river bending below the road, blue against brown hills",
ratio: WIDE,
},
],
51 * DAY,
);
await poolGallery(
jo,
"club-errands",
"Club errands",
"Everything the century after-party needed, delivered by bike. The keg was the hard one.",
[
{
file: "rides/errand-beer-run.jpg",
alt: "A pink bike with a case of beer strapped to the front rack",
ratio: PHONE,
},
{
file: "rides/errand-keg-trailer.jpg",
alt: "A bike towing a trailer with a keg strapped into it",
ratio: PHONE,
},
{
file: "rides/errand-grocery-crew.jpg",
alt: "Three riders behind a bike loaded with groceries on a sunny street",
ratio: PHONE,
},
],
26 * DAY,
);
const ferry = await poolGallery(
tom,
"ferry-loop-and-a-flat",
"Ferry loop, and a flat",
"Two ways a ride ends up on the ground.",
[
{
file: "rides/ferry-bike-deck.jpg",
alt: "A single bike leaning on the rail of an empty ferry deck",
ratio: PHONE,
},
{
file: "rides/roadside-strip-down.jpg",
alt: "A bike stripped down at a roadside pullout, bags spread on the tarmac",
ratio: PHONE,
},
],
79 * DAY,
);
await poolGallery(
tom,
"rain-or-shine",
"Rain or shine",
"It said rain or shine. It was not shine.",
[
{
file: "rides/rain-bridge-bikes.jpg",
alt: "Loaded bikes leaning on a wet stone bridge wall, the river grey behind",
ratio: PHONE,
},
{
file: "rides/rain-highway-bridge.jpg",
alt: "An empty highway bridge over the river, seen from the hill above in the rain",
ratio: PHONE,
},
{
file: "rides/rain-dam-cloud.jpg",
alt: "Low cloud over the dam and the river, everything one shade of grey",
ratio: PHONE,
},
{
file: "rides/rain-wet-panniers.jpg",
alt: "Yellow and red panniers stacked on a porch to dry",
ratio: TALL,
},
],
107 * DAY,
);
await poolGallery(
lena,
"wind-farm-loop",
"Wind farm loop",
"Tailwind home for once. The turbines were pointing the way we were going.",
[
{
file: "rides/wind-pass-sign.jpg",
alt: "A loaded bike with yellow panniers beside a summit sign on an empty road",
ratio: WIDE,
},
{
file: "rides/wind-viewpoint.jpg",
alt: "A viewpoint sign naming the mountains, brown grass to the horizon",
ratio: WIDE,
},
{
file: "rides/wind-turbine-road.jpg",
alt: "The road climbing toward a ridge of wind turbines",
ratio: WIDE,
},
{
file: "rides/wind-golden-hills.jpg",
alt: "Golden hills and yellow rabbitbrush under a clear sky",
ratio: WIDE,
},
{
file: "rides/wind-old-road.jpg",
alt: "A cracked old road running out toward the turbines",
ratio: WIDE,
},
],
149 * DAY,
);
// The one that did not go to the club: an ordinary public gallery in Lena's
// own repo, on the same afternoon, from the same app. Anyone can see this one,
// including people who have never heard of Rain Shadow Riders — which is what
// the six above are not.
await gallery(
lena,
"rain-on-the-gorge",
"Rain on the gorge",
"It rained for two days. Turns out that is when the waterfalls are worth it.",
[
{
file: "rides/gorge-misty-bikes.jpg",
alt: "Two loaded bikes at a guardrail, a rider in red, mist in the firs",
ratio: PHONE,
},
{
file: "rides/gorge-wet-bridge.jpg",
alt: "A rider crossing a wet steel bridge in the rain",
ratio: TALL,
},
{
file: "rides/gorge-waterfall.jpg",
alt: "A tall waterfall dropping down a mossy basalt cliff",
ratio: TALL,
},
],
45,
);
// What members say to each other inside the pool. Every one of these is the
// speaker's own record, written into the club's space: members see them, the
// network never does, and there is no public count of any of it anywhere.
await poolFavorite(jo, century, "fav-century", 21 * DAY);
await poolFavorite(tom, century, "fav-century", 20 * DAY);
await poolFavorite(lena, century, "fav-century", 19 * DAY);
await poolComment(
jo,
century,
"comment-century",
"The alder tunnel shot. That is exactly what it looked like from the back.",
20 * DAY,
);
await poolComment(
marcus,
century,
"comment-century",
"Sweep is the best seat on the century and nobody believes me.",
19 * DAY,
);
await poolFavorite(priya, tunnel, "fav-tunnel", 49 * DAY);
await poolComment(
priya,
tunnel,
"comment-tunnel",
"Lights checked at the start because of this ride, for the record.",
49 * DAY,
);
await poolFavorite(jo, ferry, "fav-ferry", 78 * DAY);
await poolFavorite(alex, ferry, "fav-ferry", 77 * DAY);
await poolFavorite(sam, tunnel, "fav-tunnel", 47 * DAY);
await poolComment(
dana,
tunnel,
"comment-tunnel",
"Every light checked, nobody in the dark. Good ride.",
48 * DAY,
);
await poolComment(
lena,
ferry,
"comment-ferry",
"Fixed it in nine minutes flat and still made the 10:40.",
78 * DAY,
);
console.log("[seed] done");