@vibes.diy/prompts 14.1.25 → 14.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/llms/access.md CHANGED
@@ -230,6 +230,20 @@ export function chat(doc, oldDoc, user, ctx) {
230
230
 
231
231
  **A follow-up edit that adds a NEW doc type updates `access.js` FIRST.** The app runs live while your edits stream in, and writes are enforced against the access.js that was in force before this turn until the whole turn completes — so a `db.put` of a doc type the old function rejects fails immediately (`unknown document type`), even though your access.js update lands later in the same reply. Emit the access.js edit adding the new type's branch before the App.jsx edits that write it. And never fire-and-forget background writes of a newly added type: gate seed/auto-write effects on `useVibe(dbName)` — `if (!ready || !can.create(sampleDoc).ok) return;` with `ready`/`can` in the effect deps, checking a representative sample of **each** doc type the effect writes — so a not-yet-allowed write is skipped quietly instead of surfacing rejection errors the user didn't cause.
232
232
 
233
+ ### Reconciling `seed.json` when you write or change `access.js`
234
+
235
+ An app usually gets its `seed.json` on an earlier turn than its `access.js`. So when you add or change an access function, you are almost always meeting a seed file that was written before any of these rules existed — **and the seed is yours to bring into line, not the other way round.** The access function is the constraint; the seed is a sample of the data. When a sample violates a constraint, change the sample.
236
+
237
+ This matters beyond the import itself. The app's `seed.json` stays in your context on every later turn, so it is what a future turn reads to learn the app's document shape. A seed that disagrees with the access function teaches the wrong shape to every turn that follows.
238
+
239
+ So, in the same turn as the access edit and after it:
240
+
241
+ - **Re-emit `seed.json` to fit the function.** Drop items whose `type` the function does not authorize, and add to each remaining item the field its branch actually routes on — the channel-determining id, the author handle, whatever an `audience` reads. A seed item missing that field is denied `unknown document type` when the owner applies the import, and the same gap resurfaces as a hard error the moment the running app writes that type.
242
+ - **Never add a branch to `access.js` just to admit a seed item.** A branch exists because the app writes that type. If a seed item's type appears nowhere in the app's own code, delete the item — do not widen the permission model to let example data through.
243
+ - **Keep every explicit `_id` byte-identical.** Seed items that other items reference, and well-known singletons (`config:profile`, `settings:sharing`), carry an explicit `_id` that the app's code matches literally. Renaming one during a reconcile breaks that lookup in `App.jsx` and orphans any document an owner already imported, in a single edit. Keep the `key` slugs stable for the same reason — the platform derives idempotency from them.
244
+
245
+ If the reconcile removes every item, **still emit `seed.json`, containing `{}`**. Omitting the file does NOT delete it — a file your turn does not touch is carried forward from the previous version verbatim, so leaving `seed.json` out preserves exactly the stale, unauthorized seed you were removing. An empty object is a valid seed and replaces it.
246
+
233
247
  ### Ending the function: terminal denial (the default) vs. the discard channel
234
248
 
235
249
  Every access function ends by handling the doc types it knows and **denying everything else**. The **default** ending — closing every worked example below — is the terminal `throw { forbidden: "unknown document type" }`: fail-closed, and loud. If the app later writes a type nobody added a branch for, the write is rejected with a visible error, so type-enumeration drift surfaces instead of silently corrupting the data model. **Reach for the throw unless you have a specific, named reason not to.**
@@ -0,0 +1,2 @@
1
+ import type { LlmConfig } from "./types.js";
2
+ export declare const blueskyConfig: LlmConfig;
@@ -0,0 +1,15 @@
1
+ export const blueskyConfig = {
2
+ name: "bluesky",
3
+ label: "Bluesky public data",
4
+ description: "Reading PUBLIC Bluesky data with no credential of any kind: the unauthenticated XRPC host, profiles, follows and followers, an account's own feed, actor search, why post search is not available without a session, checking a response's status before parsing it because a refusal comes back as HTML, and paging on the cursor rather than on emptiness",
5
+ cues: [
6
+ "bluesky",
7
+ "bsky",
8
+ "at protocol",
9
+ "atproto",
10
+ "my bluesky followers",
11
+ "bluesky profile",
12
+ "bluesky feed",
13
+ ],
14
+ };
15
+ //# sourceMappingURL=bluesky.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bluesky.js","sourceRoot":"","sources":["../../jsr/llms/bluesky.ts"],"names":[],"mappings":"AAKA,MAAM,CAAC,MAAM,aAAa,GAAc;IACtC,IAAI,EAAE,SAAS;IACf,KAAK,EAAE,qBAAqB;IAC5B,WAAW,EACT,6VAA6V;IAC/V,IAAI,EAAE;QACJ,SAAS;QACT,MAAM;QACN,aAAa;QACb,SAAS;QACT,sBAAsB;QACtB,iBAAiB;QACjB,cAAc;KACf;CACF,CAAC"}
@@ -0,0 +1,343 @@
1
+ # Bluesky — public profiles, follows and feeds, with no login
2
+
3
+ When someone asks to look at Bluesky — a profile card, who somebody follows, a
4
+ wall of an account's posts, a search for people — build it on the **public
5
+ AT Protocol XRPC host**, `https://public.api.bsky.app/xrpc`. Every endpoint on
6
+ this page answers an anonymous request. This is a `backend.js` feature (see the
7
+ backend docs for the general handler contract); this page is the recipe that
8
+ keeps it credential-free, honest about what the host refuses, and correct about
9
+ paging.
10
+
11
+ ## 1. No credential, ever — and why this is not a connection
12
+
13
+ Bluesky's public API needs no login, so the app renders no token field, no password box and no sign-in step.
14
+
15
+ There is no API key to get, no app password to paste, no OAuth redirect and no
16
+ `ctx.secrets` entry. `public.api.bsky.app` reads **public data about any
17
+ account** — the same data an anonymous visitor to bsky.app can see — and it
18
+ reads it for anybody who asks. An app that grows a "Bluesky app password" input
19
+ has invented a requirement the API does not have, and has put a credential in a
20
+ readable channel for nothing.
21
+
22
+ **This is public data about any account, so no login is needed. An app where
23
+ each visitor links their OWN account is a different thing — that is a
24
+ connection (the `connections` skill) — and Bluesky is not a connections
25
+ provider, so it needs none.** Read the boundary the way the request is worded:
26
+ "show me @alice's followers", "a wall of posts from these five accounts",
27
+ "search Bluesky for people who mention pottery" are all public reads and belong
28
+ here. "Post to my Bluesky", "show me MY timeline", "reply as me" need an
29
+ authenticated session this page does not give you — say so plainly and build
30
+ the public half, rather than asking the person for a password.
31
+
32
+ ## 2. What answers unauthenticated, and what does not
33
+
34
+ Base: `https://public.api.bsky.app/xrpc`. `actor` takes a handle
35
+ (`alice.bsky.social`) or a DID (`did:plc:…`) — the same value works either way.
36
+
37
+ | Endpoint | Query | Answers |
38
+ | ---------------------------- | -------------------- | -------------------------------------------------------------- |
39
+ | `app.bsky.actor.getProfile` | `actor` | `{ did, handle, displayName, avatar, description, followersCount, followsCount, postsCount }` |
40
+ | `app.bsky.graph.getFollows` | `actor`, `limit`, `cursor` | `{ follows: [profile…], cursor }` |
41
+ | `app.bsky.graph.getFollowers`| `actor`, `limit`, `cursor` | `{ followers: [profile…], cursor }` |
42
+ | `app.bsky.feed.getAuthorFeed`| `actor`, `limit`, `cursor` | `{ feed: [{ post: { uri, cid, author, record, … } }], cursor }` |
43
+ | `app.bsky.actor.searchActors`| `q`, `limit`, `cursor` | `{ actors: [profile…], cursor }` |
44
+ | `app.bsky.feed.getPostThread`| `uri` (`at://…`) | a post and its replies |
45
+
46
+ **`app.bsky.feed.searchPosts` is not available unauthenticated — it answers
47
+ `403`, so do not build post search on this host.** Full-text post search needs
48
+ a logged-in session. When somebody asks to search posts, `searchActors` finds
49
+ the people and `getAuthorFeed` reads what they wrote — say what the app does
50
+ instead of failing silently on a refusal it was never going to survive.
51
+
52
+ The profile objects inside `follows`, `followers`, `actors` and `post.author`
53
+ are the same small shape: `did`, `handle`, `displayName`, `avatar`,
54
+ `description`. A post's text is at `post.record.text` and its time at
55
+ `post.record.createdAt`; `displayName` and `avatar` are frequently absent, so
56
+ fall back to the handle rather than rendering an empty card.
57
+
58
+ ## 3. Check the response before you parse it
59
+
60
+ Check `res.ok` before calling `res.json()` — a refusal from this host comes back as HTML, so parsing it first throws instead of returning an error object.
61
+
62
+ This is the trap that turns a handled refusal into a dead app. The `403` from
63
+ `searchPosts` has an HTML body, so `await res.json()` raises a `SyntaxError`
64
+ before any `if (body.error)` line can run, and — because an uncaught throw in a
65
+ handler renders as an opaque `404` — the app tells its user it does not exist.
66
+ A 400 from a real XRPC route (a bad `uri`, an unknown actor) *is* JSON, shaped
67
+ `{ "error": "NotFound", "message": … }`, which is worth passing to the screen.
68
+
69
+ So: status first, JSON second, and a stated reason either way.
70
+
71
+ ```js
72
+ async function bsky(ctx, method, params) {
73
+ const url = new URL(`https://public.api.bsky.app/xrpc/${method}`);
74
+ for (const [k, v] of Object.entries(params)) if (v != null) url.searchParams.set(k, String(v));
75
+ const res = await ctx.fetch(url.toString(), { headers: { accept: "application/json" } });
76
+ const type = res.headers.get("content-type") ?? "";
77
+ if (!type.includes("application/json")) {
78
+ // A 403 from searchPosts lands here, as HTML. Parsing it would throw.
79
+ return { ok: false, reason: "unavailable", message: `Bluesky answered ${res.status} for ${method}.` };
80
+ }
81
+ const body = await res.json().catch(() => null);
82
+ if (!res.ok || !body) {
83
+ return { ok: false, reason: body?.error ?? "http-error", message: body?.message ?? `HTTP ${res.status}` };
84
+ }
85
+ return { ok: true, body };
86
+ }
87
+ ```
88
+
89
+ The host is CORS-open (`access-control-allow-origin: *`), so a direct call from
90
+ `App.jsx` would reach it too. Build it in `backend.js` anyway: the status check,
91
+ the paging loop and the read cache then live in one place, and the client has
92
+ one `/_api` shape to branch on instead of a raw upstream body.
93
+
94
+ ## 4. Paging follows the cursor
95
+
96
+ Page with `cursor`: pass the previous response's `cursor` back as `?cursor=`, and stop when the response carries none — never loop until a page comes back empty.
97
+
98
+ `limit` caps a page (100 is the ceiling on the graph and feed routes). The
99
+ response's `cursor` is the address of the next page; its **absence** is the end
100
+ of the list. A filtered page can be empty and still carry a cursor, so an
101
+ emptiness test stops early and quietly loses the rest of somebody's followers.
102
+
103
+ ```js
104
+ async function pages(ctx, method, params, key, max = 500) {
105
+ const rows = [];
106
+ let cursor;
107
+ do {
108
+ const page = await bsky(ctx, method, { ...params, limit: 100, cursor });
109
+ if (!page.ok) return rows.length ? { ok: true, rows, partial: page.reason } : page;
110
+ rows.push(...(page.body[key] ?? []));
111
+ cursor = page.body.cursor;
112
+ } while (cursor && rows.length < max);
113
+ return { ok: true, rows };
114
+ }
115
+ ```
116
+
117
+ Bound the walk. A popular account has hundreds of thousands of followers, and a
118
+ handler that walks all of them hits the 15s request budget and returns nothing
119
+ at all — so cap the sweep, and tell the screen the list was capped rather than
120
+ implying it is complete.
121
+
122
+ ## 5. Every `/_api` route keeps its reason
123
+
124
+ An uncaught throw in a handler is folded into an opaque `404 backend.js _api: not found`, so every `/_api` route wraps its work in try/catch and answers with a stated reason.
125
+
126
+ That fold is why a transient upstream blip reads to the user as "this app does
127
+ not exist". Wrap the body of each route, answer `{ ok: false, reason }`, and log
128
+ the throw with `ctx.log` so an operator can tell "the request never arrived"
129
+ from "the request arrived and died".
130
+
131
+ A `not-found` from Bluesky is worth its own reason too: an actor who does not
132
+ exist, a renamed handle, and a deactivated account all come back as a 400 with
133
+ `error: "NotFound"`, and "we could not find @name on Bluesky" is an answer the
134
+ person can act on — "no data" is not.
135
+
136
+ ## Complete backend.js
137
+
138
+ ```js
139
+ const XRPC = "https://public.api.bsky.app/xrpc";
140
+ const DB = "bluesky";
141
+
142
+ async function bsky(ctx, method, params) {
143
+ const url = new URL(`${XRPC}/${method}`);
144
+ for (const [k, v] of Object.entries(params)) if (v != null) url.searchParams.set(k, String(v));
145
+ const res = await ctx.fetch(url.toString(), { headers: { accept: "application/json" } });
146
+ // Status and content-type BEFORE parsing: a refusal from this host has an HTML body.
147
+ const type = res.headers.get("content-type") ?? "";
148
+ if (!type.includes("application/json")) {
149
+ return { ok: false, reason: "unavailable", message: `Bluesky answered ${res.status} for ${method}.` };
150
+ }
151
+ const body = await res.json().catch(() => null);
152
+ if (!res.ok || !body) {
153
+ return { ok: false, reason: body?.error ?? "http-error", message: body?.message ?? `HTTP ${res.status}` };
154
+ }
155
+ return { ok: true, body };
156
+ }
157
+
158
+ /** Walk `cursor` to the end, or to `max` rows — never until a page looks empty. */
159
+ async function pages(ctx, method, params, key, max = 500) {
160
+ const rows = [];
161
+ let cursor;
162
+ do {
163
+ const page = await bsky(ctx, method, { ...params, limit: 100, cursor });
164
+ if (!page.ok) return rows.length ? { ok: true, rows, capped: true, partial: page.reason } : page;
165
+ rows.push(...(page.body[key] ?? []));
166
+ cursor = page.body.cursor;
167
+ } while (cursor && rows.length < max);
168
+ return { ok: true, rows, capped: Boolean(cursor) };
169
+ }
170
+
171
+ const asProfile = (p) => ({
172
+ did: p.did,
173
+ handle: p.handle,
174
+ displayName: p.displayName ?? p.handle,
175
+ avatar: p.avatar ?? null,
176
+ description: p.description ?? "",
177
+ });
178
+
179
+ function json(payload, status = 200) {
180
+ return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
181
+ }
182
+
183
+ export async function fetch(request, ctx) {
184
+ const url = new URL(request.url);
185
+ const actor = (url.searchParams.get("actor") ?? "").trim().replace(/^@/, "");
186
+
187
+ try {
188
+ if (url.pathname === "/profile") {
189
+ if (!actor) return json({ ok: false, reason: "no-actor", message: "Type a Bluesky handle." }, 400);
190
+ const result = await bsky(ctx, "app.bsky.actor.getProfile", { actor });
191
+ if (!result.ok) return json(result);
192
+ const p = result.body;
193
+ return json({
194
+ ok: true,
195
+ profile: {
196
+ ...asProfile(p),
197
+ followersCount: p.followersCount ?? 0,
198
+ followsCount: p.followsCount ?? 0,
199
+ postsCount: p.postsCount ?? 0,
200
+ },
201
+ });
202
+ }
203
+
204
+ if (url.pathname === "/follows" || url.pathname === "/followers") {
205
+ if (!actor) return json({ ok: false, reason: "no-actor", message: "Type a Bluesky handle." }, 400);
206
+ const following = url.pathname === "/follows";
207
+ const method = following ? "app.bsky.graph.getFollows" : "app.bsky.graph.getFollowers";
208
+ const result = await pages(ctx, method, { actor }, following ? "follows" : "followers");
209
+ if (!result.ok) return json(result);
210
+ return json({ ok: true, capped: result.capped === true, people: result.rows.map(asProfile) });
211
+ }
212
+
213
+ if (url.pathname === "/feed") {
214
+ if (!actor) return json({ ok: false, reason: "no-actor", message: "Type a Bluesky handle." }, 400);
215
+ const result = await pages(ctx, "app.bsky.feed.getAuthorFeed", { actor }, "feed", 100);
216
+ if (!result.ok) return json(result);
217
+ const posts = result.rows
218
+ .map((row) => row.post)
219
+ .filter(Boolean)
220
+ .map((post) => ({
221
+ uri: post.uri,
222
+ cid: post.cid,
223
+ text: post.record?.text ?? "",
224
+ createdAt: post.record?.createdAt ?? null,
225
+ author: asProfile(post.author ?? {}),
226
+ replyCount: post.replyCount ?? 0,
227
+ repostCount: post.repostCount ?? 0,
228
+ likeCount: post.likeCount ?? 0,
229
+ }));
230
+ return json({ ok: true, posts });
231
+ }
232
+
233
+ if (url.pathname === "/search") {
234
+ const q = (url.searchParams.get("q") ?? "").trim();
235
+ if (!q) return json({ ok: false, reason: "no-query", message: "Type something to search for." }, 400);
236
+ // Only PEOPLE search answers without a session; post search is a 403 on this host.
237
+ const result = await bsky(ctx, "app.bsky.actor.searchActors", { q, limit: 25 });
238
+ if (!result.ok) return json(result);
239
+ return json({ ok: true, people: (result.body.actors ?? []).map(asProfile) });
240
+ }
241
+
242
+ return new Response("not found", { status: 404 });
243
+ } catch (err) {
244
+ // Without this, the throw is folded into an opaque 404 and the app claims it does not exist.
245
+ ctx.log("error", "bluesky route failed", { path: url.pathname, message: String(err) });
246
+ return json({ ok: false, reason: "handler-failed", message: "Bluesky could not be read just now." }, 500);
247
+ }
248
+ }
249
+ ```
250
+
251
+ ## App.jsx sketch
252
+
253
+ A profile card, its follow graph, and the account's posts — one handle in, no
254
+ account of the visitor's involved.
255
+
256
+ ```jsx
257
+ import React, { useState } from "react";
258
+
259
+ export default function App() {
260
+ const [handle, setHandle] = useState("bsky.app");
261
+ const [profile, setProfile] = useState(null);
262
+ const [people, setPeople] = useState([]);
263
+ const [posts, setPosts] = useState([]);
264
+ const [problem, setProblem] = useState(null);
265
+
266
+ // Takes the handle as an argument, defaulting to what is in the input. A click
267
+ // in the follow list below passes the person's handle straight in: calling
268
+ // `load()` right after `setHandle(...)` would read the PREVIOUS handle, because
269
+ // the state update has not applied yet — an off-by-one that looks correct on
270
+ // the second click.
271
+ const load = async (who = handle) => {
272
+ setProblem(null);
273
+ const actor = encodeURIComponent(who.trim().replace(/^@/, ""));
274
+ const [p, f, w] = await Promise.all([
275
+ fetch(`/_api/profile?actor=${actor}`).then((r) => r.json()),
276
+ fetch(`/_api/follows?actor=${actor}`).then((r) => r.json()),
277
+ fetch(`/_api/feed?actor=${actor}`).then((r) => r.json()),
278
+ ]);
279
+ if (!p.ok) {
280
+ setProblem(p.reason === "NotFound" ? `We could not find @${who} on Bluesky.` : p.message);
281
+ return;
282
+ }
283
+ setProfile(p.profile);
284
+ setPeople(f.ok ? f.people : []);
285
+ setPosts(w.ok ? w.posts : []);
286
+ };
287
+
288
+ return (
289
+ <div>
290
+ <input value={handle} onChange={(e) => setHandle(e.target.value)} placeholder="alice.bsky.social" />
291
+ <button onClick={load}>Look up</button>
292
+ {problem && <p role="alert">{problem}</p>}
293
+
294
+ {profile && (
295
+ <section>
296
+ {profile.avatar && <img src={profile.avatar} alt="" width={64} height={64} />}
297
+ <h2>{profile.displayName}</h2>
298
+ <p>@{profile.handle}</p>
299
+ <p>{profile.description}</p>
300
+ <p>
301
+ {profile.followersCount} followers · {profile.followsCount} following · {profile.postsCount} posts
302
+ </p>
303
+ </section>
304
+ )}
305
+
306
+ <section>
307
+ <h3>Following</h3>
308
+ <ul>
309
+ {people.map((person) => (
310
+ <li key={person.did}>
311
+ <button
312
+ onClick={() => {
313
+ setHandle(person.handle);
314
+ load(person.handle);
315
+ }}
316
+ >
317
+ {person.displayName} (@{person.handle})
318
+ </button>
319
+ </li>
320
+ ))}
321
+ </ul>
322
+ </section>
323
+
324
+ <section>
325
+ <h3>Posts</h3>
326
+ {posts.map((post) => (
327
+ <article key={post.uri}>
328
+ <p>{post.text}</p>
329
+ <small>
330
+ {post.createdAt} · {post.likeCount} likes · {post.replyCount} replies
331
+ </small>
332
+ </article>
333
+ ))}
334
+ </section>
335
+ </div>
336
+ );
337
+ }
338
+ ```
339
+
340
+ Clicking a person in the follow list re-runs the same three routes against their
341
+ handle, which is the whole of a follower-graph explorer. A search page is the
342
+ same shape over `/_api/search`, and a multi-account wall is `/_api/feed` per
343
+ handle merged and sorted by `createdAt`.
package/llms/index.d.ts CHANGED
@@ -15,5 +15,7 @@ export { createVibeConfig } from "./create-vibe.js";
15
15
  export { accessConfig } from "./access.js";
16
16
  export { connectionsConfig } from "./connections.js";
17
17
  export { youtubeConfig } from "./youtube.js";
18
+ export { blueskyConfig } from "./bluesky.js";
19
+ export { spotifyConfig } from "./spotify.js";
18
20
  export type { LlmConfig } from "./types.js";
19
- export declare const allConfigs: readonly [import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig];
21
+ export declare const allConfigs: readonly [import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig, import("./types.js").LlmConfig];
package/llms/index.js CHANGED
@@ -15,6 +15,8 @@ import { createVibeConfig } from "./create-vibe.js";
15
15
  import { accessConfig } from "./access.js";
16
16
  import { connectionsConfig } from "./connections.js";
17
17
  import { youtubeConfig } from "./youtube.js";
18
+ import { blueskyConfig } from "./bluesky.js";
19
+ import { spotifyConfig } from "./spotify.js";
18
20
  export { backendConfig } from "./backend.js";
19
21
  export { calendarConfig } from "./calendar.js";
20
22
  export { callaiConfig } from "./callai.js";
@@ -32,6 +34,8 @@ export { createVibeConfig } from "./create-vibe.js";
32
34
  export { accessConfig } from "./access.js";
33
35
  export { connectionsConfig } from "./connections.js";
34
36
  export { youtubeConfig } from "./youtube.js";
37
+ export { blueskyConfig } from "./bluesky.js";
38
+ export { spotifyConfig } from "./spotify.js";
35
39
  export const allConfigs = [
36
40
  callaiConfig,
37
41
  imageGenConfig,
@@ -50,5 +54,7 @@ export const allConfigs = [
50
54
  accessConfig,
51
55
  connectionsConfig,
52
56
  youtubeConfig,
57
+ blueskyConfig,
58
+ spotifyConfig,
53
59
  ];
54
60
  //# sourceMappingURL=index.js.map
package/llms/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../jsr/llms/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAClD,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAE7C,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAClD,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAI7C,MAAM,CAAC,MAAM,UAAU,GAAG;IACxB,YAAY;IACZ,cAAc;IACd,cAAc;IACd,QAAQ;IACR,aAAa;IACb,QAAQ;IACR,WAAW;IACX,eAAe;IACf,WAAW;IACX,eAAe;IACf,aAAa;IACb,gBAAgB;IAChB,aAAa;IACb,cAAc;IACd,YAAY;IACZ,iBAAiB;IACjB,aAAa;CACL,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../jsr/llms/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAClD,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAE7C,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAClD,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAI7C,MAAM,CAAC,MAAM,UAAU,GAAG;IACxB,YAAY;IACZ,cAAc;IACd,cAAc;IACd,QAAQ;IACR,aAAa;IACb,QAAQ;IACR,WAAW;IACX,eAAe;IACf,WAAW;IACX,eAAe;IACf,aAAa;IACb,gBAAgB;IAChB,aAAa;IACb,cAAc;IACd,YAAY;IACZ,iBAAiB;IACjB,aAAa;IACb,aAAa;IACb,aAAa;CACL,CAAC"}
@@ -0,0 +1,2 @@
1
+ import type { LlmConfig } from "./types.js";
2
+ export declare const spotifyConfig: LlmConfig;
@@ -0,0 +1,15 @@
1
+ export const spotifyConfig = {
2
+ name: "spotify",
3
+ label: "Spotify catalog",
4
+ description: "Reading Spotify's public catalog with the owner's own Spotify app credentials: the setup steps the app shows when SPOTIFY_CLIENT_ID/SPOTIFY_CLIENT_SECRET are missing, the client-credentials token minted in backend.js and cached in memory rather than stored, searching artists/albums/tracks, the boundary that a listener's own top tracks and playlists need a connection instead, and keeping every refusal's reason",
5
+ cues: [
6
+ "spotify",
7
+ "spotify search",
8
+ "album art",
9
+ "track search",
10
+ "artist lookup",
11
+ "music catalog",
12
+ "audio features",
13
+ ],
14
+ };
15
+ //# sourceMappingURL=spotify.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spotify.js","sourceRoot":"","sources":["../../jsr/llms/spotify.ts"],"names":[],"mappings":"AAKA,MAAM,CAAC,MAAM,aAAa,GAAc;IACtC,IAAI,EAAE,SAAS;IACf,KAAK,EAAE,iBAAiB;IACxB,WAAW,EACT,8ZAA8Z;IACha,IAAI,EAAE;QACJ,SAAS;QACT,gBAAgB;QAChB,WAAW;QACX,cAAc;QACd,eAAe;QACf,eAAe;QACf,gBAAgB;KACjB;CACF,CAAC"}
@@ -0,0 +1,376 @@
1
+ # Spotify catalog — with the owner's own Spotify app
2
+
3
+ When someone asks for a page built on Spotify's catalog — search an artist, show
4
+ an album's tracks, chart the tempo of a playlist's songs — build it on the **Spotify
5
+ Web API** with app credentials the owner supplies. This is a `backend.js` feature
6
+ (see the backend docs for the general handler contract); this page is the recipe
7
+ that makes the credentials safe, the token short-lived and unstored, and the app
8
+ honest about which half of Spotify it can actually see.
9
+
10
+ **This is the owner-dashboard pattern, and it has a boundary.** The credentials
11
+ are the owner's, the rate limit is the owner's, and every visitor sees the *same*
12
+ public catalog. That is exactly right for "let people search Spotify from my page"
13
+ and exactly wrong for "show each visitor their own top tracks". An app where each
14
+ person who opens it sees **their own** Spotify account is a **connection** — the
15
+ platform renders the consent screen, runs the sign-in, holds the credential and
16
+ refreshes it, and your code never sees a token (see the connections page). So
17
+ never render an input asking a visitor to paste an access token, and never send a
18
+ visitor to a developer console to mint one: that is the flow connections exists to
19
+ replace. The console trip below is for the **owner** of this app, once, and for
20
+ nobody else.
21
+
22
+ ## 1. Bring your own app credentials, and explain how
23
+
24
+ The owner registers an app with Spotify, once:
25
+
26
+ 1. Open the Spotify developer dashboard and log in with a Spotify account.
27
+ 2. Create an app — any name and description; a redirect URI is not used by this
28
+ flow, so put the app's own URL in if the form insists on one.
29
+ 3. Open the app's settings and copy the **Client ID**.
30
+ 4. Reveal and copy the **Client secret**.
31
+ 5. Set both on the vibe as `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET` — the
32
+ vibe's settings page, or `vibes-diy secrets set SPOTIFY_CLIENT_ID <the id>` and
33
+ `vibes-diy secrets set SPOTIFY_CLIENT_SECRET <the secret>`.
34
+
35
+ The app carries those five steps in its own UI, because the person reading them is
36
+ the person who has to do them.
37
+
38
+ When the credentials are missing, the app shows the owner the setup steps for a Spotify app instead of an error.
39
+
40
+ The backend answers `{ ok: false, reason: "missing-key" }` when either
41
+ `ctx.secrets.SPOTIFY_CLIENT_ID` or `ctx.secrets.SPOTIFY_CLIENT_SECRET` is absent,
42
+ and the client renders the numbered steps, naming the Spotify developer dashboard
43
+ and both secret names in so many words so the owner knows what to create and what
44
+ to call it. Two secrets means two ways to be half-configured: say "missing-key"
45
+ once and list both, rather than letting one set and one unset look like a broken
46
+ API.
47
+
48
+ Only the owner can set or rotate a secret, and handlers see the current value on
49
+ every invocation — so the page starts working the moment the pair lands, with no
50
+ redeploy.
51
+
52
+ ## 2. The credentials never reach the browser
53
+
54
+ Every Spotify request is made in backend.js with ctx.fetch and the two ctx.secrets values; App.jsx never calls api.spotify.com or accounts.spotify.com.
55
+
56
+ The client talks to the app's own `/_api/...` routes with a relative `fetch`, and
57
+ the backend is the only place that knows the client id and secret exist. A client
58
+ secret pasted into a React component ships to every visitor who views source, and
59
+ anyone who reads it can mint tokens against the owner's app until it is rotated.
60
+ The client id alone is no better: it is half of the pair and it names the owner's
61
+ app to anyone collecting them.
62
+
63
+ ## 3. Mint a token, hold it in memory, never write it down
64
+
65
+ The public catalog is not open. `GET https://api.spotify.com/v1/search` with no
66
+ `Authorization` header answers **401** `Missing/invalid/expired access token` —
67
+ there is no anonymous catalog access, so every app on this page mints a token
68
+ before its first call.
69
+
70
+ This is the **client-credentials** flow: server to server, with **no redirect, no
71
+ user login and no callback route**. Do not add one; there is nothing for a visitor
72
+ to approve.
73
+
74
+ ```
75
+ POST https://accounts.spotify.com/api/token
76
+ Content-Type: application/x-www-form-urlencoded
77
+
78
+ grant_type=client_credentials&client_id=<SPOTIFY_CLIENT_ID>&client_secret=<SPOTIFY_CLIENT_SECRET>
79
+ ```
80
+
81
+ Spotify documents two ways to present the pair: in the form body as above, or as
82
+ HTTP Basic auth over `client_id:client_secret`. **Use the form body.** It is one
83
+ less thing to base64-encode and it keeps the request shape plain; both are
84
+ accepted by the same endpoint.
85
+
86
+ The answer is `{ access_token, token_type: "Bearer", expires_in }`, and
87
+ `expires_in` is seconds — about an hour. Every catalog call then carries
88
+ `Authorization: Bearer <access_token>`.
89
+
90
+ The token is cached in a module-scope variable in backend.js and re-minted when it expires or when a call answers 401 — it is never written to a document.
91
+
92
+ A token in a document is a credential at rest with a readable lifetime and no
93
+ reason to exist: it buys nothing a fresh mint does not, it outlives the isolate
94
+ that needed it, and anything that can read the app's data can then spend the
95
+ owner's app. Hold it beside the handler, treat a cold isolate as a cache miss, and
96
+ mint again. Re-mint slightly early — a token that expires mid-request looks like a
97
+ broken API — and re-mint once on a 401 rather than surfacing it, since a revoked or
98
+ rotated secret and an expired token arrive as the same status.
99
+
100
+ ## 4. What client credentials buys, and what it does not
101
+
102
+ Client credentials buys the **public catalog** and nothing that belongs to a listener.
103
+
104
+ | Available | Not available with client credentials |
105
+ | --- | --- |
106
+ | `search` across tracks, artists, albums, playlists | "my top tracks", "my recently played" |
107
+ | an artist, their albums, their top tracks | the visitor's saved songs or library |
108
+ | an album and its tracks | the visitor's own playlists |
109
+ | a track and its audio features | playback, queue, or anything that controls a player |
110
+ | public playlists and their tracks | private playlists, follows, or listening history |
111
+
112
+ Everything in the right column needs the visitor's own Spotify account, which is a
113
+ **connection** and not this flow. Do not imply the app can reach it, do not add an
114
+ input that asks for a token to unlock it, and do not render an empty "Your top
115
+ tracks" section waiting for a capability that will never arrive. If the person
116
+ asked for their own listening data, say plainly that this page reads Spotify's
117
+ public catalog and point at the connections page.
118
+
119
+ ## 5. Reading the catalog
120
+
121
+ ```
122
+ GET https://api.spotify.com/v1/search?q=<terms>&type=track,artist&limit=20
123
+ GET https://api.spotify.com/v1/artists/<id>/top-tracks?market=US
124
+ GET https://api.spotify.com/v1/albums/<id>
125
+ GET https://api.spotify.com/v1/tracks?ids=<up to 50 comma-joined ids>
126
+ ```
127
+
128
+ Each answers JSON with a paging object — `items`, `total`, `limit`, `offset` — so
129
+ page with `offset` rather than asking for a bigger `limit` than the endpoint allows
130
+ (50 for most, 20 for search per type). Several endpoints want a `market`; without
131
+ one an item can come back playable nowhere and render as a blank row.
132
+
133
+ An empty `items` array means nothing matched — say that, and keep the search terms
134
+ on screen so the person can fix them. Images arrive as a `images` array ordered
135
+ largest first; take the smallest one that is big enough rather than the first.
136
+
137
+ ## 6. Errors keep their reason
138
+
139
+ Spotify answers a refusal with `{ "error": { "status", "message" } }` on the Web
140
+ API, and `{ "error", "error_description" }` on the token endpoint. Classify and
141
+ pass the reason through:
142
+
143
+ - `invalid_client` from the token endpoint — the id and secret do not match a real
144
+ app. Show the setup steps from section 1 again.
145
+ - `401` from the Web API — mint a fresh token and retry once; if the second try is
146
+ also 401, show the setup steps.
147
+ - `429` — Spotify is rate-limiting the owner's app. The `Retry-After` header is
148
+ seconds; say "Spotify asked us to wait N seconds" rather than "no results".
149
+ - `404` — no such artist, album or track. Keep the id on screen.
150
+ - anything else — show the API's own `error.message`.
151
+
152
+ A failure keeps its reason all the way to the screen. "No data" tells the owner
153
+ nothing they can act on, and the five cases above each have a different fix.
154
+
155
+ Every `_api` route wraps its handler in `try`/`catch` and answers with a stated
156
+ reason, because an uncaught throw leaves the route rendering as an opaque 404 and
157
+ the app looks broken rather than misconfigured.
158
+
159
+ ## Complete backend.js
160
+
161
+ ```js
162
+ const TOKEN_URL = "https://accounts.spotify.com/api/token";
163
+ const API = "https://api.spotify.com/v1";
164
+
165
+ /**
166
+ * The token lives HERE — beside the handler, for as long as this isolate does.
167
+ * Never in a document: a stored bearer outlives the request that needed it and
168
+ * buys nothing a fresh mint does not.
169
+ */
170
+ let cachedToken = null;
171
+ let cachedUntil = 0;
172
+
173
+ function json(payload, status = 200) {
174
+ return new Response(JSON.stringify(payload), {
175
+ status,
176
+ headers: { "content-type": "application/json" },
177
+ });
178
+ }
179
+
180
+ /** Both halves or neither — one set and one unset is still "not configured yet". */
181
+ function credentials(ctx) {
182
+ const id = ctx.secrets.SPOTIFY_CLIENT_ID;
183
+ const secret = ctx.secrets.SPOTIFY_CLIENT_SECRET;
184
+ return id && secret ? { id, secret } : null;
185
+ }
186
+
187
+ /** Client credentials: no redirect, no user login, no callback route. */
188
+ async function mintToken(ctx, creds) {
189
+ const body = new URLSearchParams({
190
+ grant_type: "client_credentials",
191
+ client_id: creds.id,
192
+ client_secret: creds.secret,
193
+ });
194
+ const res = await ctx.fetch(TOKEN_URL, {
195
+ method: "POST",
196
+ headers: { "content-type": "application/x-www-form-urlencoded" },
197
+ body: body.toString(),
198
+ });
199
+ const payload = await res.json().catch(() => null);
200
+ if (res.status !== 200 || !payload?.access_token) {
201
+ return { ok: false, reason: payload?.error ?? "token-failed", message: payload?.error_description ?? `HTTP ${res.status}` };
202
+ }
203
+ // Re-mint a minute early: a token that expires mid-request reads as a broken API.
204
+ cachedToken = payload.access_token;
205
+ cachedUntil = Date.now() + Math.max(0, Number(payload.expires_in ?? 3600) - 60) * 1000;
206
+ return { ok: true, token: cachedToken };
207
+ }
208
+
209
+ async function token(ctx, creds, { fresh = false } = {}) {
210
+ if (!fresh && cachedToken && Date.now() < cachedUntil) return { ok: true, token: cachedToken };
211
+ cachedToken = null;
212
+ return mintToken(ctx, creds);
213
+ }
214
+
215
+ /** One call, with exactly one retry when the token turned out to be stale. */
216
+ async function callSpotify(ctx, path, params = {}) {
217
+ const creds = credentials(ctx);
218
+ if (!creds) return { ok: false, reason: "missing-key" };
219
+
220
+ const url = new URL(`${API}${path}`);
221
+ for (const [k, v] of Object.entries(params)) if (v != null) url.searchParams.set(k, String(v));
222
+
223
+ for (const fresh of [false, true]) {
224
+ const minted = await token(ctx, creds, { fresh });
225
+ if (!minted.ok) return minted;
226
+ const res = await ctx.fetch(url.toString(), {
227
+ headers: { authorization: `Bearer ${minted.token}`, accept: "application/json" },
228
+ });
229
+ if (res.status === 401 && fresh === false) continue; // expired or rotated — mint once more
230
+ const payload = await res.json().catch(() => null);
231
+ if (res.status === 200 && payload) return { ok: true, body: payload };
232
+ if (res.status === 429) {
233
+ return { ok: false, reason: "rate-limited", retryAfter: Number(res.headers.get("retry-after") ?? 1) };
234
+ }
235
+ return { ok: false, reason: String(payload?.error?.status ?? res.status), message: payload?.error?.message ?? `HTTP ${res.status}` };
236
+ }
237
+ return { ok: false, reason: "unauthorized", message: "Spotify refused the app credentials twice." };
238
+ }
239
+
240
+ export async function fetch(request, ctx) {
241
+ const url = new URL(request.url);
242
+ if (request.method !== "GET") {
243
+ return new Response("method not allowed", { status: 405, headers: { allow: "GET" } });
244
+ }
245
+
246
+ // An uncaught throw renders as an opaque 404, so every route answers with a reason.
247
+ try {
248
+ if (url.pathname === "/search") {
249
+ const q = (url.searchParams.get("q") ?? "").trim();
250
+ if (q === "") return json({ ok: true, tracks: [], artists: [] });
251
+ const result = await callSpotify(ctx, "/search", { q, type: "track,artist", limit: 20, market: "US" });
252
+ if (!result.ok) return json(result);
253
+ return json({
254
+ ok: true,
255
+ tracks: (result.body.tracks?.items ?? []).map(trackRow),
256
+ artists: (result.body.artists?.items ?? []).map(artistRow),
257
+ });
258
+ }
259
+
260
+ if (url.pathname === "/artist") {
261
+ const id = url.searchParams.get("id");
262
+ if (!id) return json({ ok: false, reason: "no-artist", message: "Pick an artist first." });
263
+ const top = await callSpotify(ctx, `/artists/${encodeURIComponent(id)}/top-tracks`, { market: "US" });
264
+ if (!top.ok) return json(top);
265
+ return json({ ok: true, tracks: (top.body.tracks ?? []).map(trackRow) });
266
+ }
267
+
268
+ return new Response("not found", { status: 404 });
269
+ } catch (err) {
270
+ ctx.log("error", "spotify route failed", { path: url.pathname, message: String(err?.message ?? err) });
271
+ return json({ ok: false, reason: "handler-failed", message: "Something went wrong reading Spotify." }, 500);
272
+ }
273
+ }
274
+
275
+ function smallestImage(images) {
276
+ const sorted = [...(images ?? [])].sort((a, b) => (a.width ?? 0) - (b.width ?? 0));
277
+ return sorted.find((i) => (i.width ?? 0) >= 160)?.url ?? sorted.at(-1)?.url ?? null;
278
+ }
279
+
280
+ function trackRow(t) {
281
+ return {
282
+ id: t.id,
283
+ name: t.name,
284
+ artists: (t.artists ?? []).map((a) => a.name).join(", "),
285
+ album: t.album?.name ?? null,
286
+ image: smallestImage(t.album?.images),
287
+ url: t.external_urls?.spotify ?? null,
288
+ };
289
+ }
290
+
291
+ function artistRow(a) {
292
+ return {
293
+ id: a.id,
294
+ name: a.name,
295
+ genres: a.genres ?? [],
296
+ image: smallestImage(a.images),
297
+ url: a.external_urls?.spotify ?? null,
298
+ };
299
+ }
300
+ ```
301
+
302
+ ## App.jsx sketch
303
+
304
+ ```jsx
305
+ import React, { useState } from "react";
306
+
307
+ const SETUP_STEPS = [
308
+ "Open the Spotify developer dashboard and log in.",
309
+ "Create an app — any name and description will do.",
310
+ "Copy the Client ID from the app's settings.",
311
+ "Reveal and copy the Client secret.",
312
+ "Set them on this vibe as SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET.",
313
+ ];
314
+
315
+ export default function App() {
316
+ const [query, setQuery] = useState("");
317
+ const [tracks, setTracks] = useState([]);
318
+ const [problem, setProblem] = useState(null);
319
+
320
+ const search = async () => {
321
+ const res = await fetch(`/_api/search?q=${encodeURIComponent(query)}`);
322
+ const data = await res.json();
323
+ if (!data.ok) {
324
+ setProblem(data);
325
+ return;
326
+ }
327
+ setProblem(null);
328
+ setTracks(data.tracks);
329
+ };
330
+
331
+ return (
332
+ <div>
333
+ <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search Spotify" />
334
+ <button onClick={search}>Search</button>
335
+
336
+ {problem?.reason === "missing-key" && (
337
+ <section>
338
+ <h2>Add your Spotify app credentials</h2>
339
+ <ol>
340
+ {SETUP_STEPS.map((step) => (
341
+ <li key={step}>{step}</li>
342
+ ))}
343
+ </ol>
344
+ <p>Both values stay on the server and are never sent to the browser.</p>
345
+ </section>
346
+ )}
347
+ {problem?.reason === "rate-limited" && <p>Spotify asked us to wait {problem.retryAfter} seconds.</p>}
348
+ {problem && !["missing-key", "rate-limited"].includes(problem.reason) && <p role="alert">{problem.message}</p>}
349
+
350
+ <ul>
351
+ {tracks.map((t) => (
352
+ <li key={t.id}>
353
+ {t.image && <img src={t.image} alt="" width={64} height={64} />}
354
+ <a href={t.url} target="_blank" rel="noreferrer">
355
+ {t.name}
356
+ </a>{" "}
357
+ — {t.artists}
358
+ </li>
359
+ ))}
360
+ </ul>
361
+
362
+ <section>
363
+ <h3>What this page can see</h3>
364
+ <p>
365
+ Spotify's public catalog — search, artists, albums and tracks. Your own top tracks, playlists and saved songs
366
+ belong to your Spotify account and are not part of this.
367
+ </p>
368
+ </section>
369
+ </div>
370
+ );
371
+ }
372
+ ```
373
+
374
+ Store what the person keeps — a shortlist, a rating, a note on a track — in the
375
+ app's own database keyed by the Spotify track id. The catalog is Spotify's and
376
+ gets re-read; the opinions about it are the app's and get saved.
package/llms/youtube.md CHANGED
@@ -6,6 +6,18 @@ with a key the owner supplies. This is a `backend.js` feature (see the backend
6
6
  docs for the general handler contract); this page is the recipe that makes the
