* feat(kv): share key-value prefixes with other users, and bound what apps can be granted - KV share handles move out of Events into KvShareService, served at /kv/shares (the /events/kv-handles routes stay as an alias). - Shares have watch, read and write levels, encoded in the permission root (kv-share, kv-share-read, kv-share-write) with write implying read and watch. - Every puter.kv method takes optConfig.handle; keys are relative to the shared prefix, cursors are sealed, and private entries stay hidden. - New puter.kv.share, unshare, listShared and listSharedByMe; optional label; kv.share.received notice in the app's mailbox only; recipient blocks apply. - puter.kv.set gains onlyIfAbsent. - fs and kv events gain viaWorker, and kv events report self from the writer. - /auth/grant-user-app only accepts canonical permissions from a fixed set of families; the consent dialog, puter.perms and docs follow. - Migrations: kv_share_handles gains revoked_by, label, and grantee and app indexes (mysql_mig_49, postgres_mig_38, sqlite 0095). * fix(kv): never name a private key in a cross-app or handle cursor, and bound flat-permission scans - Listings of another namespace (cross-app or through a handle) resume after the last entry they returned, and their cursors are sealed, bound to the namespace or handle, and padded to one length. Old plain cross-app cursors are still read when they name the target namespace; other namespaces get 400. - PermissionStore scans flat permissions a few roots at a time and skips roots nested under another root in the same call.
4.6 KiB
Pagination convention for list APIs
Every list endpoint follows one wire contract. This document is the source of truth for adding pagination to a new or existing list surface.
Wire contract
Requests accept:
| Param | Type | Meaning |
|---|---|---|
limit |
number | Maximum items per page. Each endpoint documents its cap and default. |
cursor |
string | null | Opaque continuation token. null (or any presence of the key) requests the first page. |
offset |
number | Skip N items. Legacy/discouraged — see below. Cannot be combined with cursor. |
includeTotal |
boolean | Adds total to the response. |
Paginated responses are an envelope:
{ "items": [...], "cursor": "…", "total": 123 }
cursoris present only while more pages exist. Clients iterate until it is absent.totalis present only when the request setincludeTotal.- Pages may be short. Post-query filtering (TTL expiry, permission
checks) can shrink a page below
limit— or even to zero — while acursoris still returned. Never useitems.length < limitas an end-of-list signal.
Backward compatibility
Requests without pagination params keep returning the full result in the legacy shape (bare array) forever — old clients never break.
The envelope trigger depends on the endpoint's history:
- Endpoints where
limitpre-dates the convention (readdir, subdomainselect) return the envelope only when the request containscursor(includingnull) orincludeTotal;limit/offset-only requests keep the bare array. - Endpoints where every pagination param is new (kv
list, workersgetFilePaths) return the envelope when any pagination param is present.
Cursors
Cursors are opaque base64-encoded JSON, produced and consumed only by the
backend (src/backend/util/pagination.ts). A cursor over a global sequence
(an auto-increment id) is sealed with sealCursor/openCursor instead, so
two pages can't be decoded into how fast the table grows; openCursor still
reads a plain cursor. What a cursor wraps is an implementation detail per
store:
- DynamoDB-backed lists wrap
LastEvaluatedKey. A kv listing of another namespace (cross-app or through a share handle) resumes after the last entry it returned instead, since the scan can stop on a private one, and its cursor is sealed to that listing and padded to one length. - SQL-backed lists wrap a keyset position —
(sortValue, id)of the last row — and the query seeks past it (WHERE (col, id) > (?, ?) ORDER BY col, id). Any SQL list gaining a cursor must have a deterministicORDER BYending in a unique tiebreaker (id).
Cursors that carry a sort also pin it: a request that passes a cursor plus a conflicting sort is rejected with 400.
Offset
SQL-backed endpoints support offset natively. DynamoDB-backed endpoints
(kv) emulate it by advancing past skipped items with Select: COUNT queries
— no item data is transferred, but read capacity is still consumed for
everything skipped, and the caller is metered for it. Offset is supported
for parity, not recommended — cost grows linearly with the offset, so it is
capped (kv: 5000). Use cursors.
Totals (includeTotal)
- SQL:
SELECT COUNT(*)with the same WHERE clause as the listing. - DynamoDB: a
Select: COUNTloop over the query (TTL-filtered), metered to the caller. Cost is proportional to the total item count — request the total on the first page only, not every page. - Where visibility is decided per-actor after the query (protected apps in
the catalog listing),
totalapproximates the visible set: it counts non-protected plus caller-owned rows, and misses rows visible only through explicit permission grants.
fetchUntilFull (kv only)
DynamoDB applies Limit before its filter expression, so TTL-filtered pages
are structurally short. fetchUntilFull: true makes the backend keep
fetching (bounded number of continuation queries) until the page holds
limit items or the keyset is exhausted. Requires limit. If the bound is
hit, the response simply carries a cursor — still convention-legal.
Adding pagination to a new endpoint
- Use
encodeCursor/decodeCursor/normalizeLimit/normalizeOffsetfromsrc/backend/util/pagination.ts—sealCursor/openCursorwhen the cursor wraps a global id. - Push equality filters into the query so pages and counts operate on the true result set; only genuinely per-actor filtering may remain post-query (short-pages rule covers it).
- Fetch
limit + 1rows to detect whether a next page exists (SQL), or useLastEvaluatedKey(DynamoDB). - Keep the no-params request returning the legacy full result.