mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-19 19:55:57 +00:00
fix: shared kv handles also allow value opt in (#3895)
This commit is contained in:
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
|
||||
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
|
||||
|
||||
/** Highest schema version the migration table can reach. */
|
||||
const CURRENT_SCHEMA_VERSION = 80;
|
||||
const CURRENT_SCHEMA_VERSION = 81;
|
||||
|
||||
/**
|
||||
* These suites migrate real files on disk. Idle they finish in well under a
|
||||
|
||||
@@ -2210,26 +2210,30 @@ describe('cross-user kv handles', () => {
|
||||
expect(wire).not.toContain(`u${userId}`);
|
||||
});
|
||||
|
||||
it('never delivers values: a handle grants watching, not reading', async () => {
|
||||
it('hands a guest the value when it asked, keyed relative to the handle', async () => {
|
||||
mintHandle();
|
||||
await expect(
|
||||
service.subscribe(actorFor(guestId), socketId, {
|
||||
vi.useFakeTimers();
|
||||
const asking = (
|
||||
await service.subscribe(actorFor(guestId), socketId, {
|
||||
subject: `kv:${handle}:*`,
|
||||
includeValue: true,
|
||||
}),
|
||||
).rejects.toSatisfy(
|
||||
(err: unknown) =>
|
||||
isHttpError(err) &&
|
||||
err.legacyCode === 'events_kv_handle_no_values',
|
||||
);
|
||||
})
|
||||
).sub;
|
||||
const silent = (await subscribeAsGuest(`kv:${handle}:*`)).sub;
|
||||
expect(asking.includeValue).toBe(true);
|
||||
|
||||
// And a value on the wire never reaches a guest row either way.
|
||||
vi.useFakeTimers();
|
||||
const { sub } = await subscribeAsGuest(`kv:${handle}:*`);
|
||||
await dispatchKv([`${PREFIX}title`], { values: ['secret'] });
|
||||
await dispatchKv([`${PREFIX}title`], { values: ['hello'] });
|
||||
await vi.advanceTimersByTimeAsync(EVENTS_COALESCE_WINDOW_MS + 1);
|
||||
expect(sent[0].envelope.subId).toBe(sub.subId);
|
||||
expect(sent[0].envelope.event).not.toHaveProperty('value');
|
||||
|
||||
const byId = new Map(
|
||||
sent.map((one) => [one.envelope.subId, one.envelope.event]),
|
||||
);
|
||||
expect(byId.get(asking.subId)).toMatchObject({
|
||||
subject: `kv:${handle}:title`,
|
||||
key: 'title',
|
||||
value: 'hello',
|
||||
});
|
||||
expect(byId.get(silent.subId)).not.toHaveProperty('value');
|
||||
});
|
||||
|
||||
it('delivers every key under the granted region', async () => {
|
||||
|
||||
@@ -1129,11 +1129,9 @@ const parseIncludeValue = (value: unknown): true | undefined => {
|
||||
};
|
||||
|
||||
/**
|
||||
* A value rides only on a key-value row, and never through a share handle: the
|
||||
* grant behind a handle is to watch a region, not to read it, and a delivery
|
||||
* carrying the value would be a read the grant never gave. Decided on the raw
|
||||
* subject, before anything is resolved, so the refusal names this and not
|
||||
* whatever resolution would have said.
|
||||
* A value rides only on a key-value row — the other families have no value to
|
||||
* name. Decided on the raw subject, before anything is resolved, so the refusal
|
||||
* names this and not whatever resolution would have said.
|
||||
*/
|
||||
const assertValueDeliverable = (rawSubject: string): void => {
|
||||
if (parseSubject(rawSubject).family !== 'kv')
|
||||
@@ -1141,11 +1139,6 @@ const assertValueDeliverable = (rawSubject: string): void => {
|
||||
'includeValue applies to kv: subjects only',
|
||||
'invalid_include_value',
|
||||
);
|
||||
if (kvHandleFromSubject(rawSubject) !== null)
|
||||
throw badRequest(
|
||||
'A share handle does not deliver values',
|
||||
'events_kv_handle_no_values',
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1159,16 +1152,15 @@ const inlineKvValue = (value: unknown): { value: unknown } | undefined => {
|
||||
};
|
||||
|
||||
/**
|
||||
* The value rides only where the row asked for it and its holder owns the
|
||||
* namespace — never across a share handle, whose grant is to watch, not read.
|
||||
* The value rides only where the row asked for it. A share-handle row is no
|
||||
* exception: the owner minted the handle over that region, and the delivery
|
||||
* re-check that stops a revoked handle stops its values with it.
|
||||
*/
|
||||
const valueAsRowAskedFor = (
|
||||
row: DispatchSubscription,
|
||||
event: ProjectedKvEvent,
|
||||
): ProjectedKvEvent => {
|
||||
if (event.value === undefined) return event;
|
||||
if (row.includeValue === true && row.holderUserId === row.ownerUserId)
|
||||
return event;
|
||||
if (event.value === undefined || row.includeValue === true) return event;
|
||||
const { value: _value, ...withoutValue } = event;
|
||||
return withoutValue;
|
||||
};
|
||||
|
||||
@@ -102,7 +102,7 @@ A delivery names the key and not what it now holds, so a handler that needs the
|
||||
await puter.events.onLocal('kv:cart', ({ event }) => render(event.value), { includeValue: true });
|
||||
```
|
||||
|
||||
A value over **16 KB** serialized is not inlined: the event arrives without `value`, and you read the key as you would have anyway. An `expire` never carries one, since the value did not change. `includeValue` is accepted on `kv:` subjects only — anything else is refused with `invalid_include_value` — and not through a [share handle](#share-handle), whose grant is to watch a region rather than read it.
|
||||
A value over **16 KB** serialized is not inlined: the event arrives without `value`, and you read the key as you would have anyway. An `expire` never carries one, since the value did not change. `includeValue` is accepted on `kv:` subjects only — anything else is refused with `invalid_include_value`. It works through a [share handle](#share-handle) too, which is how a holder sees what was written in a region it cannot otherwise read.
|
||||
|
||||
Watching **another app's** key-value data takes the same consent as reading it: that app must not have opted out of data sharing, and the user must have granted your app `app-data:<appId>:kv:read`. It is checked when you subscribe and again on every delivery, so deliveries stop the moment either goes away. Where the feature is not enabled, a cross-app subject is refused with `events_cross_app_disabled`.
|
||||
|
||||
@@ -135,7 +135,7 @@ const { handle } = await res.json();
|
||||
await puter.events.onLocal(`kv:${handle}:*`, ({ event }) => render(event.key));
|
||||
```
|
||||
|
||||
The handle is the whole of what the holder learns: not whose data it is, not where in the namespace it sits, and not anything above the prefix it was granted on. Events name it too: `subject` and `key` on every delivery are relative to the handle, in the same grammar the subscription was written in. `kv:<handle>:messages:*` narrows to part of the shared region, and one handle per channel gives one subscription covering every key written in that channel. Values never cross a handle: `includeValue` on a handle subject is refused with `events_kv_handle_no_values`, because the grant behind it is to watch the region, not to read it.
|
||||
The handle is the whole of what the holder learns: not whose data it is, not where in the namespace it sits, and not anything above the prefix it was granted on. Events name it too: `subject` and `key` on every delivery are relative to the handle, in the same grammar the subscription was written in. `kv:<handle>:messages:*` narrows to part of the shared region, and one handle per channel gives one subscription covering every key written in that channel. Subscribe with `includeValue` and each delivery carries the new value as well — the holder has no other way to read the region, so this is how shared data reaches them, and it stops the moment the handle is revoked.
|
||||
|
||||
**Key layout is the access boundary.** A handle pins the prefix it was granted on, and nothing rewrites it afterwards: rename `workspace:<uuid>:` to `project:<uuid>:` and every handle already given out points at keys nothing writes any more. Grant on a **stable synthetic segment** — `workspace:<uuid>:`, `thread:<uuid>:` — rather than a semantic one like `acme-corp:` or `q3-planning:`, which is more likely to get renamed later.
|
||||
|
||||
@@ -195,7 +195,7 @@ A key-value change carries `key` where a filesystem change carries `uid` and `pa
|
||||
| `ts` | Number | As above. |
|
||||
| `seq` | Number | As above. |
|
||||
|
||||
Nothing else is included — in particular there is no field naming *who* made the change, because on a shared folder that would tell every subscriber who else is in there. The new **value** rides only where the subscription asked for it with `includeValue`, and only to the account whose namespace it is — never through a share handle — so a subscription never becomes a way to read data its holder could not read anyway.
|
||||
Nothing else is included — in particular there is no field naming *who* made the change, because on a shared folder that would tell every subscriber who else is in there. The new **value** rides only where the subscription asked for it with `includeValue`, and only while the delivery re-check still passes, so a revoked grant or handle stops the values with the events.
|
||||
|
||||
Emptying a whole store with [`puter.kv.flush()`](/KV/flush/) delivers nothing: no subject names "everything in this namespace went", and the keys a flush can enumerate are not reliably the keys it removed.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Called with a single `{ event }` object per delivery. `event.op === 'gap'` means
|
||||
|
||||
- `onError` (Function): Called with `{ message, code }` if the subscription lapses — the connection was lost and re-subscribing failed. The subscription is over at that point; call `onLocal()` again to resume. Without it, a lapse is reported on the console.
|
||||
- `timeout` (Number): How long to wait for the server to confirm the subscription, in milliseconds. Defaults to `30000`.
|
||||
- `includeValue` (Boolean): For a `kv:` subject, deliver the key's new value on every event as `event.value` — the written value on a `set`, `null` on a `del`, nothing on an `expire`. A value over 16 KB serialized is left out. Refused on a non-`kv:` subject and on a share handle.
|
||||
- `includeValue` (Boolean): For a `kv:` subject, deliver the key's new value on every event as `event.value` — the written value on a `set`, `null` on a `del`, nothing on an `expire`. A value over 16 KB serialized is left out. Refused on a non-`kv:` subject.
|
||||
|
||||
## Return value
|
||||
|
||||
@@ -57,7 +57,6 @@ The promise rejects with `{ message, code }`:
|
||||
| `invalid_kv_pattern` | A `kv:` subject has a `*` somewhere other than the end, or a `?`. |
|
||||
| `invalid_kv_handle_key` | A `kv:<handle>:…` subject names no key, or one that tries to leave the handle's granted region. |
|
||||
| `invalid_include_value` | `includeValue` is not a boolean, or was asked for on a subject that is not `kv:`. |
|
||||
| `events_kv_handle_no_values` | `includeValue` on a share-handle subject: a handle grants watching a region, not reading it. |
|
||||
| `events_cross_app_disabled` | The subject names another app's key-value data and that is not enabled here. |
|
||||
| `forbidden` | The target app does not share its data, or this app has not been granted `app-data:<appId>:kv:read` on it. |
|
||||
| `subject_does_not_exist` | The subject is not there, or this account cannot read it. |
|
||||
|
||||
@@ -28,7 +28,7 @@ puter.events.onPersistent(options)
|
||||
- `handler` (Function | String | Object): The handler source this subscription was written against. Sent as a **hash**, never as source: the subscription binds only if that hash matches what is published under `handlerName`, which is why `handlerName` is required alongside it. Accepts a function, a source string, or `{ file: '~/AppData/…/handler.js' }`.
|
||||
- `context` (Object): Values the handler needs, delivered to it as a frozen `ctx`. **Capped at 4 KB serialized** — see below.
|
||||
- `expiresAt` (Number | String): When the subscription ends by itself — unix seconds or an ISO-8601 string, and it has to be in the future.
|
||||
- `includeValue` (Boolean): For a `kv:` subject, deliver the key's new value on every event as `event.value` — the written value on a `set`, `null` on a `del`, nothing on an `expire`. A value over 16 KB serialized is left out. Refused on a non-`kv:` subject and on a share handle.
|
||||
- `includeValue` (Boolean): For a `kv:` subject, deliver the key's new value on every event as `event.value` — the written value on a `set`, `null` on a `del`, nothing on an `expire`. A value over 16 KB serialized is left out. Refused on a non-`kv:` subject.
|
||||
|
||||
## Background delivery takes the user's consent
|
||||
|
||||
@@ -115,7 +115,6 @@ The promise rejects with `{ message, code }`:
|
||||
| `invalid_targets` | A target outside `socket`/`worker`/`push`, `push` on a `single` subscription (which may not target it), or `worker` on a subscription with no app. |
|
||||
| `invalid_expires_at` | `expiresAt` is not a future time. |
|
||||
| `invalid_include_value` | `includeValue` is not a boolean, or was asked for on a subject that is not `kv:`. |
|
||||
| `events_kv_handle_no_values` | `includeValue` on a share-handle subject: a handle grants watching a region, not reading it. |
|
||||
| `subject_does_not_exist` | The subject is not there, or this account cannot read it. |
|
||||
| `events_subscription_limit` | This account already holds the maximum number of persistent subscriptions. |
|
||||
| `events_durable_requires_account` | Called from a temporary (anonymous) account, which gets session subscriptions only. |
|
||||
|
||||
@@ -292,7 +292,7 @@ Deleting the node a subscription is anchored on ends it too, unless the subject
|
||||
|
||||
Match patterns are compiled once when you subscribe and are capped at **256 characters** and **16 segments**, with **one `*` per segment** and **one `**` per pattern**; anything past that is rejected with `invalid_subject_pattern`. `**` crosses directories and costs no more than `*`.
|
||||
|
||||
A `kv:` subject is indexed on the first **6** `:`-segments, or **160 bytes**, of its key — whichever comes first; past that the remainder becomes a match pattern, which is subject to the caps above. A key-value subject matches its key exactly unless it ends in `*`, and a `*` anywhere else — or a `?` — is rejected with `invalid_kv_pattern`. Watching another app's key-value data is refused with `events_cross_app_disabled` where that is not enabled, and otherwise takes the same consent as reading it. The app slot names an app uid and is capped at **40 characters**; past that the subscription is refused with `events_value_too_large`. A subscription made with `includeValue` is handed the key's new value on each delivery, up to **16 KB** serialized; a larger value is left out of the event and the subscriber reads the key back. Values never ride through a share handle, which grants watching a region and not reading it — `includeValue` on one is refused with `events_kv_handle_no_values`.
|
||||
A `kv:` subject is indexed on the first **6** `:`-segments, or **160 bytes**, of its key — whichever comes first; past that the remainder becomes a match pattern, which is subject to the caps above. A key-value subject matches its key exactly unless it ends in `*`, and a `*` anywhere else — or a `?` — is rejected with `invalid_kv_pattern`. Watching another app's key-value data is refused with `events_cross_app_disabled` where that is not enabled, and otherwise takes the same consent as reading it. The app slot names an app uid and is capped at **40 characters**; past that the subscription is refused with `events_value_too_large`. A subscription made with `includeValue` is handed the key's new value on each delivery, up to **16 KB** serialized; a larger value is left out of the event and the subscriber reads the key back. A share-handle subscription may ask for values too, and receives them for as long as the handle stands.
|
||||
|
||||
**Deliveries are coalesced over 250 ms per subject.** A multipart upload, a save loop, or a recursive delete is one thing the user did, and it arrives as one event carrying the newest state rather than as one event per write. Two different files in the same window are two deliveries.
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@
|
||||
* @property {number} [timeout] How long to wait for the server to answer
|
||||
* `subscribe`, in milliseconds. Default `30000`.
|
||||
* @property {boolean} [includeValue] `kv:` subjects only: deliver the key's new
|
||||
* value on each event as `event.value`. Refused on a share handle.
|
||||
* value on each event as `event.value`.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -195,7 +195,7 @@
|
||||
* itself — unix seconds or an ISO-8601 string, and it has to be in the
|
||||
* future.
|
||||
* @property {boolean} [includeValue] `kv:` subjects only: deliver the key's new
|
||||
* value on each event as `event.value`. Refused on a share handle.
|
||||
* value on each event as `event.value`.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user