@pikku/skills 0.12.25 → 0.12.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.
@@ -0,0 +1,163 @@
1
+ ---
2
+ name: pikku-list-query
3
+ description: >-
4
+ Use when building a paginated/infinite-scroll list — any RPC that returns rows a user scrolls through (tables, card grids, search results). Covers pikkuListFunc, the ListInput/ListOutput cursor contract, and the generated usePikkuInfiniteQuery hook.
5
+ TRIGGER when: user asks for infinite scroll, "load more", a paginated table/list/grid, or a list that could grow beyond a single page.
6
+ DO NOT TRIGGER when: the list is small and fixed (e.g. a settings page with 5 items) — a plain pikkuFunc + usePikkuQuery returning a full array is simpler and correct there.
7
+ installGroups: [core, client]
8
+ ---
9
+
10
+ # Pikku List Queries
11
+
12
+ ## Agent Operating Procedure
13
+
14
+ 1. Capture baseline. Run `pikku all` BEFORE writing code; only NEW errors are yours to fix.
15
+ 2. Write the backend function with `pikkuListFunc` (below) — never a bespoke `{items: [...]}` shape once the list can page.
16
+ 3. Run `pikku all` to regenerate `usePikkuInfiniteQuery` for the new function.
17
+ 4. Wire the frontend with `usePikkuInfiniteQuery`, not a hand-rolled `useState` page counter and not a raw `useInfiniteQuery` — the generated hook already resolves cursor plumbing from your function's types.
18
+ 5. Validate with `pikku all`.
19
+
20
+ ## The `pikkuListFunc` contract
21
+
22
+ A list function is a normal `pikkuFunc`/`pikkuSessionlessFunc` whose input/output conform to two shared shapes from `@pikku/core`:
23
+
24
+ ```typescript
25
+ interface ListInput<F extends Record<string, unknown> = {}, S extends string = never> {
26
+ cursor?: string // opaque — echo back whatever you returned as nextCursor
27
+ limit?: number // page size; server may cap it
28
+ sort?: Array<{ column: S; direction: 'asc' | 'desc' }>
29
+ filter?: Filter<F> // structured AND/OR tree, Prisma-style leaf operators
30
+ search?: string // free-text search across server-chosen fields
31
+ }
32
+
33
+ interface ListOutput<Row> {
34
+ rows: Row[]
35
+ nextCursor: string | null // null = no more pages
36
+ totalCount?: number // optional — skip when expensive to compute
37
+ }
38
+ ```
39
+
40
+ Adopting this shape is what makes the function eligible for the generated `usePikkuInfiniteQuery` hook — the react-query codegen structurally detects any RPC whose output includes `nextCursor` and generates an infinite-query hook for it automatically. No manual wiring, no opt-in flag.
41
+
42
+ ```typescript
43
+ import { pikkuListFunc } from '#pikku/function'
44
+
45
+ interface Item {
46
+ id: string
47
+ label: string
48
+ }
49
+
50
+ export const listItems = pikkuListFunc<{ status?: string }, Item>({
51
+ expose: true,
52
+ auth: true,
53
+ readonly: true,
54
+ description: 'List items for the signed-in user, paginated.',
55
+ // `input` is inferred as ListInput<{ status?: string }> from the generics above —
56
+ // never re-annotate it inline.
57
+ func: async ({ kysely }, input, { session }) => {
58
+ // `limit` is caller-supplied on an exposed RPC, so it is CAPPED, not trusted —
59
+ // `ListInput` says "server may cap" and this is where that happens.
60
+ const limit = Math.min(Math.max(Math.trunc(input.limit ?? 20) || 20, 1), 100)
61
+ const parsed = input.cursor ? Number(input.cursor) : 0
62
+ const offset = Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : 0
63
+
64
+ let query = kysely.selectFrom('item').where('userId', '=', session!.userId)
65
+ const status = leafEquals(input.filter, 'status')
66
+ if (status !== undefined) {
67
+ query = query.where('status', '=', status)
68
+ }
69
+
70
+ const rows = await query.orderBy('createdAt', 'desc').offset(offset).limit(limit).execute()
71
+ const nextOffset = offset + rows.length
72
+ const totalCount = await query
73
+ .select((eb) => eb.fn.countAll<number>().as('count'))
74
+ .executeTakeFirstOrThrow()
75
+
76
+ return {
77
+ rows: rows.map((r) => ({ id: r.id, label: r.label })),
78
+ nextCursor: nextOffset < totalCount.count ? String(nextOffset) : null,
79
+ totalCount: totalCount.count,
80
+ }
81
+ },
82
+ })
83
+ ```
84
+
85
+ Cursor doesn't have to be a numeric offset — any opaque string works (a keyset value, an encoded timestamp, etc.), as long as you can turn it back into a query position on the next call.
86
+
87
+ ## Frontend: `usePikkuInfiniteQuery`
88
+
89
+ Generated automatically alongside `usePikkuQuery`/`usePikkuMutation` once `reactQueryFile` is configured (see the react-query wiring docs) — no separate setup for list functions specifically.
90
+
91
+ ```tsx
92
+ import { usePikkuInfiniteQuery } from '.pikku/pikku-react-query.gen'
93
+
94
+ function ItemList() {
95
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = usePikkuInfiniteQuery(
96
+ 'listItems',
97
+ { limit: 20 }, // never pass cursor here — the hook manages it
98
+ )
99
+
100
+ const rows = data?.pages.flatMap((page) => page.rows) ?? []
101
+
102
+ return (
103
+ <>
104
+ {rows.map((row) => (
105
+ <div key={row.id}>{row.label}</div>
106
+ ))}
107
+ {hasNextPage && (
108
+ <button disabled={isFetchingNextPage} onClick={() => fetchNextPage()}>
109
+ Load more
110
+ </button>
111
+ )}
112
+ </>
113
+ )
114
+ }
115
+ ```
116
+
117
+ For scroll-triggered loading (rather than a button), pair it with an `IntersectionObserver` sentinel at the end of the list that calls `fetchNextPage()` when it enters the viewport and `hasNextPage` is true — don't poll on a scroll event handler.
118
+
119
+ ## Common mistakes
120
+
121
+ - **Bespoke output shape** (`{items, total}` with no `nextCursor`) — compiles, but disqualifies the function from `usePikkuInfiniteQuery`; you're left hand-rolling pagination state. Use `ListOutput<Row>`'s field names (`rows`, `nextCursor`) even if you don't need `filter`/`sort`/`search` yet — they're optional.
122
+ - **Fixed large `limit` instead of real pagination** (e.g. `{ limit: 500 }` fetched once) — works until the collection outgrows the cap, then silently truncates. If a list can grow unbounded, page it from the start.
123
+ - **Passing `cursor` manually into `usePikkuInfiniteQuery`'s input argument** — the hook injects it into each page request itself; the input you pass is the _base_ filter/limit shared by every page.
124
+
125
+ ## `filter` is a TREE, not a bag of fields
126
+
127
+ `Filter<F>` is recursive: an **array** is an AND of its children, a **multi-key object** is
128
+ an OR keyed by labels that mean nothing at evaluation time, and only a **single-key object**
129
+ is a leaf. A leaf's value is either the value itself or an operator object
130
+ (`{ contains, in, gt, gte, lt, lte, not, startsWith, … }`).
131
+
132
+ So `'status' in input.filter` answers `false` for `[{ status: 'open' }, { userId: 'u1' }]`
133
+ and for `{ status: { in: ['open', 'held'] } }` — the first because the filter is an array,
134
+ the second because the value is an operator object rather than the string the code then
135
+ compares. Both cases **silently return unfiltered rows**, which on a list endpoint means
136
+ handing back records the caller asked to exclude. Pikku ships no filter-to-SQL helper: the
137
+ backend decides what it accepts, and it has to say so.
138
+
139
+ Read exactly the shape you support, and refuse the rest rather than ignoring it:
140
+
141
+ ```typescript
142
+ import type { Filter } from '@pikku/core/function'
143
+
144
+ /** The one shape this endpoint accepts: a single-key leaf with a plain value. */
145
+ function leafEquals<F extends Record<string, unknown>, K extends keyof F & string>(
146
+ filter: Filter<F> | undefined,
147
+ field: K,
148
+ ): F[K] | undefined {
149
+ if (!filter || Array.isArray(filter)) return undefined
150
+ const keys = Object.keys(filter)
151
+ if (keys.length !== 1 || keys[0] !== field) return undefined
152
+ const value = (filter as Record<string, unknown>)[field]
153
+ if (value !== null && typeof value === 'object') {
154
+ throw new Error(`filter.${field} takes a value, not an operator object`)
155
+ }
156
+ return value as F[K]
157
+ }
158
+ ```
159
+
160
+ Supporting AND/OR or operators means walking the tree properly — recurse into the array and
161
+ the multi-key object, and map each leaf operator to its Kysely equivalent. Do that when the
162
+ UI needs it; until then, throwing on the shapes you do not handle is what stops a filter
163
+ from being quietly dropped.
@@ -0,0 +1,126 @@
1
+ ---
2
+ name: pikku-permissions
3
+ description: >-
4
+ Use when deciding WHO may call a function — resource ownership, role gates, admin-only actions, or any "only their own rows" rule. Covers the `permissions` field, `pikkuPermission`, `pikkuAuth`, scopes, and where ownership belongs versus where it does not.
5
+ TRIGGER when: writing or reviewing any function that touches a row a user owns, gating an action on a role, building the permissions half of a contract in build PHASE 2, or about to write an `if` in a function body that decides whether the caller is allowed.
6
+ DO NOT TRIGGER when: the question is how to sign someone in or seed a persona (that is pikku-auth), or how to shape a paginated list (that is pikku-list-query).
7
+ installGroups: [core]
8
+ ---
9
+
10
+ # Pikku Permissions
11
+
12
+ ## The rule
13
+
14
+ **Authorization goes in the `permissions` field. Never in the `func` body.**
15
+
16
+ `permissions` runs before `func`, and it is DECLARED — `pikku meta` and the auditor can
17
+ see it. An `if` in the body is the same check, invisible: nothing can tell you which
18
+ functions are gated or how, and the next person to add a caller gets no warning.
19
+
20
+ ```typescript
21
+ // RIGHT
22
+ export const deleteBook = pikkuFunc({
23
+ permissions: { owner: isBookOwner },
24
+ func: async ({ kysely }, { bookId }) => {
25
+ await kysely.deleteFrom('book').where('bookId', '=', bookId).execute()
26
+ },
27
+ })
28
+
29
+ // WRONG — the gate is buried in the body
30
+ export const deleteBook = pikkuFunc({
31
+ func: async ({ kysely }, { bookId }, { session }) => {
32
+ const book = await kysely.selectFrom('book')...executeTakeFirst()
33
+ if (book?.ownerId !== session.userId) throw new UnauthorizedError()
34
+ await kysely.deleteFrom('book').where('bookId', '=', bookId).execute()
35
+ },
36
+ })
37
+ ```
38
+
39
+ ## `auth: true` IS NOT OWNERSHIP — this is the one people get wrong
40
+
41
+ `auth: true` means "somebody is signed in". It does NOT mean "this row is theirs". A CRUD
42
+ function set to `auth: true` and nothing else lets ANY signed-in user delete ANY other
43
+ user's row by passing its id. Every function that takes a row id needs BOTH: `auth: true`
44
+ for the session, and a `permissions` entry for the ownership.
45
+
46
+ Equally: do NOT write an `isSignedIn` permission that returns `!!session`. That re-checks
47
+ authentication, which `auth: true` already did. A permission answers *may this user do
48
+ this* — role, ownership, tier — never *is there a session*.
49
+
50
+ ## Single row vs list — where ownership actually goes
51
+
52
+ This is the distinction to get right, and both halves are correct code:
53
+
54
+ - **A function taking a row id** (`get`, `update`, `delete`) — ownership is a
55
+ PERMISSION. Load the row, compare the owner to the session. It is a yes/no question
56
+ about one row, which is exactly what a permission is.
57
+ - **A function returning many rows** (`list`, `search`, any stats query) — ownership is
58
+ a `WHERE` clause in the query, because "only their rows" is a filter, not a yes/no.
59
+ There is no permission to write here; scoping the query IS the enforcement.
60
+
61
+ A list that fetches everything and then filters in JS is a bug, not a permission.
62
+
63
+ ## Writing the checkers
64
+
65
+ Put them in `src/permissions/`, one file per entity, and reuse one checker across every function on that
66
+ entity rather than writing a near-copy per function.
67
+
68
+ ```typescript
69
+ // src/permissions/book.ts
70
+ import { pikkuPermission, pikkuAuth } from '#pikku/auth'
71
+
72
+ // Data-aware: gets the input, so it can load the row the caller named.
73
+ export const isBookOwner = pikkuPermission(
74
+ async ({ kysely }, { bookId }, { session }) => {
75
+ const book = await kysely
76
+ .selectFrom('book')
77
+ .select('ownerId')
78
+ .where('bookId', '=', bookId)
79
+ .executeTakeFirst()
80
+ return book?.ownerId === session?.userId
81
+ },
82
+ )
83
+
84
+ // Session-only: no input needed. Use for role and flag gates.
85
+ export const isAdmin = pikkuAuth(async (_services, session) => session?.role === 'admin')
86
+ ```
87
+
88
+ ## OR and AND
89
+
90
+ ```typescript
91
+ permissions: {
92
+ owner: isBookOwner, // OR — an owner may
93
+ admin: isAdmin, // OR — an admin may
94
+ editor: [isAdmin, isBookOwner] // AND — both, inside one group
95
+ }
96
+ ```
97
+
98
+ Groups are OR'd; entries inside a group array are AND'd.
99
+
100
+ ## Roles
101
+
102
+ If the app has roles, the role lives on the session (see pikku-auth / `mapSession`) and
103
+ every mutating or admin-only function names it in `permissions`. Gate the FUNCTION — hiding
104
+ an admin button in the UI is UX, never enforcement, and a member who guesses the RPC name
105
+ gets straight through if the function itself is open.
106
+
107
+ ## Scopes
108
+
109
+ `scopes: ['admin:invoices:void']` is an AND gate checked BEFORE permissions and before
110
+ input validation. Declare the tree once with `defineScope`; a function naming an
111
+ undeclared scope fails codegen rather than gating on nothing. A grant satisfies a scope if
112
+ it is that scope, an ancestor, or a wildcard — a session holding `admin` satisfies
113
+ `admin:invoices:void`. Most apps need roles, not scopes; reach for these only when the
114
+ plan asked for granular grants.
115
+
116
+ ## The one sanctioned exception
117
+
118
+ `permissionsInBody: true` — for a check that genuinely cannot be a permission because the
119
+ identity arrives in the payload and there is no session: a webhook signature, a signed
120
+ token, an invite code. It is purely declarative and enforces nothing; its job is to tell
121
+ the auditor the openness is deliberate. Anything expressible as a permission must be one.
122
+
123
+ ## After changes
124
+
125
+ `pikku all` — regenerates and typechecks the checkers. A permission whose signature is
126
+ wrong fails here, not at runtime.
@@ -195,6 +195,26 @@ const pikku = createPikku(PikkuFetch, PikkuRPC, {
195
195
  })
