@vibes.diy/prompts 14.1.25 → 14.1.27
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 +14 -0
- package/llms/bluesky.d.ts +2 -0
- package/llms/bluesky.js +15 -0
- package/llms/bluesky.js.map +1 -0
- package/llms/bluesky.md +343 -0
- package/llms/index.d.ts +3 -1
- package/llms/index.js +6 -0
- package/llms/index.js.map +1 -1
- package/llms/spotify.d.ts +2 -0
- package/llms/spotify.js +15 -0
- package/llms/spotify.js.map +1 -0
- package/llms/spotify.md +376 -0
- package/llms/youtube.md +12 -0
- package/package.json +4 -4
- package/system-prompt.md +1 -1
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.**
|
package/llms/bluesky.js
ADDED
|
@@ -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"}
|
package/llms/bluesky.md
ADDED
|
@@ -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"}
|
package/llms/spotify.js
ADDED
|
@@ -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"}
|
package/llms/spotify.md
ADDED
|
@@ -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.
|
|
3
|
+
"version": "14.1.27",
|
|
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.
|
|
38
|
-
"@vibes.diy/identity": "14.1.
|
|
39
|
-
"@vibes.diy/use-vibes-types": "14.1.
|
|
37
|
+
"@vibes.diy/call-ai-v2": "14.1.27",
|
|
38
|
+
"@vibes.diy/identity": "14.1.27",
|
|
39
|
+
"@vibes.diy/use-vibes-types": "14.1.27",
|
|
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
|
-
-
|
|
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
|
|