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 +199 -0
- package/README.md +366 -56
- package/docs/importing.md +115 -0
- package/package.json +4 -2
- package/src/client/client.js +58 -9
- package/src/client/session-events.js +13 -2
- package/src/domain/index.js +2 -1
- package/src/domain/link-request.js +44 -11
- package/src/domain/roles.js +29 -6
- package/src/domain/sessions.js +7 -3
- package/src/server/csrf.js +13 -6
- package/src/server/hooks.js +64 -0
- package/src/server/hub.js +17 -12
- package/src/server/index.js +1 -0
- package/src/server/messages.js +14 -4
- package/src/server/migrate.js +30 -4
- package/src/server/migrations/002-members.sql +60 -0
- package/src/server/options.js +61 -3
- package/src/server/plugin.js +10 -6
- package/src/server/registration.js +37 -12
- package/src/server/routes/auth.js +43 -15
- package/src/server/routes/devices.js +173 -0
- package/src/server/routes/events.js +3 -3
- package/src/server/routes/group.js +9 -8
- package/src/server/routes/groups.js +92 -22
- package/src/server/routes/link-requests.js +230 -53
- package/src/server/routes/me.js +3 -2
- package/src/server/routes/members.js +103 -65
- package/src/server/sessions.js +41 -19
- package/src/testing/index.js +1 -1
- package/src/testing/members.js +28 -10
- package/src/ui/claim-dialog.js +188 -0
- package/src/ui/index.js +1 -0
- package/src/ui/join.js +168 -15
- package/src/ui/scan-button.js +28 -9
- package/src/ui/style.css +31 -1
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
|