196
196
  ```
197
197
 
198
+ `transformDate: true` revives **fully-zoned ISO-8601 instants** — `2026-03-14T08:12:00Z`,
199
+ `2026-03-14T08:12:00.000+01:00` — into `Date` objects. Nothing else is touched: a bare
200
+ `2026-03-14`, a zoneless `2026-03-14T08:12:00`, and a shaped-but-impossible
201
+ `2026-02-31T00:00:00Z` all stay the strings the server sent, because each names a reading
202
+ rather than a moment and `new Date` would guess a different instant per machine.
203
+
204
+ So the field's runtime type follows the VALUE, not the schema — one `z.string()` column can
205
+ arrive as a `Date` from one row and a string from the next. Two consequences, both of which
206
+ typecheck:
207
+
208
+ - **A string method on a revived field throws at runtime.** `row.createdAt.split('T')[0]` —
209
+ there is no string to slice.
210
+ - **A raw `Date` in JSX crashes the route.** `<span>{row.createdAt}</span>` throws
211
+ `Objects are not valid as a React child (found: [object Date])` and the page falls into its
212
+ error boundary — a white screen, with nothing catching it first.
213
+
214
+ Format before rendering, with whatever date library the project already uses, and let it take
215
+ either type. Coercing instead (`` `${d}` ``, `String(d)`) does not crash but prints
216
+ `Mon Jun 15 2026 02:00:00 GMT+0200`, which is a different bug.
217
+
198
218
  There is no request-interceptor hook. For a token that changes after startup,
199
219
  call the setter on the shared instance — RPC and realtime pick it up because
200
220
  they hold the same fetch:
@@ -0,0 +1,147 @@
1
+ ---
2
+ name: pikku-realtime
3
+ description: >-
4
+ Use when making ANY view live/realtime in a Pikku app — a board, shared list, dashboard, ticker, bidding room, live count — or when adding two-way chat/presence. Covers the DEFAULT event-hub SSE path and the two-way WebSocket channel.
5
+ TRIGGER when: the user wants live updates, realtime, "update without refresh", a live board/feed/ticker/room, presence, or chat; or when data that MORE THAN ONE signed-in user can change should reflect others
6
+ DO NOT TRIGGER when: a plain one-shot query/refetch is fine (data only one user changes, or a manual refresh is acceptable), or for background jobs (that is pikku-schedule/pikku-workflow).
7
+ installGroups: [core, client]
8
+ ---
9
+
10
+ # Pikku Realtime (SSE + WebSocket channels)
11
+
12
+ There is NOTHING to hand-roll and NOTHING to "find". The event-hub SSE transport
13
+ is already wired into every app, and the two patterns below ARE the realtime
14
+ templates. Start from them and rename — never grep the project for existing
15
+ `sse`/`eventHub` code to copy, never write a custom `EventSource`, and never
16
+ write a bespoke `sse: true` route for a plain live feed.
17
+
18
+ ## Pick the transport (almost always SSE)
19
+
20
+ - **Server → client live updates → SSE via the event-hub.** This is the DEFAULT
21
+ for making any view live: a board, list, dashboard, ticker, feed, or a "room"
22
+ (a bidding room, sale room, live auction). The client only RECEIVES — the
23
+ change itself happens through a NORMAL HTTP RPC (`placeBid`, `updateLot`, …)
24
+ that publishes the new row.
25
+ - **Client → server push mid-session → a WebSocket channel.** ONLY when the
26
+ BROWSER must send up the socket without a page action: live chat messages,
27
+ typing indicators, cursors/presence.
28
+
29
+ A screen being called a "room", or being multi-user, or being live is NOT a
30
+ reason to use a channel. If the browser isn't pushing frames up, it's SSE.
31
+
32
+ ## Level 1 — live updates (event-hub SSE, the default)
33
+
34
+ Two halves; both are required or nothing arrives.
35
+
36
+ **Backend — publish after every write.** In each create/update/status function,
37
+ AFTER the DB write, publish the changed row on a topic:
38
+
39
+ ```ts
40
+ const lot = await kysely
41
+ .updateTable('lot')
42
+ .set({ status: 'sold' })
43
+ .where('id', '=', input.lotId)
44
+ .returning(['id', 'status', 'currentBid', 'updatedAt'])
45
+ .executeTakeFirstOrThrow()
46
+ await eventHub.publish('lot-updated', null, { topic: 'lot-updated', data: lot })
47
+ return lot
48
+ ```
49
+
50
+ **A topic is PUBLIC — publish a projection, never `returningAll()`.** The generated
51
+ `/events/:topic` route is wired `auth: false` with a sessionless handler, so anyone who can
52
+ reach the origin can subscribe to any topic name and read every frame on it. `returningAll()`
53
+ then ships the whole row — `reservePrice`, `sellerId`, internal notes, whatever the table
54
+ grows next — to unauthenticated subscribers, and it does it silently because the RPC's own
55
+ `output` schema never sees the event payload. List the columns the topic is FOR, the way the
56
+ example does. If a change genuinely has per-viewer content, it does not belong on a topic:
57
+ publish an id-only "something changed" frame and let each client refetch through an
58
+ authenticated RPC that applies its own permissions.
59
+
60
+ The **2nd arg is the channel to EXCLUDE** from the broadcast: pass `null` from a
61
+ normal HTTP/RPC write (there is no one to skip); pass `channel.channelId` ONLY
62
+ when you publish from INSIDE a channel handler, or the sender gets an echo of its
63
+ own update. `eventHub` is already injected — do not wire it.
64
+
65
+ **Frontend — subscribe over SSE.** The generated
66
+ `PikkuRealtime.subscribeToTopic(topic, handler)` opens an SSE stream to the
67
+ built-in `/events/:topic` route. Seed state from a normal query, then patch it as
68
+ events arrive; the event is the `{ topic, data }` envelope, so read `.data`.
69
+
70
+ ```tsx
71
+ import { useEffect } from 'react'
72
+ import { useQueryClient } from '@tanstack/react-query'
73
+ import { realtime } from '../lib/pikku'
74
+
75
+ export function useLiveLots() {
76
+ const queryClient = useQueryClient()
77
+
78
+ useEffect(() => {
79
+ const subscription = realtime.subscribeToTopic('lot-updated', () => {
80
+ queryClient.invalidateQueries({ queryKey: ['listLots'] })
81
+ })
82
+ return () => subscription.close()
83
+ }, [queryClient])
84
+ }
85
+ ```
86
+
87
+ **Invalidate; do not hand-patch the cache.** The generated hooks key a query as
88
+ `[name, input]` — `['listLots', { status: 'open', cursor: undefined }]`, one entry per set of
89
+ arguments — so `setQueryData(['listLots'], …)` writes to a key nothing reads and the screen
90
+ never changes. `invalidateQueries({ queryKey: ['listLots'] })` prefix-matches, so it refreshes
91
+ every variant of that list whatever input each one was fetched with.
92
+
93
+ Patching also has to know the payload's shape, and a list RPC returns
94
+ `ListOutput<Lot>` — `{ rows, nextCursor, totalCount? }`, not `Lot[]` — so a `rows.map(...)`
95
+ updater is reading `.map` off an object. Refetching sidesteps both, and it re-applies the server's own filtering,
96
+ which a locally patched row does not: a lot that just moved to `sold` may no longer belong in
97
+ an "open lots" list at all.
98
+
99
+ `subscribeToTopic` returns `{ close }` — ALWAYS close on unmount or you leak the
100
+ stream. Never hand-roll an `EventSource`.
101
+
102
+ ## Level 2 — two-way channel
103
+
104
+ Only when the client pushes up the socket. The backend channel lives in its own
105
+ `*.channel.ts` with `onConnect`/`onMessage` handlers:
106
+
107
+ ```ts
108
+ import { pikkuChannelFunc, wireChannel } from '#pikku/channel'
109
+
110
+ export const onMessage = pikkuChannelFunc<{ text: string }>({
111
+ func: async ({ eventHub }, input, { channel, session }) => {
112
+ const message = { id: crypto.randomUUID(), text: input.text, userId: session!.userId }
113
+ await eventHub.publish('room', channel.channelId, { topic: 'room', data: message })
114
+ return message
115
+ },
116
+ })
117
+
118
+ wireChannel({ name: 'room', route: '/room', auth: true, onMessage })
119
+ ```
120
+
121
+ The frontend opens it with `PikkuRealtime.connectToChannel(path)`, which returns
122
+ a socket you both `.send(...)` on and read via `onmessage`:
123
+
124
+ ```tsx
125
+ useEffect(() => {
126
+ const socket = realtime.connectToChannel('/room')
127
+ socket.onmessage = (event) => appendMessage(JSON.parse(event.data))
128
+ return () => socket.close()
129
+ }, [])
130
+ ```
131
+
132
+ Publish server→client fan-out from a channel handler with
133
+ `eventHub.publish(topic, channel.channelId, envelope)` — the 2nd arg excludes the
134
+ sender, so the browser that sent the message does not receive its own echo.
135
+
136
+ ## Do NOT
137
+
138
+ - Do **not** grep the project for existing SSE/eventHub infra to reverse-engineer
139
+ or copy — the patterns above ARE the template (same rule as never reading
140
+ `.gen.ts` to learn an API).
141
+ - Do **not** write a custom `sse: true` HTTP route or a bespoke `EventSource` for
142
+ an ordinary live feed — the event-hub covers it. (A dedicated `sse: true` route
143
+ is only for a long-job PROGRESS stream, and is not needed for an initial build.)
144
+ - Do **not** use a WebSocket channel for a live board/ticker/room — that is SSE.
145
+ A channel is for client→server push (chat/presence) ONLY.
146
+ - Do **not** forget the backend `eventHub.publish(...)` — a subscribed frontend
147
+ with no publisher is a silent, empty stream.
@@ -0,0 +1,133 @@
1
+ ---
2
+ name: pikku-seo
3
+ description: >-
4
+ On-page SEO rules for the app's PUBLIC pages: per-route head() titles and meta descriptions, Open Graph tags, one-h1 heading hierarchy, semantic/crawlable markup, JSON-LD on the landing page, and noindex for the logged-in area.
5
+ TRIGGER when: building or reworking any public page (landing, pricing, about, blog/content pages), writing page titles or meta tags, or the user asks about SEO / Google / discoverability / social sharing previews.
6
+ DO NOT TRIGGER when: working on logged-in /app screens (they are noindexed — only the one robots rule below applies), backend functions, database, or deployment.
7
+ installGroups: [client]
8
+ ---
9
+
10
+ # SEO Rules
11
+
12
+ Apps render SSR from the edge, so crawlers see full HTML — the ranking work is
13
+ getting the on-page signals right while you build. These rules apply to PUBLIC
14
+ routes only (the landing page and any marketing/content pages). The logged-in
15
+ `/app` area is private: it gets `noindex` and nothing else from this skill.
16
+
17
+ ## Per-route head() — every public route, no exceptions
18
+
19
+ Titles and descriptions live in TanStack Start's `head()` on the route, merged
20
+ root → leaf (the leaf's title/meta win). The root route already carries the
21
+ site-wide defaults and OG tags; every public page you add MUST override both:
22
+
23
+ ```tsx
24
+ export const Route = createFileRoute('/pricing')({
25
+ head: () => ({
26
+ meta: [
27
+ { title: 'Pricing — Acme Scheduling' },
28
+ {
29
+ name: 'description',
30
+ content:
31
+ 'Simple per-seat pricing for Acme Scheduling. Start free, upgrade when your team grows — no setup fees, cancel anytime.',
32
+ },
33
+ { property: 'og:title', content: 'Pricing — Acme Scheduling' },
34
+ { property: 'og:description', content: 'Simple per-seat pricing. Start free.' },
35
+ ],
36
+ }),
37
+ component: PricingPage,
38
+ })
39
+ ```
40
+
41
+ `head()` strings are plain strings (they do not go through the Mantine i18n
42
+ gate) — write real copy for THIS app, in the app's voice.
43
+
44
+ - **Title**: unique per page, 50–60 characters, the page's primary topic first,
45
+ brand at the end (`Topic — AppName`). The template's `__APP_TITLE__` default
46
+ must never survive the rebrand, on any page.
47
+ - **Description**: unique per page, 150–160 characters, states the concrete
48
+ value of the page in plain language — a reason to click, not a keyword list.
49
+ - **Dynamic public pages** (e.g. a public detail page) build both from loader
50
+ data: `head: ({ loaderData }) => ({ meta: [{ title: `${loaderData.name} — AppName` }, ...] })`.
51
+ - **Never invent URLs**: the deployed domain is unknown at build time, so do
52
+ NOT emit `canonical`, `og:url`, or `og:image` pointing at a made-up domain —
53
+ omit them (same principle as the `/api` serverUrl rule). `og:image` only if a
54
+ real asset exists in the app.
55
+
56
+ ## Logged-in area = noindex
57
+
58
+ The `/app` route (the authenticated layout route) gets exactly one meta entry:
59
+
60
+ ```tsx
61
+ head: () => ({ meta: [{ name: 'robots', content: 'noindex' }] })
62
+ ```
63
+
64
+ Never noindex a public page, and never put per-page SEO effort into `/app`
65
+ screens — they are invisible to crawlers by design.
66
+
67
+ ## Headings — exactly one h1 per page
68
+
69
+ - Every page has EXACTLY ONE h1 (`<Title order={1}>` in Mantine, `<h1>` in
70
+ Tailwind) and it names the page's primary topic — aligned with the title tag,
71
+ not identical boilerplate.
72
+ - Logical hierarchy below it: h1 → h2 → h3, no skipped levels, headings
73
+ describe the content under them. Never pick a heading level for its font
74
+ size — set the size on the correct level (`<Title order={2} fz="xs">`).
75
+
76
+ ## Crawlable, semantic markup
77
+
78
+ - Landmarks on public pages: `<nav>`, `<main>`, `<footer>` (Mantine: `component="nav"` etc.).
79
+ - Navigation between public pages uses real links (`<Link>`/`<a href>`) with
80
+ descriptive anchor text — crawlers follow hrefs; a `div onClick` navigation
81
+ is invisible to them. No public page may be orphaned: every public page is
82
+ reachable by link from the landing page (directly or via nav/footer).
83
+ - Every meaningful `<img>` has alt text describing the image; decorative images
84
+ get `alt=""`. Prefer descriptive file names for real assets.
85
+ - Readable URLs: public routes are lowercase, hyphen-separated, and named for
86
+ their content (`/pricing`, `/how-it-works`) — never `/page2` or query-param
87
+ navigation.
88
+
89
+ ## JSON-LD on the landing page
90
+
91
+ The landing page carries one structured-data script describing the product.
92
+ Only mark up what is visibly true on the page — never invent ratings, reviews,
93
+ or offers (fake schema is a Google penalty, not a boost):
94
+
95
+ ```tsx
96
+ head: () => ({
97
+ meta: [
98
+ /* title + description as above */
99
+ ],
100
+ scripts: [
101
+ {
102
+ type: 'application/ld+json',
103
+ children: JSON.stringify({
104
+ '@context': 'https://schema.org',
105
+ '@graph': [
106
+ { '@type': 'Organization', name: 'Acme Scheduling', description: '…' },
107
+ { '@type': 'WebSite', name: 'Acme Scheduling' },
108
+ ],
109
+ }),
110
+ },
111
+ ],
112
+ })
113
+ ```
114
+
115
+ Add further types only when the page genuinely IS that thing and shows the
116
+ required fields: `FAQPage` for a real FAQ section, `Article` for a blog post
117
+ (headline, datePublished, author), `Product`/`Offer` for a real price list.
118
+ Omit `url`/`logo` fields — deployed domain unknown (see "never invent URLs").
119
+
120
+ ## Verify
121
+
122
+ Open each PUBLIC page and read its `<head>`: a title, a meta description, and
123
+ exactly one `h1`. A public page is not done while any of the three is missing.
124
+ Signed-in pages under `/app` are exempt once `noindex` is set — they are not
125
+ indexed, so their head tags do not matter.
126
+
127
+ ## Don'ts
128
+
129
+ - No keyword stuffing — write for the reader; one clear topic per page.
130
+ - Don't duplicate the same title/description across pages (worse than absent).
131
+ - Don't render SEO-critical copy only after client-side effects — it must be
132
+ in the SSR HTML (loader data is fine; `useEffect`-fetched content is not).
133
+ - Don't add robots.txt/sitemap plumbing — the platform owns that layer.
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: pikku-software-archaeology
3
3
  description: 'Use when reverse-engineering an existing repository into a Product Blueprint — recovering what product an undocumented or organically-grown codebase implements so it can be rebuilt cleanly (e.g. as a Pikku app) — and when turning that blueprint into a plain-language second opinion for the non-technical owner who holds the app. TRIGGER when: user says "extract a blueprint", "reverse engineer this app", "what does this codebase actually do as a product", "prepare this repo for a rewrite/migration", points at a legacy repo (any language — JS, TS, Ruby, Python, PHP, Go) and asks for its domains, workflows, business rules or a rebuild plan, or asks "explain how my app works" / "what would you do differently" / "is this built well?" for a founder, PM or operator audience. DO NOT TRIGGER for: documenting code structure, generating API docs from an already-clean codebase, or an engineer-facing code review.'
4
+ installGroups: [core]
4
5
  ---
5
6
 
6
7
  # Software Archaeology
@@ -121,6 +121,31 @@ With neither secret set, deliveries go **unsigned**. A missing named secret is
121
121
  logged as an error and still sends unsigned; treat that log line as a
122
122
  misconfiguration, not noise.
123
123
 
124
+ ## What this does not give you
125
+
126
+ There is no subscription model. `webhookSchema` owns exactly two tables —
127
+ `webhookDelivery` and `webhookDeliveryAttempt` — and both are delivery-side
128
+ history. Nothing stores _which_ URL belongs to which customer, which events
129
+ they asked for, or whether their endpoint is still enabled.
130
+
131
+ That is the app's table, and every app that exposes webhooks to its users
132
+ needs one:
133
+
134
+ | Column | Why |
135
+ | --------- | ------------------------------------------------------------------------------------------------------------- |
136
+ | `url` | where to POST |
137
+ | `secret` | the raw HMAC key, passed as `SendWebhookInput.secret` |
138
+ | `events` | which event names this endpoint subscribed to |
139
+ | `enabled` | so a failing endpoint can be paused without deleting it |
140
+ | scope | the org/tenant column you filter on — mirror it into `organizationId` so the delivery log scopes the same way |
141
+
142
+ So one emitted event becomes a `SELECT` over your endpoint table and one
143
+ `send()` per row. Everything after that call — signing, queueing, retrying,
144
+ recording — is the primitive's.
145
+
146
+ The single-integration case needs none of this: one fixed URL on the row it
147
+ belongs to, and `send()` straight at it.
148
+
124
149
  ## Verifying on the receiving side
125
150
 
126
151
  `sign()` produces `sha256=<hex>` (GitHub style, body only, no timestamp) into