@pikku/skills 0.12.25 → 0.12.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/CHANGELOG.md +33 -0
- package/dist/skills.gen.js +1 -1
- package/package.json +1 -1
- package/skills/pikku-a11y/SKILL.md +59 -0
- package/skills/pikku-architect/SKILL.md +1 -0
- package/skills/pikku-auth/references/better-auth.md +125 -7
- package/skills/pikku-build/SKILL.md +1 -0
- package/skills/pikku-build/references/app.md +1 -1
- package/skills/pikku-build/references/multi-app.md +55 -0
- package/skills/pikku-build/references/ship.md +2 -2
- package/skills/pikku-fabric/SKILL.md +35 -18
- package/skills/pikku-i18n/SKILL.md +5 -3
- package/skills/pikku-knowledge/SKILL.md +1 -0
- package/skills/pikku-list-query/SKILL.md +163 -0
- package/skills/pikku-permissions/SKILL.md +126 -0
- package/skills/pikku-react/references/client.md +20 -0
- package/skills/pikku-realtime/SKILL.md +147 -0
- package/skills/pikku-seo/SKILL.md +133 -0
- package/skills/pikku-software-archaeology/SKILL.md +1 -0
- package/skills/pikku-webhook/SKILL.md +25 -0
- package/skills/pikku-workflow/SKILL.md +37 -0
|
@@ -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
|
|
@@ -27,6 +27,43 @@ See `pikku-concepts` for the core mental model.
|
|
|
27
27
|
|
|
28
28
|
Build durable, multi-step workflows with automatic retry, sleep, suspend/resume, and parallel execution. Steps are cached for replay safety.
|
|
29
29
|
|
|
30
|
+
## Decide FIRST: should this even BE a workflow?
|
|
31
|
+
|
|
32
|
+
The deciding question is: **does any part of this cross an external boundary that can fail and MUST NOT be lost or double-run** — a payment authorised/captured through a provider, a third-party API call, an email/webhook, a wait for approval? If yes → workflow (durability, retries, restart-survival, and a visible run). If it's **a single algorithm done in one shot, purely local, and not reused elsewhere** → a plain `pikkuFunc` is correct; do NOT wrap it in a workflow.
|
|
33
|
+
|
|
34
|
+
- **Checkout WITH payment → workflow.** Get cart → compute total → **(atomic: create order + order items, deduct stock, clear cart)** → **charge payment through the provider** → send confirmation email. It's a workflow because the payment leg (and the email) are external and must be **retried, not lost, and not charged twice** across a restart — and the user benefits from seeing where the run is.
|
|
35
|
+
- **Checkout with NO external payment** — e.g. it just records the order and decrements stock in one transaction, nothing leaves the process — is a **single-shot algorithm**: a plain `pikkuFunc` wrapping one `kysely.transaction`. Not a workflow. A workflow here would add durability machinery for a thing that already commits atomically in one shot.
|
|
36
|
+
- **One durable step is NOT a workflow — it's a queue worker.** A lone side-effect that must be retried / not lost (send one email, fire one webhook, one external charge) → a **queue worker** (`pikku-queue`), enqueued fire-and-forget. A workflow adds a step graph for a thing that has no steps to orchestrate. (A single non-durable step is just a direct RPC call.)
|
|
37
|
+
- Also workflows: onboarding sequences, settlements/payouts, digests and batch sends, anything that waits (`sleep`/`suspend`) or fans out with retries — the common thread is **multiple** steps or a durable wait, never a single step.
|
|
38
|
+
|
|
39
|
+
**HARD RULE — never a single-RPC (one-step) workflow.** A workflow whose body is one `workflow.do('x', 'someRpc', …)` is a mislabeled durable function, not orchestration. Route by durability, NOT into a workflow:
|
|
40
|
+
|
|
41
|
+
- **Not durable** — the caller wants the result now / it can just run in-request → **call the RPC directly** (this is also the synchronous path). No workflow, no queue.
|
|
42
|
+
- **Durable** — must be **retried / not lost / survive a restart** (one email, one webhook, one external charge) → a **queue worker** (`wireQueueWorker` + `queueService.add(...)`). Fire-and-forget, retried by the queue.
|
|
43
|
+
|
|
44
|
+
There is no "one-step workflow is justified for the durability" exception — durability for a single step is a QUEUE. A workflow earns its name only with genuine multi-step orchestration (a `sleep`/`suspend` wait, fan-out, or a saga).
|
|
45
|
+
|
|
46
|
+
**Atomicity is a TRANSACTION, not a workflow.** All-or-nothing multi-write units (create order + items + deduct stock + clear cart) belong inside ONE `kysely.transaction(async (trx) => { … })` — a single step or a single plain `pikkuFunc` — **never split across workflow steps.** A step is a unit of RETRY and REPLAY, not a unit of atomicity: pikku opens no transaction around `workflow.do`, so a step that does three writes and throws on the third leaves the first two committed, and the retry runs them again. Spreading one logical transaction over several steps is the same failure one level up. Your writes are atomic only where YOU opened a transaction, so open one inside the step (reach for compensating/saga steps only when you truly need cross-service rollback). So a payment checkout is a workflow whose _atomic DB writes are ONE step that opens ONE transaction_, with the payment charge and email as the other durable steps around it.
|
|
47
|
+
|
|
48
|
+
**A retried step re-runs its side effects.** Replay caching only covers steps that already
|
|
49
|
+
returned; a step that failed — or that timed out after the provider accepted it — runs again
|
|
50
|
+
from the top, so a charge, an email or a webhook can fire twice. Durability is at-least-once,
|
|
51
|
+
not exactly-once. Pass a stable idempotency key the provider deduplicates on, derived from the
|
|
52
|
+
workflow's own data rather than generated inside the step:
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
await workflow.do('Charge', 'chargePayment', {
|
|
56
|
+
orderId: data.orderId,
|
|
57
|
+
amount: data.amount,
|
|
58
|
+
idempotencyKey: `order-${data.orderId}-charge`,
|
|
59
|
+
})
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`randomUUID()` or `Date.now()` inside the step is a different key on every attempt, which is
|
|
63
|
+
the double-charge. Where the provider has no such header, make the step itself idempotent —
|
|
64
|
+
check for the effect before performing it, or record a unique row that the second attempt
|
|
65
|
+
collides with.
|
|
66
|
+
|
|
30
67
|
## Choosing the right factory
|
|
31
68
|
|
|
32
69
|
| Factory | When to use | Step-graph view? |
|