7
7
  key safe, the quota survivable, and the numbers honest about what they are.
8
8
 
9
+ **This is the owner-dashboard pattern, and it has a boundary.** The key is the
10
+ owner's, the quota is the owner's, and every visitor sees the *owner's* channel.
11
+ That is exactly right for "show me how my channel is doing" and exactly wrong for
12
+ the same sentence asked by a visitor about theirs. An app where each person who
13
+ opens it sees **their own** account is a **connection** — the platform renders the
14
+ consent screen, runs the sign-in, holds the credential and refreshes it, and your
15
+ code never sees a token (see the connections page). So never render an input
16
+ asking a visitor to paste an access token, and never send a visitor to a
17
+ developer console to mint one: that is the flow connections exists to replace.
18
+ The console trip below is for the **owner** of this app, once, and for nobody
19
+ else.
20
+
9
21
  ## 1. Bring your own key, and explain how
10
22
 
11
23
  The owner gets a key from Google, once:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibes.diy/prompts",
3
- "version": "14.1.25",
3
+ "version": "14.1.26",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "exports": {
@@ -34,9 +34,9 @@
34
34
  "license": "Apache-2.0",
35
35
  "dependencies": {
36
36
  "@adviser/cement": "~0.5.34",
37
- "@vibes.diy/call-ai-v2": "14.1.25",
38
- "@vibes.diy/identity": "14.1.25",
39
- "@vibes.diy/use-vibes-types": "14.1.25",
37
+ "@vibes.diy/call-ai-v2": "14.1.26",
38
+ "@vibes.diy/identity": "14.1.26",
39
+ "@vibes.diy/use-vibes-types": "14.1.26",
40
40
  "arktype": "~2.2.3",
41
41
  "json-schema-faker": "~0.6.3"
42
42
  },
package/system-prompt.md CHANGED
@@ -708,7 +708,7 @@ Rules for the items:
708
708
  - **Seed every app-written document type.** For every string-literal `type` that `App.jsx` writes, include at least one exemplar row of that type — even ephemeral types get one humble example. Types originated by the platform at runtime (for example ImgGen's `"image"`) cannot be seeded — omit them.
709
709
  - **JSON only — no images or binary.** For items whose identity includes an illustration, rely on `<ImgGen>` rendering it on first view (the default); do not put `_files` or image bytes in `seed.json` — put binaries in the Files tab instead.
710
710
  - **Platform-originated types can't be seeded.** Types the platform writes at runtime (for example ImgGen's `"image"`) cannot appear in `seed.json`; give those their own `access.js` branch instead.
711
- - **If the app has an `access.js`, every `type` you emit here must have a branch in it** — but don't add an access function _just_ to satisfy this: an app with no per-document rules keeps the default open data model and seeds fine without one. When there **is** an `access.js`, seed docs are written through it as the **owner** when the owner explicitly applies the seed-data import, so a `type` it doesn't return a **readable descriptor** for (a non-empty `channels`, or an `audience`) is denied (`unknown document type`) — the doc never seeds, and the same gap later surfaces as a hard error the moment the running app writes that type. Before finishing, if you emitted an `access.js`, confirm it returns a readable descriptor for every distinct `type` present in `seed.json`. (Deletes go through the same gate: a `db.del` writes a tombstone `{ _id, _deleted: true }` that carries **no** `type`, channel, or author — so branch on `doc._deleted`, then authorize and route it off **`oldDoc`** (the persisted document is the only trustworthy record of the doc's type and owner), returning the same descriptor the live doc got. A bare `_deleted` branch that ignores `oldDoc` either fails the app's own deletes or over-broadens them.)
711
+ - **`access.js` decides which types a seed may carry — not the other way round.** Don't add an access function just to satisfy a seed: an app with no per-document rules keeps the default open data model and seeds fine without one. When there **is** an `access.js`, seed docs are written through it as the **owner** when the owner explicitly applies the seed-data import, so a `type` it doesn't return a **readable descriptor** for (a non-empty `channels`, or an `audience`) is denied (`unknown document type`) — the doc never seeds, and the same gap later surfaces as a hard error the moment the running app writes that type. So when you write or change an `access.js`, bring `seed.json` into line with it in the same turn: drop items whose type it doesn't authorize, add the field each branch routes on, and never add a branch just to admit a seed item. The full rule, including which ids must stay byte-identical, is in the `access.js` skill under _Reconciling `seed.json` when you write or change `access.js`_. (Deletes go through the same gate: a `db.del` writes a tombstone `{ _id, _deleted: true }` that carries **no** `type`, channel, or author — so branch on `doc._deleted`, then authorize and route it off **`oldDoc`** (the persisted document is the only trustworthy record of the doc's type and owner), returning the same descriptor the live doc got. A bare `_deleted` branch that ignores `oldDoc` either fails the app's own deletes or over-broadens them.)
712
712
 
713
713
  ### Make it fun and alive on screen one
714
714