velvet-rope 0.2.0 → 0.3.0

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 ADDED
@@ -0,0 +1,199 @@
1
+ # Changelog
2
+
3
+ Every notable change to velvet-rope, newest first. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Before 1.0, a minor version (0.x.0)
6
+ may break things. Breaking changes are marked **Breaking:**, and the release notes say how to
7
+ upgrade.
8
+
9
+ ## [Unreleased]
10
+
11
+ ## [0.3.0] - 2026-11-03
12
+
13
+ ### Added
14
+
15
+ - `eventsRoute: false` option, for apps that bridge velvet-rope's events into their own stream.
16
+ - `roles.canManageDevice()` for renaming and revoking a device: a member may always manage their
17
+ own devices, and anyone else's need `manageMembers`.
18
+ - The `remove`, `relink` and `rename` member actions. `rename` is allowed for your own member or
19
+ for anyone with `manageMembers`.
20
+ - Group creation accepts an optional `name` for the first member, which defaults to the device
21
+ label.
22
+ - `testing.addDevice()`.
23
+ - A join request renews while its page is open: `POST /link-requests/current/renew` and
24
+ `renewJoin()` extend a pending request without changing its code, up to a cap. The
25
+ `linkRequestTtlMs` (default 5 minutes) and `linkRequestMaxAgeMs` (default 1 hour) options set
26
+ both. The join cookie now lasts for the maximum age.
27
+ - `sessionTtlMs` option, for a separate sliding lifetime for passkey and device-key sessions (30
28
+ and 400 days by default). The session cookie's `Max-Age` follows the device kind, capped at 400
29
+ days.
30
+ - `Refusal` (from `velvet-rope/server`), for hooks to veto a change:
31
+ `throw new Refusal(status, code, message?)` rolls the change back and replies
32
+ `{ error: code, message }`. Any other error a hook throws also rolls back, and replies with a
33
+ generic 500. Hooks run inside the transaction and must be synchronous.
34
+ - The `onGroupDeleting` hook, which runs in the delete transaction before the group's rows go.
35
+ - `sessionCookieName` option, which replaces the whole session cookie name, `__Host-` prefix
36
+ included, for apps that must keep an existing cookie.
37
+ - Join requests take an optional `name` and `profile` (`requestJoin({ label, name, profile })`).
38
+ The profile is checked against the new `maxProfileBytes` option (default 16 KB), then by the
39
+ new `validateProfile` hook, which returns the value to store or throws a `Refusal`. By default
40
+ any profile is refused.
41
+ - `PATCH /devices/:id` and `DELETE /devices/:id` (`updateDevice()`, `removeDevice()`): rename or
42
+ revoke one device without ending its member. A member may always manage their own devices.
43
+ Revoking the last device leaves the member in the group, ready to be linked again.
44
+ - The `onMemberEnded(db, { group, member, reason, actorId })` and
45
+ `onDeviceRevoked(db, { group, member, device, reason })` hooks, which run inside the
46
+ transaction and can veto. There's also a `member-ended` group event, `{ id, reason }`.
47
+ - When sign-in is refused because the device was removed, the client calls
48
+ `PublicKeyCredential.signalUnknownCredential`, so the browser can forget that passkey. The call
49
+ is skipped where unsupported, and its errors are ignored. `connectSessionEvents` gains
50
+ `onMemberEnded({ id, reason })`.
51
+ - **Relink and device transfer.** Completing a join applies the outcome the scanner chose:
52
+ - `own-device` and `member` add the new device to an existing member;
53
+ - `signOut` then signs out the scanning device (`scanner`) or the member's other devices
54
+ (`others`) when the new device finishes, not at the scan, with `onDeviceRevoked` reason
55
+ `replaced`.
56
+
57
+ This covers switching phones (the old phone scans the new one) and relinking a lost phone (an
58
+ admin scans for the member).
59
+ - **Device keys.** Under `credentials: ['passkey', 'key']` or `['key']`, a device that can't
60
+ make a passkey gets a long-lived HttpOnly cookie instead. The cookie is the device key, hashed
61
+ like any session, and slides for 400 days. It can finish joining with
62
+ `POST /link-requests/current/complete-key` (`completeJoinWithKey()`), with the same outcomes
63
+ and sign-outs as a passkey join. It can create a group with `POST /groups/key`
64
+ (`createGroupWithKey()`), under the same invite, profile and hook rules as `/groups`. Without
65
+ `'key'` in the policy, these routes refuse with `key_not_allowed`. Under `['key']`, passkey
66
+ joins and passkey group creation refuse with `passkey_not_allowed`.
67
+ - Upgrade a device-key device to a passkey in place, from the device itself and with no scan:
68
+ `POST /devices/current/passkey/options`, `POST /devices/current/passkey` and
69
+ `upgradeToPasskey()`. The device keeps its id, label and member, and its session stays.
70
+ - The join screen (`mountJoin`) now:
71
+ - renews its code while the code is showing;
72
+ - offers "Use this device without a passkey" when the server allows device keys and a
73
+ passkey can't be made, and joins with a key directly under `['key']`;
74
+ - says on success whether the device joined as a new member or was added to someone's
75
+ devices.
76
+
77
+ The new `JOIN_TEXT` keys are `useKey`, `welcome` and `addedTo`.
78
+ - `docs/importing.md`, which ships in the package: a tested guide to importing members and
79
+ device cookies from an existing app, so its users stay signed in through the switch.
80
+ - The `onMemberCreated(db, { group, member, profile, via })` hook, at group creation
81
+ (`via: 'create-group'`) and when a new member joins (`via: 'link'`, with the join request's
82
+ profile). It runs inside the transaction and can veto.
83
+ - `POST /link-requests/peek` and `peekLink(token)` preview a join request without claiming it.
84
+ The preview shows the label, name, profile, expiry and the outcomes this scanner may choose.
85
+ `allowedOutcomes(role, roles)` in the domain gives the same list.
86
+ - `mountJoin` takes `renderProfileFields(box, savedProfile)`, `askName` and `draftKey`. They add
87
+ a name field, the app's own profile fields, and a draft saved in `localStorage` that comes back
88
+ when the page is reopened. The name and profile are sent with the join request.
89
+ - `credentials` option: `['passkey']` (the default), `['passkey', 'key']` or `['key']`. A
90
+ joining device sees the policy in its join status.
91
+ - `ids` option, for app-supplied id generators for groups, members and devices (`randomUUID` by
92
+ default).
93
+
94
+ ### Changed
95
+
96
+ - **Breaking:** members own devices. The `passkeys` table is now `devices`, and a new `members`
97
+ table holds each person's name and role. `migrate()` converts existing data and keeps ids, so
98
+ app tables that referenced `passkeys(id)` now reference `devices(id)`. Apps that query
99
+ `passkeys` in raw SQL must switch to `devices` or `members`. A revoked 0.2 passkey becomes a
100
+ member who was removed.
101
+ - **Breaking:** `request.session`, `GET /me` and the sign-in, group-creation and join responses
102
+ are now `{ member: { id, name, role }, device: { id, label, kind }, group }`, plus the
103
+ deprecated `passkey` alias. The `onAudit` hook's `actorId` is a member id, and so is `target`,
104
+ except for the device actions (`rename-device`, `revoke-device`), whose `target` is the device
105
+ id.
106
+ - **Breaking:** the event hub's `passkeyId` is now `deviceId`, in `subscribe()`, `publishTo()`
107
+ and `disconnect()`.
108
+ - **Breaking:** `GET /members` lists people (`id, name, role, created_at, current`), with their
109
+ live devices under `devices` (`id, label, kind, last_used_at, current`). The member-level
110
+ `label` and `last_used_at` fields are gone.
111
+ - `PATCH /members/:id` takes `name`, with `label` kept as a deprecated alias.
112
+ `DELETE /members/:id` removes the person and revokes all their devices.
113
+ - **Breaking:** `POST /link-requests/claim` needs an `outcome`: `new-member`, `own-device` (one of
114
+ the scanner's devices) or `member` (a device of the live member `memberId`). It also takes an
115
+ optional `signOut` (`none`, `scanner` or `others`). On the client it's
116
+ `claimLink(token, { outcome, memberId, signOut })`. The join status shows the outcome once the
117
+ request is claimed.
118
+ - Scanning a join code with the drop-in scan button now opens a confirm dialog
119
+ (`openClaimDialog`, `CLAIM_TEXT`, `CLAIM_CLASSES`). It shows the device's label, name and the
120
+ app's `renderProfile(profile)`, and the ways this member may add it: as a new member, as one of
121
+ their own devices, or as another member's device. Each comes with its sign-out choice. With
122
+ only one way, it's a single confirm button. `createScanButton` takes `claimDialog` options.
123
+ - Signing out on a device-key device removes that device from its member, because the cookie
124
+ is the device key (`onDeviceRevoked` with reason `signed-out`). The reply is
125
+ `{ forgotDevice: true }`, and `signOut()` resolves `{ forgotDevice }`. A passkey device signs
126
+ out as before.
127
+ - **Breaking:** the audit action for removing a member is now `remove` (it was `revoke`). The new
128
+ audit actions are `revoke-device` and `rename-device`.
129
+ - **Breaking:** `testing.addMember()` returns `{ memberId, deviceId, token }`.
130
+ - The default refusal messages say "device" where they used to assume a passkey
131
+ (`names_required`, `label_required`, `removed`). The codes are unchanged.
132
+ - **Breaking:** `linkOwnDevice` is now a library capability, so the app's admin role must grant
133
+ it or `defineRoles` throws.
134
+ - The CSRF guard requires a JSON content type only on requests that have a body; the CSRF header
135
+ is still always required.
136
+
137
+ ### Fixed
138
+
139
+ - The join screen shows an app's refusal at completion, such as a hook's veto, with a retry,
140
+ instead of saying the code expired.
141
+ - The claim dialog works as a modal for keyboard and screen-reader users. Escape cancels, focus
142
+ stays inside it, it's labelled by its title, and its member picker has an accessible name.
143
+
144
+ ### Deprecated
145
+
146
+ - The `revoke` member action; use `remove`.
147
+ - The `onGroupDeleted` hook; use `onGroupDeleting`. It still works when it's the only one given.
148
+ - `request.session.passkey` (and the `passkey` field in responses), the 0.2 shape
149
+ `{ id: device.id, label, role }`. It will be removed in 0.4.
150
+
151
+ ## [0.2.0] - 2026-11-01
152
+
153
+ ### Added
154
+
155
+ - Support for Node's built-in `node:sqlite` (Node 22.5+) alongside `better-sqlite3`. Pass a
156
+ handle from either driver wherever velvet-rope takes a `db`.
157
+ - `openDatabase(path, { driver })` opens a database with whichever driver is installed,
158
+ `better-sqlite3` first. `wrapDatabase(db)` adapts a handle the app already opened.
159
+ - npm package metadata: repository, homepage and issue tracker.
160
+
161
+ ### Changed
162
+
163
+ - `better-sqlite3` is now an optional peer dependency.
164
+ - The licence is now MPL-2.0.
165
+
166
+ ## [0.1.0] - 2026-10-28
167
+
168
+ The first version.
169
+
170
+ ### Added
171
+
172
+ - **Passkey sign-in:** discoverable sign-in, where the passkey picked decides the group.
173
+ Sessions use a hashed 256-bit cookie (`HttpOnly`, `SameSite=Strict`, `__Host-` on HTTPS) with
174
+ a 30-day sliding expiry.
175
+ - **Joining by QR:**
176
+ - a new device shows a QR code;
177
+ - a signed-in member who can `link` scans it, and the scan is the approval;
178
+ - the new device then registers its own passkey with the linked role.
179
+ - **Groups:** creation, controlled by `groupCreation` (`open`, `invite` or `closed`). Invites
180
+ are single-use, and an admin can delete the group.
181
+ - **Roles:** defined by the app with `defineRoles`. Members can be listed, renamed, given a new
182
+ role, revoked, or can leave, and the last admin can't be removed.
183
+ - **Server-Sent Events:** a stream per group (`members-changed`, `role-changed`,
184
+ `session-revoked`, `group-deleted`) that the app can share for its own events.
185
+ - **Protections:**
186
+ - a CSRF guard (JSON and a custom header);
187
+ - rate limits through `@fastify/rate-limit`;
188
+ - `{ error, message }` refusals that apps can reword;
189
+ - `startSweeper` for expired rows.
190
+ - **Entry points:**
191
+ - `velvet-rope/domain`, `/server`, `/client` and `/ui` (with `ui/style.css`);
192
+ - `/testing`, with a soft authenticator and member helpers;
193
+ - `/testing/playwright`.
194
+ - An example notes app.
195
+
196
+ [Unreleased]: https://github.com/EyeOfMidas/velvet-rope/compare/v0.3.0...HEAD
197
+ [0.3.0]: https://github.com/EyeOfMidas/velvet-rope/compare/v0.2.0...v0.3.0
198
+ [0.2.0]: https://github.com/EyeOfMidas/velvet-rope/compare/caf6bbb...v0.2.0
199
+ [0.1.0]: https://github.com/EyeOfMidas/velvet-rope/commit/caf6bbb