mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-21 20:55:55 +00:00
fix: sharing, events and worker-credential authorization hardening (#3855)
- PUT-1800: gate `createWorkerSessionToken` on actor type, so an app or an access token can no longer mint an app-less, root-shaped worker session; `WorkerDriver` binds on `effectiveApp` instead of `app`. - PUT-1799: add `isAccountContext` and read it where "no app" was being read as "the account" — handler publish, events-worker listing, kv handle mint/revoke/list. A scoped API token is no longer an account session. - PUT-1802: re-authorize a durable row before its backlog drains, settling it permanently when the grant is gone. Covers an ancestor-level unshare, which the revoke settle deliberately leaves to the delivery re-check. - PUT-1803: mask the owner's absolute path out of deliveries and subscription anchors on a foreign node, the way every FS surface already does. - PUT-1804: let a revoke reach rows already suspended for a resumable reason, re-stamping them so a resume cannot hand over the held backlog. - PUT-1805: apply the subscribe path's audience gate to `/events/fetch` before the query, so a cursor can no longer count and name invisible notifications. - PUT-1807: refuse `mode: 'manage'` from any actor holding an app — inside its own AppData the ACL short-circuit would otherwise supply the reach. - PUT-1808: take the sending peer from the verified signature header rather than the request body. - PUT-1810: re-base a kv share-handle row's stored match filter on the handle, so the owner's absolute key prefix stays hidden. - PUT-1814: escape LIKE wildcards and anchor the issuer-prefix queries on a segment; anchor `manage:` stripping; reject a backslash in a share prefix; assert a resolved actor in `subscribeDurable`. - PUT-1815: bound the char/varchar columns behind `event_subscriptions` and `kv_share_handles` at the store layer.
This commit is contained in:
@@ -47,8 +47,7 @@ export class BroadcastController extends PuterController {
|
||||
@Post('/webhook', { subdomain: '*' })
|
||||
async webhook(req: Request, res: Response): Promise<void> {
|
||||
const broadcast = this.services.broadcast as unknown as
|
||||
| BroadcastService
|
||||
| undefined;
|
||||
BroadcastService | undefined;
|
||||
if (!broadcast) {
|
||||
res.status(503).json({
|
||||
error: { message: 'Broadcast service not registered' },
|
||||
@@ -80,8 +79,7 @@ export class BroadcastController extends PuterController {
|
||||
@Post('/events', { subdomain: '*' })
|
||||
async events(req: Request, res: Response): Promise<void> {
|
||||
const broadcast = this.services.broadcast as unknown as
|
||||
| BroadcastService
|
||||
| undefined;
|
||||
BroadcastService | undefined;
|
||||
if (!broadcast) {
|
||||
res.status(503).json({
|
||||
error: { message: 'Broadcast service not registered' },
|
||||
@@ -106,6 +104,7 @@ export class BroadcastController extends PuterController {
|
||||
|
||||
const reply = await this.services.eventForward.receive(
|
||||
(req.body ?? {}) as ForwardBatch,
|
||||
verified.peerId ?? '',
|
||||
);
|
||||
res.status(200).json({ ok: true, ...reply });
|
||||
}
|
||||
|
||||
@@ -136,6 +136,21 @@ export const isAccessTokenActor = (
|
||||
return !!actor?.accessToken;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether this actor holds its user's own account reach: a plain session, or a
|
||||
* full-access token, which carries exactly that.
|
||||
*
|
||||
* Read this — not `effectiveApp === null` — wherever "no app" is about to be
|
||||
* read as "the account". A scoped access token carries no app either, so that
|
||||
* test admits a credential confined to a subset of its issuer's permissions to
|
||||
* surfaces meant for the account itself. Unresolved answers no, so an actor
|
||||
* that skipped `makeActor` is denied rather than admitted.
|
||||
*/
|
||||
export const isAccountContext = (actor: Actor | undefined | null): boolean => {
|
||||
if (!actor || actor.effectiveApp !== null) return false;
|
||||
return !actor.accessToken || actor.accessToken.fullAccess === true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stable identifier for an actor. Used as a cache key (e.g., permission scan
|
||||
* cache) and for cycle detection.
|
||||
|
||||
@@ -331,7 +331,10 @@ export class WorkerDriver extends PuterDriver {
|
||||
if (!skipAdmission) this.#requireVerified(actor);
|
||||
const workerName = String(args.workerName ?? '').toLowerCase();
|
||||
const filePath = String(args.filePath ?? '');
|
||||
const appId = args.appId || actor.app?.uid;
|
||||
// `effectiveApp`, not `app`: a token an app issued carries no app of
|
||||
// its own, and reading it as "no app" is what drops the deploy into
|
||||
// the account-scoped branch below.
|
||||
const appId = args.appId || actor.effectiveApp?.uid;
|
||||
if (!workerName)
|
||||
throw new HttpError(400, 'Missing `workerName`', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -421,6 +424,7 @@ export class WorkerDriver extends PuterDriver {
|
||||
legacyCode: 'internal_error',
|
||||
});
|
||||
const session = await this.services.auth.createWorkerSessionToken(
|
||||
actor,
|
||||
userRow,
|
||||
workerName,
|
||||
);
|
||||
@@ -1139,6 +1143,7 @@ export class WorkerDriver extends PuterDriver {
|
||||
} else {
|
||||
const session =
|
||||
await this.services.auth.createWorkerSessionToken(
|
||||
ownerActor,
|
||||
ownerUser,
|
||||
workerName,
|
||||
);
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
@@ -1040,9 +1040,12 @@ describe('AuthService (integration)', () => {
|
||||
const before = Math.floor(Date.now() / 1000);
|
||||
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const { session, token, gui_token } =
|
||||
await authService.createWorkerSessionToken(user, workerName, {
|
||||
user_agent: 'worker-agent',
|
||||
});
|
||||
await authService.createWorkerSessionToken(
|
||||
makeActor({ user }),
|
||||
user,
|
||||
workerName,
|
||||
{ user_agent: 'worker-agent' },
|
||||
);
|
||||
|
||||
const row = (await server.stores.session.getByUuid(
|
||||
(session as { uuid: string }).uuid,
|
||||
@@ -1067,14 +1070,56 @@ describe('AuthService (integration)', () => {
|
||||
expect(decodeAuth(gui_token).worker_name).toBe(workerName);
|
||||
});
|
||||
|
||||
it('createWorkerSessionToken refuses every delegated credential', async () => {
|
||||
// The token it mints is `type: 'session'` with no app, so it walks
|
||||
// past the gates that keep apps and tokens out of account
|
||||
// management — including the one refusing an access token the
|
||||
// right to mint another.
|
||||
const user = await makeUser();
|
||||
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const delegated: Actor[] = [
|
||||
makeActor({ user, app: { uid: 'app-caller' } }),
|
||||
makeActor({
|
||||
user,
|
||||
accessToken: {
|
||||
uid: 'tok-scoped',
|
||||
issuer: makeActor({ user }),
|
||||
authorized: null,
|
||||
fullAccess: false,
|
||||
},
|
||||
}),
|
||||
makeActor({
|
||||
user,
|
||||
accessToken: {
|
||||
uid: 'tok-pat',
|
||||
issuer: makeActor({ user }),
|
||||
authorized: null,
|
||||
fullAccess: true,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
for (const actor of delegated) {
|
||||
await expect(
|
||||
authService.createWorkerSessionToken(
|
||||
actor,
|
||||
user,
|
||||
workerName,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
}
|
||||
});
|
||||
|
||||
it('createWorkerSessionToken is idempotent on (user, worker_name) — redeploys reuse the row', async () => {
|
||||
const user = await makeUser();
|
||||
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const a = await authService.createWorkerSessionToken(
|
||||
makeActor({ user }),
|
||||
user,
|
||||
workerName,
|
||||
);
|
||||
const b = await authService.createWorkerSessionToken(
|
||||
makeActor({ user }),
|
||||
user,
|
||||
workerName,
|
||||
);
|
||||
@@ -1086,10 +1131,12 @@ describe('AuthService (integration)', () => {
|
||||
it('createWorkerSessionToken with different worker_names mints distinct rows for the same user', async () => {
|
||||
const user = await makeUser();
|
||||
const a = await authService.createWorkerSessionToken(
|
||||
makeActor({ user }),
|
||||
user,
|
||||
`wk-${Math.random().toString(36).slice(2, 8)}-a`,
|
||||
);
|
||||
const b = await authService.createWorkerSessionToken(
|
||||
makeActor({ user }),
|
||||
user,
|
||||
`wk-${Math.random().toString(36).slice(2, 8)}-b`,
|
||||
);
|
||||
@@ -1101,7 +1148,11 @@ describe('AuthService (integration)', () => {
|
||||
it('createWorkerSessionToken rejects an empty workerName (400)', async () => {
|
||||
const user = await makeUser();
|
||||
await expect(
|
||||
authService.createWorkerSessionToken(user, ''),
|
||||
authService.createWorkerSessionToken(
|
||||
makeActor({ user }),
|
||||
user,
|
||||
'',
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
@@ -1453,7 +1504,11 @@ describe('AuthService (integration)', () => {
|
||||
const user = await makeUser();
|
||||
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const { token, session } =
|
||||
await authService.createWorkerSessionToken(user, workerName);
|
||||
await authService.createWorkerSessionToken(
|
||||
makeActor({ user }),
|
||||
user,
|
||||
workerName,
|
||||
);
|
||||
const sessionUuid = (session as { uuid: string }).uuid;
|
||||
|
||||
await authService.revokeSession(sessionUuid);
|
||||
@@ -1473,6 +1528,7 @@ describe('AuthService (integration)', () => {
|
||||
const user = await makeUser();
|
||||
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const first = await authService.createWorkerSessionToken(
|
||||
makeActor({ user }),
|
||||
user,
|
||||
workerName,
|
||||
);
|
||||
@@ -1480,6 +1536,7 @@ describe('AuthService (integration)', () => {
|
||||
await authService.revokeSession(firstUuid);
|
||||
|
||||
const second = await authService.createWorkerSessionToken(
|
||||
makeActor({ user }),
|
||||
user,
|
||||
workerName,
|
||||
);
|
||||
@@ -1532,7 +1589,11 @@ describe('AuthService (integration)', () => {
|
||||
const user = await makeUser();
|
||||
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const { token, session } =
|
||||
await authService.createWorkerSessionToken(user, workerName);
|
||||
await authService.createWorkerSessionToken(
|
||||
makeActor({ user }),
|
||||
user,
|
||||
workerName,
|
||||
);
|
||||
const sessionUuid = (session as { uuid: string }).uuid;
|
||||
|
||||
await authService.removeSessionByToken(token);
|
||||
|
||||
@@ -272,6 +272,7 @@ export class AuthService extends PuterService {
|
||||
* session from a user-driven one without a DB round-trip.
|
||||
*/
|
||||
async createWorkerSessionToken(
|
||||
actor: Actor,
|
||||
user: UserRow,
|
||||
workerName: string,
|
||||
meta: Record<string, unknown> = {},
|
||||
@@ -280,6 +281,7 @@ export class AuthService extends PuterService {
|
||||
token: string;
|
||||
gui_token: string;
|
||||
}> {
|
||||
this.#assertWorkerSessionMintAllowed(actor);
|
||||
if (!workerName) {
|
||||
throw new HttpError(400, 'Missing `workerName`', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -385,6 +387,22 @@ export class AuthService extends PuterService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An app-less worker session token carries the account's own reach: it is a
|
||||
* `type: 'session'` credential with no `app_uid`, so it passes the gates
|
||||
* that keep apps and tokens out of account management. Only an actor that
|
||||
* already holds that reach may mint one — a delegated credential binds its
|
||||
* worker to an app instead, through `createWorkerAppToken`.
|
||||
*/
|
||||
#assertWorkerSessionMintAllowed(actor: Actor): void {
|
||||
if (!actor.effectiveApp && !actor.accessToken) return;
|
||||
throw new HttpError(
|
||||
403,
|
||||
'A delegated credential must bind its worker to an app',
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker-token variant of `#assertAppDelegationAllowed`, with one extra
|
||||
* allowance: an app may bind a worker to an app it created for this same
|
||||
|
||||
@@ -46,6 +46,11 @@ interface IncomingResult {
|
||||
message?: string;
|
||||
/** Optional informational payload to include when ok===true. */
|
||||
info?: Record<string, unknown>;
|
||||
/**
|
||||
* The peer whose secret actually signed the request. The only trustworthy
|
||||
* name for the sender — a body field naming one is the sender's own claim.
|
||||
*/
|
||||
peerId?: string;
|
||||
}
|
||||
|
||||
interface IncomingHeaders {
|
||||
@@ -320,7 +325,7 @@ export class BroadcastService extends PuterService {
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
return { ok: true, peerId };
|
||||
}
|
||||
|
||||
#pubsubFanout(key: string, data: unknown, meta: object): void {
|
||||
|
||||
@@ -522,11 +522,15 @@ export class EventForwardService extends PuterService {
|
||||
* Deliveries keep the batch's order, so a subscription's events reach its
|
||||
* socket as emitted; settles are each a row read, a queue write and a
|
||||
* drain, so they run under a concurrency bound instead.
|
||||
*
|
||||
* `from` is the sender the signature actually proved, not the one the body
|
||||
* names: peers that share a secret would otherwise be able to write each
|
||||
* other's region into this region's remote-watch index.
|
||||
*/
|
||||
async receive(batch: ForwardBatch): Promise<ForwardReply> {
|
||||
async receive(batch: ForwardBatch, from: string): Promise<ForwardReply> {
|
||||
const items = batch.items ?? [];
|
||||
forwardReceived.add(items.length, {
|
||||
from: batch.from ?? 'unknown',
|
||||
from: from || 'unknown',
|
||||
to: this.region,
|
||||
});
|
||||
|
||||
@@ -548,7 +552,7 @@ export class EventForwardService extends PuterService {
|
||||
await this.stores.eventSubscription.noteRemoteWatch(
|
||||
item.userId,
|
||||
item.token,
|
||||
batch.from,
|
||||
from,
|
||||
item.op,
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -578,7 +582,7 @@ export class EventForwardService extends PuterService {
|
||||
const { matched, tokens } =
|
||||
await this.services.events.dispatchForwarded(item);
|
||||
sessionForward.add(1, {
|
||||
from: batch.from,
|
||||
from,
|
||||
result: matched ? 'matched' : 'no-rows',
|
||||
});
|
||||
if (!matched)
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
import {
|
||||
assertResolvedActor,
|
||||
isAccessTokenActor,
|
||||
isAccountContext,
|
||||
makeActor,
|
||||
userRelatedActor,
|
||||
type Actor,
|
||||
@@ -101,6 +102,7 @@ import {
|
||||
} from '../../util/pagination.js';
|
||||
import type { AclMode, ResourceDescriptor } from '../acl/ACLService.js';
|
||||
import { resolveNode } from '../fs/resolveNode.js';
|
||||
import { maskUnderAnchor } from '../fs/sharePathMask.js';
|
||||
import { assertActorHasCredits } from '../metering/enforcement.js';
|
||||
import {
|
||||
canViewNotification,
|
||||
@@ -175,6 +177,7 @@ import {
|
||||
type MatchSpec,
|
||||
type NotifEventContext,
|
||||
type ProjectedEvent,
|
||||
type ProjectedFsEvent,
|
||||
type ProjectedKvEvent,
|
||||
type ProjectedNotifEvent,
|
||||
type SubjectSpec,
|
||||
@@ -197,14 +200,20 @@ import {
|
||||
KV_MATCH_SEPARATOR,
|
||||
NOTIF_MATCH_SEPARATOR,
|
||||
fsAnchorToken,
|
||||
isFsToken,
|
||||
isKvToken,
|
||||
isNotifToken,
|
||||
kvHandleFromSubject,
|
||||
parseSubject,
|
||||
type FsOp,
|
||||
type ParsedSubject,
|
||||
type SubjectOp,
|
||||
} from './subjects.js';
|
||||
import { backlogPolicyFor, isResumable } from './suspension.js';
|
||||
import {
|
||||
backlogPolicyFor,
|
||||
isResumable,
|
||||
RESUMABLE_REASONS,
|
||||
} from './suspension.js';
|
||||
import { singleAttempt } from './metrics.js';
|
||||
import type {
|
||||
EventsInvokeTransport,
|
||||
@@ -866,18 +875,30 @@ const errorAck = (err: unknown): VerbAck<never> => {
|
||||
/**
|
||||
* A share-handle row's real anchor is the owner's namespace and the absolute
|
||||
* granted prefix — neither of which its holder was ever told. The handle is the
|
||||
* only anchor its holder may see, mirroring `#asRowAddressesIt`.
|
||||
* only anchor its holder may see, mirroring `#asRowAddressesIt`, and the stored
|
||||
* `match` is re-based on it for the same reason: it is composed onto the
|
||||
* granted prefix, so returning it verbatim hands back the owner's key layout.
|
||||
*
|
||||
* A row on someone else's node is addressed by uid alone. The anchor's real
|
||||
* path names every directory above it, which is exactly what the FS surfaces
|
||||
* mask out of a recipient's view.
|
||||
*/
|
||||
const toView = (sub: DispatchSubscription): SubscriptionView => {
|
||||
const handle = kvHandleFromSubject(sub.subject);
|
||||
const foreign = sub.ownerUserId !== sub.holderUserId;
|
||||
return {
|
||||
subId: sub.subId,
|
||||
subject: sub.subject,
|
||||
anchor:
|
||||
handle !== null
|
||||
? { uid: handle, path: '' }
|
||||
: { uid: sub.anchorUid, path: sub.anchorPath },
|
||||
match: sub.match,
|
||||
: { uid: sub.anchorUid, path: foreign ? '' : sub.anchorPath },
|
||||
// `null` where the pattern does not sit under the grant: no filter is
|
||||
// the wrong answer to show, but it is not the owner's prefix.
|
||||
match:
|
||||
handle !== null && sub.match !== null
|
||||
? relativeToKvShareRoot(sub.permission, sub.match)
|
||||
: sub.match,
|
||||
op: sub.op,
|
||||
targets: sub.targets ?? SESSION_TARGETS,
|
||||
};
|
||||
@@ -1627,6 +1648,9 @@ export class EventsService extends PuterService {
|
||||
if (!this.enabled) throw disabled();
|
||||
const holderUserId = actor.user?.id;
|
||||
if (holderUserId === undefined) throw disabled();
|
||||
// As the session verb does: an unresolved `effectiveApp` would land an
|
||||
// app's row in the account's scope.
|
||||
assertResolvedActor(actor);
|
||||
|
||||
await this.#spendCallBudget(holderUserId);
|
||||
|
||||
@@ -1774,6 +1798,25 @@ export class EventsService extends PuterService {
|
||||
appUid: actor.effectiveApp?.uid ?? null,
|
||||
});
|
||||
|
||||
// The same audience gate the subscribe path applies, asked before the
|
||||
// query rather than over its rows. Filtering afterwards empties `items`
|
||||
// but still cuts the cursor from the unfiltered page, so walking it
|
||||
// counts and names rows this actor may not see. Answered as an empty
|
||||
// page, not a refusal: which notifications exist is not this surface's
|
||||
// to say.
|
||||
if (scope.appUid !== undefined) {
|
||||
const ownsApp =
|
||||
scope.appUid !== null && scope.audience === 'developer'
|
||||
? await this.#recipientOwnsApp(user.id, scope.appUid)
|
||||
: false;
|
||||
const visible = canViewNotification(
|
||||
{ audience: scope.audience, appUid: scope.appUid },
|
||||
actor,
|
||||
{ recipientOwnsApp: ownsApp },
|
||||
);
|
||||
if (!visible) return { items: [] };
|
||||
}
|
||||
|
||||
const asked = Math.floor(Number(request.limit));
|
||||
const limit = Math.min(
|
||||
Number.isFinite(asked) && asked > 0
|
||||
@@ -1796,7 +1839,7 @@ export class EventsService extends PuterService {
|
||||
const last = page[page.length - 1];
|
||||
|
||||
return {
|
||||
items: visible.map((row, i) => projectNotifRow(row, user.uuid, i)),
|
||||
items: visible.map((row, i) => projectNotifRow(row, user.uuid!, i)),
|
||||
...(rows.length > limit && last
|
||||
? { cursor: encodeCursor({ id: Number(last.id) }) }
|
||||
: {}),
|
||||
@@ -2038,7 +2081,7 @@ export class EventsService extends PuterService {
|
||||
deployable: boolean;
|
||||
}> {
|
||||
if (!this.enabled) throw disabled();
|
||||
if (actor.effectiveApp !== null) throw eventsWorkerOwnerOnly();
|
||||
if (!isAccountContext(actor)) throw eventsWorkerOwnerOnly();
|
||||
const ownerUserId = actor.user?.id;
|
||||
if (ownerUserId === undefined) throw disabled();
|
||||
|
||||
@@ -2143,6 +2186,10 @@ export class EventsService extends PuterService {
|
||||
// surface bounded on the app it is acting as.
|
||||
const app = actor.effectiveApp;
|
||||
if (app === undefined) throw handleOwnerOnly();
|
||||
// An account's surface is the account's, and a token scoped to less
|
||||
// than its issuer holds is not the account. A token an app issued is
|
||||
// refused by the delegation check below, which has its own answer.
|
||||
if (app === null && !isAccountContext(actor)) throw handleOwnerOnly();
|
||||
|
||||
// The budget is the user's, so an app spends its user's slots rather
|
||||
// than a machine-rate allowance of its own.
|
||||
@@ -2278,9 +2325,10 @@ export class EventsService extends PuterService {
|
||||
const app = actor.effectiveApp;
|
||||
if (app === undefined) throw handleOwnerOnly();
|
||||
// A delegation is the app's to hold, not to pass on, so a token an app
|
||||
// issued is refused here as it is on the mint path. A user's own token
|
||||
// carries no app and acts for the user.
|
||||
if (app !== null && isAccessTokenActor(actor)) throw handleOwnerOnly();
|
||||
// issued is refused here as it is on the mint path — and a user's own
|
||||
// token acts for the user only when it carries the user's whole reach.
|
||||
if (isAccessTokenActor(actor) && !isAccountContext(actor))
|
||||
throw handleOwnerOnly();
|
||||
|
||||
await this.#spendHandleBudget(owner.id);
|
||||
|
||||
@@ -2362,7 +2410,8 @@ export class EventsService extends PuterService {
|
||||
if (owner?.id === undefined) throw disabled();
|
||||
const app = actor.effectiveApp;
|
||||
if (app === undefined) throw handleOwnerOnly();
|
||||
if (app !== null && isAccessTokenActor(actor)) throw handleOwnerOnly();
|
||||
if (isAccessTokenActor(actor) && !isAccountContext(actor))
|
||||
throw handleOwnerOnly();
|
||||
|
||||
// An app sees its own namespace and nothing else; an account session
|
||||
// sees across apps, which is what makes the account the surface for a
|
||||
@@ -2496,11 +2545,12 @@ export class EventsService extends PuterService {
|
||||
async #suspend(
|
||||
rows: readonly DurableSubscription[],
|
||||
reason: SuspendedReason,
|
||||
opts: { override?: readonly SuspendedReason[] } = {},
|
||||
): Promise<DurableSubscription[]> {
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const { suspended, bumps } =
|
||||
await this.stores.durableSubscription.suspend(rows, reason);
|
||||
await this.stores.durableSubscription.suspend(rows, reason, opts);
|
||||
const policy = backlogPolicyFor(reason);
|
||||
for (const row of suspended) {
|
||||
try {
|
||||
@@ -2809,6 +2859,12 @@ export class EventsService extends PuterService {
|
||||
// Unresolved is not "no app": reading it that way is what would let an
|
||||
// app token publish into a namespace it never named.
|
||||
if (acting === undefined) throw handlerAppForbidden();
|
||||
// Naming an app is the account's to do, and a scoped token carries no
|
||||
// app either — without this, a token minted for one narrow purpose
|
||||
// replaces the handler code of every app its user owns, and that code
|
||||
// then runs holding each subscriber's own credential.
|
||||
if (acting === null && !isAccountContext(actor))
|
||||
throw handlerAppForbidden();
|
||||
|
||||
const named = parseAppUid(requested);
|
||||
if (acting && named !== null && named !== acting.uid)
|
||||
@@ -3197,7 +3253,7 @@ export class EventsService extends PuterService {
|
||||
);
|
||||
|
||||
const anchor = resolveKvAnchor(parsed, {
|
||||
userUuid: user.uuid,
|
||||
userUuid: user.uuid!,
|
||||
appUid: actor.effectiveApp?.uid ?? null,
|
||||
});
|
||||
|
||||
@@ -3219,7 +3275,7 @@ export class EventsService extends PuterService {
|
||||
path: anchor.prefix,
|
||||
match: anchor.match,
|
||||
op: null,
|
||||
ownerUserId: user.id,
|
||||
ownerUserId: user.id!,
|
||||
// The column wants a mode; a KV row's re-check is the cross-app
|
||||
// gate rather than an ACL reading, so nothing reads this back.
|
||||
permission: SUBSCRIBE_MODE,
|
||||
@@ -3708,7 +3764,7 @@ export class EventsService extends PuterService {
|
||||
*/
|
||||
async #notifStillAuthorized(
|
||||
rows: DispatchSubscription[],
|
||||
context: NotifEventContext,
|
||||
context: Pick<NotifEventContext, 'audience' | 'appUid' | 'userId'>,
|
||||
): Promise<DispatchSubscription[]> {
|
||||
if (rows.length === 0) return rows;
|
||||
|
||||
@@ -3805,7 +3861,7 @@ export class EventsService extends PuterService {
|
||||
// A session row is addressed at one connection, which is the
|
||||
// one that made it and is therefore here.
|
||||
remote: row.socketId === undefined,
|
||||
worker: this.#workerInvocation(row, event),
|
||||
worker: this.#workerInvocation(row, event)!,
|
||||
meter: meterFor(row),
|
||||
// `broadcast` is one send per delivery — no retry to dedup.
|
||||
bill: true,
|
||||
@@ -3851,7 +3907,11 @@ export class EventsService extends PuterService {
|
||||
): P | null {
|
||||
// Asked of every delivery, so the families that can never answer are
|
||||
// turned away on a token comparison rather than a subject parse.
|
||||
if (!isKvToken(row.token)) return event;
|
||||
if (!isKvToken(row.token)) {
|
||||
if (row.ownerUserId === row.holderUserId) return event;
|
||||
if (!isFsToken(row.token)) return event;
|
||||
return this.#asRecipientAddressesIt(row, event);
|
||||
}
|
||||
const handle = kvHandleFromSubject(row.subject);
|
||||
if (handle === null) return event;
|
||||
|
||||
@@ -3863,6 +3923,36 @@ export class EventsService extends PuterService {
|
||||
return { ...event, subject: `kv:${handle}:${key}`, key };
|
||||
}
|
||||
|
||||
/**
|
||||
* A delivery on someone else's node, addressed the way every FS surface
|
||||
* addresses one: the anchor stands in for everything above it, so the
|
||||
* folders the owner keeps it in — and what sits beside it — stay theirs.
|
||||
*
|
||||
* `from` on a move is masked the same way. A row anchored on the node
|
||||
* itself is told where it went, and that can be somewhere the holder was
|
||||
* never granted anything at all.
|
||||
*/
|
||||
#asRecipientAddressesIt<P extends ProjectedEvent>(
|
||||
row: DispatchSubscription,
|
||||
event: P,
|
||||
): P {
|
||||
const fs = event as unknown as ProjectedFsEvent;
|
||||
if (typeof fs.path !== 'string') return event;
|
||||
const anchor = { uid: row.anchorUid, path: row.anchorPath };
|
||||
return {
|
||||
...event,
|
||||
path: maskUnderAnchor(anchor, { path: fs.path, uid: fs.uid }),
|
||||
...(typeof fs.from === 'string'
|
||||
? {
|
||||
from: maskUnderAnchor(anchor, {
|
||||
path: fs.from,
|
||||
uid: fs.uid,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Op filter first — a comparison, where the glob is not. */
|
||||
#passes(
|
||||
row: DispatchSubscription,
|
||||
@@ -4158,9 +4248,13 @@ export class EventsService extends PuterService {
|
||||
);
|
||||
}
|
||||
|
||||
// Suspended rows included: the three resumable reasons hold a backlog,
|
||||
// and one skipped here comes back in service on the next resume and
|
||||
// hands over everything queued after the revocation.
|
||||
const held = await this.stores.durableSubscription.listActiveForHolder(
|
||||
revocation.holderUserId,
|
||||
revocation.appUid,
|
||||
{ includeSuspended: true },
|
||||
);
|
||||
const settling = await this.#leftSettling(held, revocation.permission);
|
||||
if (settling.length === 0) return 0;
|
||||
@@ -4169,7 +4263,9 @@ export class EventsService extends PuterService {
|
||||
// at once: it holds the paths of a resource its holder has just lost
|
||||
// the right to see, and keeping it for a resume that by design never
|
||||
// comes turns a revocation into a delayed disclosure.
|
||||
const suspended = await this.#suspend(settling, 'permission_revoked');
|
||||
const suspended = await this.#suspend(settling, 'permission_revoked', {
|
||||
override: RESUMABLE_REASONS,
|
||||
});
|
||||
await this.#notifyEnded(suspended, 'permission_revoked');
|
||||
return suspended.length;
|
||||
}
|
||||
@@ -4332,6 +4428,26 @@ export class EventsService extends PuterService {
|
||||
|
||||
/** Whether a row's holder can still reach its anchor, asked fresh. */
|
||||
async #anchorStillReachable(row: DurableSubscription): Promise<boolean> {
|
||||
// On the token, like the families below it: the stored subject parses
|
||||
// only because a subscribe validated it, and a settle must not start
|
||||
// throwing on a row it cannot read.
|
||||
if (isNotifToken(row.token)) {
|
||||
const actor = await resolveGrantActor(row, this.#aclDeps());
|
||||
if (!actor?.user.uuid) return false;
|
||||
const anchor = resolveNotifAnchor(parseSubject(row.subject), {
|
||||
userUuid: actor.user.uuid,
|
||||
appUid: row.appUid,
|
||||
});
|
||||
return (
|
||||
(
|
||||
await this.#notifStillAuthorized([row], {
|
||||
audience: anchor.audience,
|
||||
appUid: anchor.appScoped ? anchor.ref : null,
|
||||
userId: row.holderUserId,
|
||||
})
|
||||
).length > 0
|
||||
);
|
||||
}
|
||||
if (kvHandleFromSubject(row.subject) !== null)
|
||||
return this.#kvShareHolds(row);
|
||||
if (isKvToken(row.token)) {
|
||||
@@ -4622,7 +4738,7 @@ export class EventsService extends PuterService {
|
||||
event,
|
||||
);
|
||||
this.#reportShed(shed);
|
||||
await this.#drain(row);
|
||||
await this.#drain(row, { justAuthorized: true });
|
||||
} catch (err) {
|
||||
this.#enqueueFailed(row, err);
|
||||
}
|
||||
@@ -4637,8 +4753,10 @@ export class EventsService extends PuterService {
|
||||
row: DispatchSubscription,
|
||||
// Only the sweeper defers: it alone reads the pending index in score
|
||||
// order, so only it may rewrite a score without starving what is behind.
|
||||
opts?: { deferWhenBusy?: boolean },
|
||||
opts?: { deferWhenBusy?: boolean; justAuthorized?: boolean },
|
||||
): Promise<number> {
|
||||
if (!opts?.justAuthorized && !(await this.#stillOwed(row))) return 0;
|
||||
|
||||
let handed = 0;
|
||||
for (let pass = 0; pass < PENDING_DRAIN_BATCH; pass++) {
|
||||
const claimed = await this.stores.pendingDelivery.claim(row.subId);
|
||||
@@ -4650,6 +4768,24 @@ export class EventsService extends PuterService {
|
||||
await this.stores.pendingDelivery.defer(row.subId);
|
||||
return handed;
|
||||
}
|
||||
// A generic mailbox slice can contain several apps; ownership
|
||||
// must still hold for each queued developer notification.
|
||||
if (
|
||||
'audience' in claimed.event &&
|
||||
(
|
||||
await this.#notifStillAuthorized([row], {
|
||||
audience: claimed.event.audience,
|
||||
appUid: claimed.event.appUid,
|
||||
userId: row.holderUserId,
|
||||
})
|
||||
).length === 0
|
||||
) {
|
||||
await this.stores.pendingDelivery.settle(
|
||||
row.subId,
|
||||
claimed.entryId,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
handed++;
|
||||
// Anything still holding the lease is the next consumer's answer to
|
||||
// give, so this pass is over.
|
||||
@@ -4658,6 +4794,28 @@ export class EventsService extends PuterService {
|
||||
return handed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a queued backlog may still be handed over.
|
||||
*
|
||||
* Nothing between the claim and the socket asks this — and the revoke
|
||||
* settle deliberately leaves a grant withdrawn on an _ancestor_ of an
|
||||
* anchor to the delivery re-check, which this path is. Without it the queue
|
||||
* keeps draining for the whole backlog TTL after an unshare.
|
||||
*
|
||||
* Asked once per drain rather than per delivery: the whole backlog stands
|
||||
* on the one grant. A row that has lost it is settled the way the revoke
|
||||
* settle would have — permanently, and taking the backlog with it.
|
||||
*/
|
||||
async #stillOwed(row: DispatchSubscription): Promise<boolean> {
|
||||
if (row.durable !== true) return true;
|
||||
const durable = row as DurableSubscription;
|
||||
if (await this.#anchorStillReachable(durable)) return true;
|
||||
|
||||
const suspended = await this.#suspend([durable], 'permission_revoked');
|
||||
await this.#notifyEnded(suspended, 'permission_revoked');
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* One attempt at one owed delivery. Sockets first and one at a time, the
|
||||
* handler once they are spent — a client that is there answers faster than
|
||||
|
||||
@@ -143,6 +143,17 @@ describe('who may subscribe', () => {
|
||||
|
||||
const sub = await subscribeAs(guest, 'guest-a', `fs:${path}`);
|
||||
|
||||
// The uid is the whole address a recipient gets: the owner's real path
|
||||
// names every folder above the shared one.
|
||||
expect(sub.anchor.uid).toBeTruthy();
|
||||
expect(sub.anchor.path).toBe('');
|
||||
});
|
||||
|
||||
it('still tells an owner where their own anchor is', async () => {
|
||||
const path = await folder(`/${owner.username}/own-anchor`);
|
||||
|
||||
const sub = await subscribeAs(owner, 'owner-a', `fs:${path}`);
|
||||
|
||||
expect(sub.anchor.path).toBe(path);
|
||||
});
|
||||
|
||||
@@ -208,6 +219,36 @@ describe('delivering across an account boundary', () => {
|
||||
expect(byId.get(mine.subId)).toMatchObject({ self: true });
|
||||
});
|
||||
|
||||
it('addresses a guest`s delivery the way every other FS surface does', async () => {
|
||||
const shared = await folder(`/${owner.username}/private-tree`);
|
||||
const inner = await folder(`${shared}/clients/acme`);
|
||||
await share(inner, 'list');
|
||||
const theirs = await subscribeAs(guest, 'guest-m', `fs:${inner}`);
|
||||
const mine = await subscribeAs(owner, 'owner-m', `fs:${inner}`);
|
||||
delivered.length = 0;
|
||||
|
||||
const written = uniquePath(inner);
|
||||
await mkdirAsOwner(written);
|
||||
await settle(2);
|
||||
|
||||
const byId = new Map(delivered.map((d) => [d.subId, d.event]));
|
||||
const anchorUid = (
|
||||
await env.server.stores.fsEntry.getEntryByPath(inner)
|
||||
)?.uid;
|
||||
const guestPath = (byId.get(theirs.subId) as { path: string }).path;
|
||||
|
||||
// The anchor stands in for everything above it, so the folders the
|
||||
// owner keeps it in are not named.
|
||||
expect(guestPath).toBe(
|
||||
`/${owner.username}/${anchorUid}/acme/${written.split('/').pop()}`,
|
||||
);
|
||||
expect(guestPath).not.toContain('/clients/');
|
||||
expect(guestPath).not.toContain('private-tree');
|
||||
|
||||
// The owner's own row is untouched.
|
||||
expect(byId.get(mine.subId)).toMatchObject({ path: written });
|
||||
});
|
||||
|
||||
it('stops delivering the moment the share is revoked', async () => {
|
||||
const path = await folder(`/${owner.username}/shared-revoked`);
|
||||
await share(path, 'list');
|
||||
|
||||
@@ -84,12 +84,12 @@ const call = async (
|
||||
};
|
||||
|
||||
const subscribe = (token: string, body: object = {}): Promise<ApiResponse> =>
|
||||
call('POST', '/events/subscribe', token, { subject: `fs:${anchor}`, ...body });
|
||||
call('POST', '/events/subscribe', token, {
|
||||
subject: `fs:${anchor}`,
|
||||
...body,
|
||||
});
|
||||
|
||||
const listSubscriptions = (
|
||||
token: string,
|
||||
query = '',
|
||||
): Promise<ApiResponse> =>
|
||||
const listSubscriptions = (token: string, query = ''): Promise<ApiResponse> =>
|
||||
call('GET', `/events/subscriptions${query}`, token);
|
||||
|
||||
const unsubscribe = (token: string, subId: string): Promise<ApiResponse> =>
|
||||
@@ -127,7 +127,10 @@ const makeApp = async (
|
||||
);
|
||||
return {
|
||||
uid,
|
||||
token: await env.server.services.auth.getUserAppToken(actor.actor!, uid),
|
||||
token: await env.server.services.auth.getUserAppToken(
|
||||
actor.actor!,
|
||||
uid,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -687,18 +690,21 @@ describe('a durable row across a share', () => {
|
||||
});
|
||||
delivered.length = 0;
|
||||
|
||||
await fs().touch(userId, { path: `${sharedPath}/first.txt` });
|
||||
await settle(`${sharedPath}/first.txt`);
|
||||
expect(deliveryOf(`${sharedPath}/first.txt`)?.subId).toBe(
|
||||
created.body.subId,
|
||||
);
|
||||
|
||||
const sharedEntry =
|
||||
// The guest is addressed the way every FS surface addresses a foreign
|
||||
// node: the anchor stands in for everything above it.
|
||||
const anchorEntry =
|
||||
await env.server.stores.fsEntry.getEntryByPath(sharedPath);
|
||||
const seenAs = (name: string) =>
|
||||
`/${username}/${anchorEntry!.uid}/shared-with-guest/${name}`;
|
||||
|
||||
await fs().touch(userId, { path: `${sharedPath}/first.txt` });
|
||||
await settle(seenAs('first.txt'));
|
||||
expect(deliveryOf(seenAs('first.txt'))?.subId).toBe(created.body.subId);
|
||||
|
||||
await env.server.services.permission.revokeUserUserPermission(
|
||||
ownerActor,
|
||||
env.users.other.username,
|
||||
`fs:${sharedEntry!.uid}:list`,
|
||||
`fs:${anchorEntry!.uid}:list`,
|
||||
);
|
||||
delivered.length = 0;
|
||||
|
||||
@@ -707,6 +713,7 @@ describe('a durable row across a share', () => {
|
||||
await fs().touch(userId, { path: `${sharedPath}/second.txt` });
|
||||
await quiet();
|
||||
|
||||
expect(deliveryOf(seenAs('second.txt'))).toBeUndefined();
|
||||
expect(deliveryOf(`${sharedPath}/second.txt`)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -758,9 +765,9 @@ describe('with events switched off', () => {
|
||||
|
||||
/**
|
||||
* The tiering the rest of this file opts out of. Seeded accounts carry no
|
||||
* email, which is exactly what the plan machinery reads as a temporary
|
||||
* account — so this block boots with plans left on and gives the account an
|
||||
* email when it wants to be a registered one.
|
||||
* email, which is exactly what the plan machinery reads as a temporary account
|
||||
* — so this block boots with plans left on and gives the account an email when
|
||||
* it wants to be a registered one.
|
||||
*/
|
||||
describe('what a plan lets an account hold', () => {
|
||||
let tiered: PuterTestEnv;
|
||||
@@ -788,7 +795,9 @@ describe('what a plan lets an account hold', () => {
|
||||
};
|
||||
|
||||
const subscribeTiered = (token: string) =>
|
||||
tieredCall('/events/subscribe', token, { subject: `fs:${tieredAnchor}` });
|
||||
tieredCall('/events/subscribe', token, {
|
||||
subject: `fs:${tieredAnchor}`,
|
||||
});
|
||||
|
||||
/**
|
||||
* Move the account between the plans the caps are written against: an
|
||||
@@ -880,6 +889,8 @@ describe('what a plan lets an account hold', () => {
|
||||
|
||||
// The account itself is nowhere near its own cap, so its own session
|
||||
// may still subscribe.
|
||||
expect((await subscribeTiered(tiered.users.user.token)).status).toBe(200);
|
||||
expect((await subscribeTiered(tiered.users.user.token)).status).toBe(
|
||||
200,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,15 +110,15 @@ export interface ForwardBump {
|
||||
}
|
||||
|
||||
export type ForwardItem =
|
||||
| ForwardDelivery
|
||||
| ForwardAck
|
||||
| ForwardWatch
|
||||
| ForwardEvent
|
||||
| ForwardBump;
|
||||
ForwardDelivery | ForwardAck | ForwardWatch | ForwardEvent | ForwardBump;
|
||||
|
||||
/** One batch, as a peer receives it. */
|
||||
export interface ForwardBatch {
|
||||
/** Sending region, so the receiver can address a reply. */
|
||||
/**
|
||||
* Sending region, so the receiver can address a reply. Informational only
|
||||
* on the receiving side — it is the sender's own claim, and the signed
|
||||
* peer-id header is what the receiver acts on.
|
||||
*/
|
||||
from: string;
|
||||
items: ForwardItem[];
|
||||
}
|
||||
@@ -323,7 +323,7 @@ const isGapMarker = (item: ForwardItem): boolean =>
|
||||
*/
|
||||
const shed = (queue: PeerQueue, count: number): ForwardItem[] => {
|
||||
const dropped: ForwardItem[] = [];
|
||||
for (let i = 0; i < queue.items.length && dropped.length < count; ) {
|
||||
for (let i = 0; i < queue.items.length && dropped.length < count;) {
|
||||
if (isGapMarker(queue.items[i])) {
|
||||
i++;
|
||||
continue;
|
||||
@@ -346,7 +346,7 @@ const shed = (queue: PeerQueue, count: number): ForwardItem[] => {
|
||||
const shedBytes = (queue: PeerQueue, maxBytesHeld: number): ForwardItem[] => {
|
||||
const dropped: ForwardItem[] = [];
|
||||
let remaining = queue.bytes;
|
||||
for (let i = 0; i < queue.items.length && remaining > maxBytesHeld; ) {
|
||||
for (let i = 0; i < queue.items.length && remaining > maxBytesHeld;) {
|
||||
if (isGapMarker(queue.items[i])) {
|
||||
i++;
|
||||
continue;
|
||||
|
||||
@@ -324,7 +324,9 @@ const makeRegion = (
|
||||
throw new Error('peer did not answer');
|
||||
const target = regions.get(peerId);
|
||||
if (!target) throw new Error(`no such peer ${peerId}`);
|
||||
return target.forward.receive(batch);
|
||||
// The controller passes the peer the signature proved, not the
|
||||
// one the body names.
|
||||
return target.forward.receive(batch, name);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -452,20 +454,21 @@ const makeRegion = (
|
||||
// tests need — the webhook backstop for a durable generation bump. The
|
||||
// rest of it (kv.mutated, permission wiring, sweep timers) is out of
|
||||
// scope here, and `services.permission` is not stubbed to support it.
|
||||
clients.event.on(
|
||||
'outer.pubsub.events.generationBumped',
|
||||
((_key: string, data: unknown, meta: unknown) => {
|
||||
if (!(meta as { from_outside?: boolean })?.from_outside) return;
|
||||
const { userId: bumpedUserId, durable } = (data ?? {}) as {
|
||||
userId?: number;
|
||||
durable?: boolean;
|
||||
};
|
||||
if (typeof bumpedUserId === 'number')
|
||||
region.events.invalidateUser(bumpedUserId, {
|
||||
rebuild: durable === true,
|
||||
});
|
||||
}) as (...args: never[]) => void,
|
||||
);
|
||||
clients.event.on('outer.pubsub.events.generationBumped', ((
|
||||
_key: string,
|
||||
data: unknown,
|
||||
meta: unknown,
|
||||
) => {
|
||||
if (!(meta as { from_outside?: boolean })?.from_outside) return;
|
||||
const { userId: bumpedUserId, durable } = (data ?? {}) as {
|
||||
userId?: number;
|
||||
durable?: boolean;
|
||||
};
|
||||
if (typeof bumpedUserId === 'number')
|
||||
region.events.invalidateUser(bumpedUserId, {
|
||||
rebuild: durable === true,
|
||||
});
|
||||
}) as (...args: never[]) => void);
|
||||
regions.set(name, region);
|
||||
return region;
|
||||
};
|
||||
@@ -473,7 +476,9 @@ const makeRegion = (
|
||||
// -- Helpers ----------------------------------------------------------
|
||||
|
||||
/** Reconstructs a pair's row from its per-region items, the way `read()` does. */
|
||||
const rowFor = (appUid: string = PRESENCE_NO_APP): { regions: Record<string, number> } => {
|
||||
const rowFor = (
|
||||
appUid: string = PRESENCE_NO_APP,
|
||||
): { regions: Record<string, number> } => {
|
||||
const prefix = presenceItemKey(`user-${userId}`, appUid, '');
|
||||
const now = Date.now() / 1000;
|
||||
const regions: Record<string, number> = {};
|
||||
@@ -502,7 +507,10 @@ const dispatch = (region: Region, node = entry()): Promise<void> =>
|
||||
ancestors: async () => ancestors(),
|
||||
});
|
||||
|
||||
/** A session (`onLocal`) row on the shared anchor, through the real subscribe path. */
|
||||
/**
|
||||
* A session (`onLocal`) row on the shared anchor, through the real subscribe
|
||||
* path.
|
||||
*/
|
||||
const subscribeSession = async (
|
||||
region: Region,
|
||||
opts: {
|
||||
@@ -1215,9 +1223,7 @@ describe('a row naming a region that is not a peer', () => {
|
||||
});
|
||||
|
||||
expect(await west.forward.regionsFor(userId, null)).toEqual([]);
|
||||
await vi.waitFor(() =>
|
||||
expect(rowFor().regions.ghost).toBeUndefined(),
|
||||
);
|
||||
await vi.waitFor(() => expect(rowFor().regions.ghost).toBeUndefined());
|
||||
});
|
||||
|
||||
it('still offers a peer alongside a non-peer name in the same row', async () => {
|
||||
@@ -1254,25 +1260,28 @@ describe('receiving a batch', () => {
|
||||
},
|
||||
);
|
||||
|
||||
await east.forward.receive({
|
||||
from: 'west',
|
||||
items: ['first', 'second', 'third'].map((subId) => ({
|
||||
kind: 'delivery' as const,
|
||||
userId,
|
||||
appUid: null,
|
||||
subId,
|
||||
event: {
|
||||
id: `e-${subId}`,
|
||||
subject: 'fs:/u7/Documents',
|
||||
op: 'write',
|
||||
uid: 'node-1',
|
||||
path: '/u7/Documents/notes.txt',
|
||||
self: true,
|
||||
seq: 0,
|
||||
ts: 1,
|
||||
},
|
||||
})),
|
||||
});
|
||||
await east.forward.receive(
|
||||
{
|
||||
from: 'west',
|
||||
items: ['first', 'second', 'third'].map((subId) => ({
|
||||
kind: 'delivery' as const,
|
||||
userId,
|
||||
appUid: null,
|
||||
subId,
|
||||
event: {
|
||||
id: `e-${subId}`,
|
||||
subject: 'fs:/u7/Documents',
|
||||
op: 'write',
|
||||
uid: 'node-1',
|
||||
path: '/u7/Documents/notes.txt',
|
||||
self: true,
|
||||
seq: 0,
|
||||
ts: 1,
|
||||
},
|
||||
})),
|
||||
},
|
||||
'west',
|
||||
);
|
||||
|
||||
expect(delivered).toEqual(['first', 'second', 'third']);
|
||||
});
|
||||
@@ -1303,7 +1312,7 @@ describe('receiving a batch', () => {
|
||||
};
|
||||
|
||||
const startedAt = Date.now();
|
||||
await east.forward.receive(batch);
|
||||
await east.forward.receive(batch, 'west');
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
|
||||
// Bounded by the one slow settle running alongside the rest, not by
|
||||
@@ -1546,19 +1555,22 @@ describe('a session subscription in another region', () => {
|
||||
const east = makeRegion('east', ['west'], forwardCfg);
|
||||
|
||||
await expect(
|
||||
east.forward.receive({
|
||||
from: 'west',
|
||||
items: [
|
||||
{ kind: 'from-the-future' } as never,
|
||||
{
|
||||
kind: 'bump',
|
||||
userId,
|
||||
generation: 1,
|
||||
scope: 'subscription',
|
||||
durable: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
east.forward.receive(
|
||||
{
|
||||
from: 'west',
|
||||
items: [
|
||||
{ kind: 'from-the-future' } as never,
|
||||
{
|
||||
kind: 'bump',
|
||||
userId,
|
||||
generation: 1,
|
||||
scope: 'subscription',
|
||||
durable: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
'west',
|
||||
),
|
||||
).resolves.toEqual({});
|
||||
});
|
||||
|
||||
@@ -1610,6 +1622,24 @@ describe('a session subscription in another region', () => {
|
||||
expect(west.posts).toEqual([]);
|
||||
});
|
||||
|
||||
it('attributes a watch to the peer that signed it, not the one the body names', async () => {
|
||||
// Peers that share a webhook secret would otherwise be able to write
|
||||
// each other's region into this one's remote-watch index.
|
||||
const east = makeRegion('east', ['west'], forwardCfg);
|
||||
const token = fsAnchorToken(anchorUid());
|
||||
|
||||
await east.forward.receive(
|
||||
{
|
||||
from: 'south',
|
||||
items: [{ kind: 'watch', userId, token, op: 'add' }],
|
||||
},
|
||||
'west',
|
||||
);
|
||||
|
||||
const { remote } = await east.subscriptions.watchedFor(userId, [token]);
|
||||
expect(remote.get(token)).toEqual(['west']);
|
||||
});
|
||||
|
||||
it('writes no remote-watch entry on a region that has it turned off', async () => {
|
||||
const west = makeRegion('west', ['east'], {
|
||||
events: { enabled: true, forwardSession: false },
|
||||
|
||||
@@ -26,7 +26,15 @@
|
||||
* shared handler delivers each subscriber their own.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
EVENTS_COALESCE_WINDOW_MS,
|
||||
@@ -44,8 +52,10 @@ import { RecordingWorkerInvoker } from './workerSeam.js';
|
||||
|
||||
const BOOT_TIMEOUT_MS = 120_000;
|
||||
|
||||
const SOURCE = 'async ({ event, ctx }) => { await fetch(ctx.url, { method: "POST" }); }';
|
||||
const NEXT_SOURCE = 'async ({ event, ctx }) => { console.log(event.path, ctx.url); }';
|
||||
const SOURCE =
|
||||
'async ({ event, ctx }) => { await fetch(ctx.url, { method: "POST" }); }';
|
||||
const NEXT_SOURCE =
|
||||
'async ({ event, ctx }) => { console.log(event.path, ctx.url); }';
|
||||
|
||||
let env: PuterTestEnv;
|
||||
let userId: number;
|
||||
@@ -116,8 +126,12 @@ const makeApp = async (
|
||||
|
||||
const tokens: string[] = [];
|
||||
for (const grant of grants) {
|
||||
const entry = await env.server.stores.fsEntry.getEntryByPath(grant.path);
|
||||
const { actor } = await env.server.services.auth.authenticate(grant.token);
|
||||
const entry = await env.server.stores.fsEntry.getEntryByPath(
|
||||
grant.path,
|
||||
);
|
||||
const { actor } = await env.server.services.auth.authenticate(
|
||||
grant.token,
|
||||
);
|
||||
await env.server.services.permission.grantUserAppPermission(
|
||||
actor!,
|
||||
uid,
|
||||
@@ -143,8 +157,8 @@ const subscribe = (token: string, body: object): Promise<ApiResponse> =>
|
||||
const listSubscriptions = async (
|
||||
token: string,
|
||||
): Promise<DurableSubscriptionView[]> =>
|
||||
(await call('GET', '/events/subscriptions', token))
|
||||
.body.items as DurableSubscriptionView[];
|
||||
(await call('GET', '/events/subscriptions', token)).body
|
||||
.items as DurableSubscriptionView[];
|
||||
|
||||
const rowOf = (subId: string) => durable().getBySubId(subId);
|
||||
|
||||
@@ -270,6 +284,31 @@ describe('who may publish a handler', () => {
|
||||
expect(refused.body.code).toBe('events_handler_forbidden');
|
||||
});
|
||||
|
||||
it('refuses a scoped API token naming an app its user owns', async () => {
|
||||
// It carries no app, so a gate reading that as "the account" would let
|
||||
// a token minted for one narrow purpose replace the handler code of
|
||||
// every app its user owns — code that then runs holding each
|
||||
// subscriber's own credential.
|
||||
const actor = await env.server.services.auth.authenticate(
|
||||
env.users.user.token,
|
||||
);
|
||||
const entry = await env.server.stores.fsEntry.getEntryByPath(anchor);
|
||||
const scoped = await env.server.services.auth.createAccessToken(
|
||||
actor.actor!,
|
||||
[[`fs:${entry!.uid}:list`]],
|
||||
{ label: 'handlers-scope' },
|
||||
);
|
||||
|
||||
const refused = await publish(scoped, {
|
||||
appUid,
|
||||
name: 'ingestUpload',
|
||||
source: SOURCE,
|
||||
});
|
||||
|
||||
expect(refused.status).toBe(403);
|
||||
expect(refused.body.code).toBe('events_handler_forbidden');
|
||||
});
|
||||
|
||||
it('refuses an app token reaching into another app`s namespace', async () => {
|
||||
const refused = await publish(appToken, {
|
||||
appUid: foreignAppUid,
|
||||
@@ -325,7 +364,9 @@ describe('publishing a set', () => {
|
||||
// stopped rather than being left to guess.
|
||||
const listed = await call('GET', '/events/handlers/list', appToken);
|
||||
expect(
|
||||
(listed.body.handlers as Array<{ name: string }>).map((h) => h.name),
|
||||
(listed.body.handlers as Array<{ name: string }>).map(
|
||||
(h) => h.name,
|
||||
),
|
||||
).toContain('indexDocument');
|
||||
});
|
||||
});
|
||||
@@ -339,11 +380,19 @@ describe('the total-source cap', () => {
|
||||
// can reach the total cap.
|
||||
const bigSource = 'x'.repeat(EVENTS_HANDLER_SOURCE_MAX_BYTES);
|
||||
const names = Array.from(
|
||||
{ length: EVENTS_WORKER_SOURCE_MAX_BYTES / EVENTS_HANDLER_SOURCE_MAX_BYTES },
|
||||
{
|
||||
length:
|
||||
EVENTS_WORKER_SOURCE_MAX_BYTES /
|
||||
EVENTS_HANDLER_SOURCE_MAX_BYTES,
|
||||
},
|
||||
(_, i) => `big${i}`,
|
||||
);
|
||||
|
||||
for (let i = 0; i < names.length; i += EVENTS_HANDLER_PUBLISH_BATCH) {
|
||||
for (
|
||||
let i = 0;
|
||||
i < names.length;
|
||||
i += EVENTS_HANDLER_PUBLISH_BATCH
|
||||
) {
|
||||
const batch = names
|
||||
.slice(i, i + EVENTS_HANDLER_PUBLISH_BATCH)
|
||||
.map((name) => ({ name, source: bigSource }));
|
||||
@@ -748,11 +797,16 @@ describe('the handler lifecycle', () => {
|
||||
|
||||
// New source under the name is the fix for a handler that could not
|
||||
// take its deliveries, so it is what brings its subscriptions back.
|
||||
const republished = await call('POST', '/events/handlers/publish', appToken, {
|
||||
name: 'ingestUpload',
|
||||
source: NEXT_SOURCE,
|
||||
replace: true,
|
||||
});
|
||||
const republished = await call(
|
||||
'POST',
|
||||
'/events/handlers/publish',
|
||||
appToken,
|
||||
{
|
||||
name: 'ingestUpload',
|
||||
source: NEXT_SOURCE,
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(republished.body.resumed).toBe(1);
|
||||
expect(await rowOf(subId)).toMatchObject({
|
||||
|
||||
@@ -321,6 +321,19 @@ describe('the cross-app gate against real grants', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the app slot in a `kv:` subject, from a plain account session', () => {
|
||||
it('refuses an app slot too long to store rather than truncate the anchor', async () => {
|
||||
await clearRows();
|
||||
const subject = `kv:${'a'.repeat(4000)}:key`;
|
||||
|
||||
// No app on this actor, so the cross-app gate never runs — the store's
|
||||
// own width guard is what stands between this and a truncated anchor.
|
||||
await expect(
|
||||
subscribeDurable(subject, env.users.user.token),
|
||||
).rejects.toMatchObject({ legacyCode: 'events_value_too_large' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the writer never pays for the subscriber', () => {
|
||||
it('completes the write when the dispatcher throws', async () => {
|
||||
const dispatch = vi
|
||||
|
||||
@@ -21,14 +21,14 @@
|
||||
* An app minting a share handle on its user's data, and a grantee reading one
|
||||
* through their own app.
|
||||
*
|
||||
* The bounds are the ones sharing already puts on an app handing out its
|
||||
* user's files: the authority is the user's, the consent is a `manage:` grant
|
||||
* the user gave this app on this region, and the reach is whatever the
|
||||
* credential structurally holds — for key-value that is one namespace. What
|
||||
* these cases pin is that each of those is actually load-bearing, and that a
|
||||
* handle minted this way is in every other respect an ordinary one — including
|
||||
* for a grantee who exercises it while running as an app, which only works
|
||||
* bound to the same app the region was shared to.
|
||||
* The bounds are the ones sharing already puts on an app handing out its user's
|
||||
* files: the authority is the user's, the consent is a `manage:` grant the user
|
||||
* gave this app on this region, and the reach is whatever the credential
|
||||
* structurally holds — for key-value that is one namespace. What these cases
|
||||
* pin is that each of those is actually load-bearing, and that a handle minted
|
||||
* this way is in every other respect an ordinary one — including for a grantee
|
||||
* who exercises it while running as an app, which only works bound to the same
|
||||
* app the region was shared to.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@@ -100,18 +100,14 @@ const delegate = (prefix = PREFIX) =>
|
||||
permissions().grantUserAppPermission(
|
||||
owner.actor,
|
||||
appUid,
|
||||
kvShareManagePermission(
|
||||
kvSharePermission(owner.uuid, appUid, prefix),
|
||||
),
|
||||
kvShareManagePermission(kvSharePermission(owner.uuid, appUid, prefix)),
|
||||
);
|
||||
|
||||
const undelegate = (prefix = PREFIX) =>
|
||||
permissions().revokeUserAppPermission(
|
||||
owner.actor,
|
||||
appUid,
|
||||
kvShareManagePermission(
|
||||
kvSharePermission(owner.uuid, appUid, prefix),
|
||||
),
|
||||
kvShareManagePermission(kvSharePermission(owner.uuid, appUid, prefix)),
|
||||
);
|
||||
|
||||
const mint = (request: Record<string, unknown> = {}, actor = appActor) =>
|
||||
@@ -205,9 +201,11 @@ describe('an app minting without consent', () => {
|
||||
await delegate('workspace:abc:');
|
||||
// A sibling region, and the parent the consent sits under: coverage
|
||||
// only ever runs downward.
|
||||
await expect(mint({ prefix: 'workspace:other:' })).rejects.toMatchObject(
|
||||
{ legacyCode: 'events_kv_handle_not_delegated' },
|
||||
);
|
||||
await expect(
|
||||
mint({ prefix: 'workspace:other:' }),
|
||||
).rejects.toMatchObject({
|
||||
legacyCode: 'events_kv_handle_not_delegated',
|
||||
});
|
||||
await expect(mint({ prefix: 'workspace:' })).rejects.toMatchObject({
|
||||
legacyCode: 'events_kv_handle_not_delegated',
|
||||
});
|
||||
@@ -256,6 +254,26 @@ describe('an access token', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('may not mint on the account when scoped to less than its issuer holds', async () => {
|
||||
// No app of its own, so the surface reads it as the account — which it
|
||||
// is not: its reach is whatever manifest the user pinned it to.
|
||||
const scopedActor = makeActor({
|
||||
user: owner.actor.user as never,
|
||||
accessToken: {
|
||||
uid: `tok-${uuidv4()}`,
|
||||
issuer: owner.actor,
|
||||
authorized: null,
|
||||
fullAccess: false,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
mint({ prefix: 'scoped-probe:' }, scopedActor),
|
||||
).rejects.toMatchObject({
|
||||
legacyCode: 'events_kv_handle_owner_only',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not block a full-access token acting for its own user, on their own namespace', async () => {
|
||||
const patActor = makeActor({
|
||||
user: owner.actor.user as never,
|
||||
@@ -290,16 +308,14 @@ describe('an app minting with consent', () => {
|
||||
// The reach cap is structural — this app addresses `v1:<user>:<app>`
|
||||
// and nothing else — so naming another namespace is refused rather
|
||||
// than minted somewhere the app cannot even write.
|
||||
await expect(
|
||||
mint({ appUid: 'os-global' }),
|
||||
).rejects.toMatchObject({
|
||||
legacyCode: 'events_kv_handle_outside_namespace',
|
||||
});
|
||||
await expect(
|
||||
mint({ appUid: `app-${uuidv4()}` }),
|
||||
).rejects.toMatchObject({
|
||||
await expect(mint({ appUid: 'os-global' })).rejects.toMatchObject({
|
||||
legacyCode: 'events_kv_handle_outside_namespace',
|
||||
});
|
||||
await expect(mint({ appUid: `app-${uuidv4()}` })).rejects.toMatchObject(
|
||||
{
|
||||
legacyCode: 'events_kv_handle_outside_namespace',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores a fabricated owner field — the owner is always the caller behind the app', async () => {
|
||||
@@ -688,9 +704,9 @@ describe('an app managing what it minted', () => {
|
||||
});
|
||||
expect(appPage.items.length).toBeGreaterThan(0);
|
||||
for (const row of appPage.items) expect(row.appUid).toBe(appUid);
|
||||
expect(
|
||||
appPage.items.some((row) => row.appUid === 'os-global'),
|
||||
).toBe(false);
|
||||
expect(appPage.items.some((row) => row.appUid === 'os-global')).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
const ownerPage = await events().listKvHandles(owner.actor, {
|
||||
limit: 100,
|
||||
|
||||
@@ -236,7 +236,9 @@ describe('revoking a handle', () => {
|
||||
// holder's rows — no lookup keyed on the handle anywhere.
|
||||
expect(settle).toHaveBeenCalledTimes(1);
|
||||
expect(byHolder).toHaveBeenCalledTimes(1);
|
||||
expect(byHolder).toHaveBeenCalledWith(guest.id, null);
|
||||
expect(byHolder).toHaveBeenCalledWith(guest.id, null, {
|
||||
includeSuspended: true,
|
||||
});
|
||||
} finally {
|
||||
byHolder.mockRestore();
|
||||
settle.mockRestore();
|
||||
|
||||
@@ -495,6 +495,33 @@ describe('subscribing through a handle', () => {
|
||||
expect(JSON.stringify(listed)).not.toContain(PREFIX);
|
||||
});
|
||||
|
||||
it('keeps the stored filter on the handle, whatever shape the grantee wrote', async () => {
|
||||
// The pattern is composed onto the granted prefix before it is stored,
|
||||
// so a `*` that is not delimiter-aligned leaves the owner's absolute
|
||||
// key layout in `match`. Re-based on the handle, as the anchor is.
|
||||
await clearRows();
|
||||
const { handle } = await mint();
|
||||
|
||||
for (const pattern of ['messages*', 'a:b*', 'messages:*', '*']) {
|
||||
const sub = (
|
||||
await events().subscribe(guest.actor, SOCKET_ID, {
|
||||
subject: `kv:${handle}:${pattern}`,
|
||||
})
|
||||
).sub;
|
||||
|
||||
expect(JSON.stringify(sub)).not.toContain(PREFIX);
|
||||
expect(JSON.stringify(sub)).not.toContain(owner.uuid);
|
||||
if (sub.match !== null) expect(sub.match).toBe(pattern);
|
||||
|
||||
const [listed] = await events().listSubscriptions(
|
||||
guest.actor,
|
||||
SOCKET_ID,
|
||||
);
|
||||
expect(JSON.stringify(listed)).not.toContain(PREFIX);
|
||||
await clearRows();
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a user the handle was not granted to', async () => {
|
||||
await clearRows();
|
||||
const { handle } = await mint();
|
||||
|
||||
@@ -144,6 +144,10 @@ describe('granted prefixes', () => {
|
||||
// a region other than the one asked for.
|
||||
['workspace::abc:', 'an empty key segment'],
|
||||
[':workspace:abc:', 'a leading empty segment'],
|
||||
// `escape_permission_component` leaves `\\` alone while escaping `:`,
|
||||
// so the stored prefix and the permission string would disagree and
|
||||
// the share would silently never fire.
|
||||
['work\\space:abc:', 'a backslash'],
|
||||
])('refuses %s (%s)', (prefix) => {
|
||||
let thrown: unknown;
|
||||
try {
|
||||
@@ -361,14 +365,14 @@ describe('the app delegate implicator', () => {
|
||||
expect(implicator.matches(`fs:${OWNER}:read`)).toBe(false);
|
||||
});
|
||||
|
||||
it('grants when the actor\'s own app matches the namespace app and the user holds the grant', async () => {
|
||||
it("grants when the actor's own app matches the namespace app and the user holds the grant", async () => {
|
||||
const userHolds = vi.fn().mockResolvedValue(true);
|
||||
const implicator = kvShareAppDelegateImplicator({ userHolds });
|
||||
const actor = appActor(OWNER, APP);
|
||||
|
||||
await expect(
|
||||
implicator.check({ actor, permission }),
|
||||
).resolves.toEqual({});
|
||||
await expect(implicator.check({ actor, permission })).resolves.toEqual(
|
||||
{},
|
||||
);
|
||||
|
||||
expect(userHolds).toHaveBeenCalledTimes(1);
|
||||
const [calledActor, calledPermission] = userHolds.mock.calls[0];
|
||||
@@ -387,7 +391,7 @@ describe('the app delegate implicator', () => {
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses when the actor\'s app differs from the namespace app', async () => {
|
||||
it("refuses when the actor's app differs from the namespace app", async () => {
|
||||
const userHolds = vi.fn().mockResolvedValue(true);
|
||||
const implicator = kvShareAppDelegateImplicator({ userHolds });
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { type Actor, userRelatedActor } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { KV_SHARE_HANDLE_WIDTHS } from '../../stores/events/columnWidths.js';
|
||||
import { KV_GLOBAL_APP_KEY } from '../../stores/systemKv/SystemKVStore.js';
|
||||
import {
|
||||
MANAGE_PERM_PREFIX,
|
||||
@@ -58,7 +59,7 @@ import {
|
||||
export const KV_SHARE_PERMISSION_PREFIX = 'kv-share';
|
||||
|
||||
/** Width of the `app_uid` column the handle row stores its namespace in. */
|
||||
export const KV_SHARE_APP_UID_MAX_LENGTH = 40;
|
||||
export const KV_SHARE_APP_UID_MAX_LENGTH = KV_SHARE_HANDLE_WIDTHS.appUid.max;
|
||||
|
||||
export const mintKvHandleId = (): string =>
|
||||
`${KV_HANDLE_PREFIX}${randomUUID()}`;
|
||||
@@ -144,6 +145,11 @@ export const assertShareablePrefix = (keyPrefix: unknown): string => {
|
||||
throw invalidPrefix('`prefix` must be a string');
|
||||
if (keyPrefix.includes('*') || keyPrefix.includes('?'))
|
||||
throw invalidPrefix('A share prefix is a key prefix, not a pattern');
|
||||
// `escape_permission_component` escapes `:` as `\C` but leaves `\` alone,
|
||||
// so a prefix carrying one desynchronizes the stored `key_prefix` from the
|
||||
// permission string and the share silently never fires.
|
||||
if (keyPrefix.includes('\\'))
|
||||
throw invalidPrefix('A share prefix may not contain a backslash');
|
||||
// Normalizing drops empty segments, so `a::b:` would silently become a
|
||||
// grant on `a:b:` — a region other than the one asked for. Refused rather
|
||||
// than rewritten; only the trailing delimiter is optional.
|
||||
|
||||
@@ -83,8 +83,8 @@ const unshare = async (path: string, mode: AclMode): Promise<void> => {
|
||||
};
|
||||
|
||||
/**
|
||||
* The real user-facing surface: `ShareService`, not the ACL/permission layer
|
||||
* it settles on top of. What the settle mechanism actually has to survive is
|
||||
* The real user-facing surface: `ShareService`, not the ACL/permission layer it
|
||||
* settles on top of. What the settle mechanism actually has to survive is
|
||||
* everything this service does around the grant — index-row bookkeeping,
|
||||
* authorization, delegate resolution — not just the grant itself.
|
||||
*/
|
||||
@@ -224,7 +224,7 @@ const clearRows = async () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
events: { enabled: true },
|
||||
events: { enabled: true, notificationsFoldIn: true },
|
||||
// Seeded accounts carry no email, which the plan machinery reads as a
|
||||
// temporary account — and a temporary account holds no durable rows.
|
||||
// Plans are not what these cases are about.
|
||||
@@ -257,6 +257,88 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
describe('the delivery re-check on its own', () => {
|
||||
it.each(['account', 'app-user', 'developer'] as const)(
|
||||
'retries an authorized %s notification backlog',
|
||||
async (audience) => {
|
||||
await clearRows();
|
||||
const app =
|
||||
audience === 'account'
|
||||
? null
|
||||
: await makeApp(
|
||||
await folder(`/${owner.username}/notif-${audience}`),
|
||||
);
|
||||
const appUid = app?.effectiveApp?.uid ?? null;
|
||||
const subject = `notif:${appUid ?? owner.actor.user.uuid}:${audience}`;
|
||||
const { sub } = await events().subscribeDurable(owner.actor, {
|
||||
subject,
|
||||
delivery: 'single',
|
||||
handlerName: 'onChange',
|
||||
});
|
||||
await env.server.stores.pendingDelivery.enqueue(sub.subId, {
|
||||
id: uuidv4(),
|
||||
subject,
|
||||
op: 'post',
|
||||
uid: uuidv4(),
|
||||
type: 'app.news',
|
||||
audience,
|
||||
appUid,
|
||||
notification: { title: 'news' },
|
||||
self: true,
|
||||
ts: Date.now(),
|
||||
seq: 0,
|
||||
});
|
||||
|
||||
expect(await events().sweepPending()).toBe(1);
|
||||
expect((await rowOf(sub.subId)).suspended_reason).toBeNull();
|
||||
expect(
|
||||
await env.server.stores.pendingDelivery.depth(sub.subId),
|
||||
).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
'withholds queued developer notifications after ownership changes (generic: %s)',
|
||||
async (generic) => {
|
||||
await clearRows();
|
||||
const app = await makeApp(
|
||||
await folder(`/${owner.username}/notif-owner-${generic}`),
|
||||
);
|
||||
const appUid = app.effectiveApp!.uid;
|
||||
const subject = `notif:${appUid}:developer`;
|
||||
const { sub } = await events().subscribeDurable(owner.actor, {
|
||||
subject: generic ? 'notif:developer' : subject,
|
||||
delivery: 'single',
|
||||
handlerName: 'onChange',
|
||||
});
|
||||
await env.server.stores.pendingDelivery.enqueue(sub.subId, {
|
||||
id: uuidv4(),
|
||||
subject,
|
||||
op: 'post',
|
||||
uid: uuidv4(),
|
||||
type: 'app.news',
|
||||
audience: 'developer',
|
||||
appUid,
|
||||
notification: { title: 'news' },
|
||||
self: true,
|
||||
ts: Date.now(),
|
||||
seq: 0,
|
||||
});
|
||||
await env.server.clients.db.write(
|
||||
'UPDATE `apps` SET `owner_user_id` = ? WHERE `uid` = ?',
|
||||
[guest.id, appUid],
|
||||
);
|
||||
await env.server.stores.app.invalidateByUid(appUid);
|
||||
|
||||
expect(await events().sweepPending()).toBe(0);
|
||||
expect(
|
||||
await env.server.stores.pendingDelivery.depth(sub.subId),
|
||||
).toBe(0);
|
||||
expect((await rowOf(sub.subId)).suspended_reason).toBe(
|
||||
generic ? null : 'permission_revoked',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('stops a session subscription, and meters nothing, without any settle', async () => {
|
||||
await clearRows();
|
||||
const path = await folder(`/${owner.username}/backstop-session`);
|
||||
@@ -380,6 +462,94 @@ describe('what a revoked grant settles', () => {
|
||||
expect(delivered).toEqual([]);
|
||||
});
|
||||
|
||||
it('reaches a row already suspended for a reason that lifts', async () => {
|
||||
// Suspended rows hold a backlog for up to a day and come back on the
|
||||
// next resume. A settle that only looks at live rows leaves one
|
||||
// standing, and the resume hands over everything queued since.
|
||||
await clearRows();
|
||||
const path = await folder(`/${owner.username}/settle-suspended`);
|
||||
await share(path, 'list');
|
||||
const sub = (
|
||||
await events().subscribeDurable(guest.actor, {
|
||||
subject: `fs:${path}`,
|
||||
delivery: 'single',
|
||||
handlerName: 'onChange',
|
||||
})
|
||||
).sub;
|
||||
|
||||
await write(uniquePath(path));
|
||||
await vi.waitFor(
|
||||
async () =>
|
||||
expect(
|
||||
await env.server.stores.pendingDelivery.depth(sub.subId),
|
||||
).toBeGreaterThan(0),
|
||||
{ timeout: 5_000, interval: 25 },
|
||||
);
|
||||
|
||||
expect(await events().suspendForFailures(sub.subId)).toBe(true);
|
||||
expect((await rowOf(sub.subId)).suspended_reason).toBe('failures');
|
||||
|
||||
await unshare(path, 'list');
|
||||
await suspendedRow(sub.subId);
|
||||
|
||||
// Re-stamped as terminal, so a resume cannot lift it, and the backlog
|
||||
// went with the re-stamp.
|
||||
expect(await env.server.stores.pendingDelivery.depth(sub.subId)).toBe(
|
||||
0,
|
||||
);
|
||||
expect(await events().resumeForCredit(guest.id)).toBe(0);
|
||||
expect((await rowOf(sub.subId)).suspended_reason).toBe(
|
||||
'permission_revoked',
|
||||
);
|
||||
});
|
||||
|
||||
it('will not drain a backlog after a grant above the anchor is withdrawn', async () => {
|
||||
// The settle narrows on the anchor's own uid and leaves an ancestor
|
||||
// revoke to the delivery re-check. Nothing between the claim and the
|
||||
// socket runs that check, so without one on the drain the queue keeps
|
||||
// going out for the backlog's whole life.
|
||||
await clearRows();
|
||||
const shared = await folder(`/${owner.username}/settle-ancestor`);
|
||||
const inner = await folder(`${shared}/project`);
|
||||
await share(shared, 'list');
|
||||
|
||||
const sub = (
|
||||
await events().subscribeDurable(guest.actor, {
|
||||
subject: `fs:${inner}`,
|
||||
delivery: 'single',
|
||||
handlerName: 'onChange',
|
||||
})
|
||||
).sub;
|
||||
|
||||
await write(uniquePath(inner));
|
||||
await vi.waitFor(
|
||||
async () =>
|
||||
expect(
|
||||
await env.server.stores.pendingDelivery.depth(sub.subId),
|
||||
).toBeGreaterThan(0),
|
||||
{ timeout: 5_000, interval: 25 },
|
||||
);
|
||||
|
||||
// On the ancestor, so the settle does not narrow to this row.
|
||||
await unshare(shared, 'list');
|
||||
expect((await rowOf(sub.subId)).suspended_reason).toBeNull();
|
||||
|
||||
delivered.length = 0;
|
||||
await events().sweepPending();
|
||||
|
||||
await vi.waitFor(
|
||||
async () =>
|
||||
expect((await rowOf(sub.subId)).suspended_reason).toBe(
|
||||
'permission_revoked',
|
||||
),
|
||||
{ timeout: 5_000, interval: 25 },
|
||||
);
|
||||
expect(await env.server.stores.pendingDelivery.depth(sub.subId)).toBe(
|
||||
0,
|
||||
);
|
||||
expect(delivered).toEqual([]);
|
||||
});
|
||||
|
||||
it('tells the holder their subscription ended, and why', async () => {
|
||||
await clearRows();
|
||||
const path = await folder(`/${owner.username}/settle-notified`);
|
||||
@@ -400,8 +570,7 @@ describe('what a revoked grant settles', () => {
|
||||
{},
|
||||
);
|
||||
const match = rows.find(
|
||||
(row: { type?: string }) =>
|
||||
row.type === 'app.events.ended',
|
||||
(row: { type?: string }) => row.type === 'app.events.ended',
|
||||
);
|
||||
expect(match).toBeDefined();
|
||||
return match as { audience: string; value: unknown };
|
||||
@@ -481,10 +650,16 @@ describe('what a revoked grant settles', () => {
|
||||
);
|
||||
|
||||
const held = [
|
||||
(await events().subscribeDurable(appActor, { subject: `fs:${one}` }))
|
||||
.sub,
|
||||
(await events().subscribeDurable(appActor, { subject: `fs:${two}` }))
|
||||
.sub,
|
||||
(
|
||||
await events().subscribeDurable(appActor, {
|
||||
subject: `fs:${one}`,
|
||||
})
|
||||
).sub,
|
||||
(
|
||||
await events().subscribeDurable(appActor, {
|
||||
subject: `fs:${two}`,
|
||||
})
|
||||
).sub,
|
||||
];
|
||||
|
||||
await env.server.services.permission.revokeUserAppAll(
|
||||
@@ -593,7 +768,9 @@ describe('what a revoked grant settles', () => {
|
||||
const own = `/${guest.username}/settle-scope-own`;
|
||||
await fs().mkdir(guest.id, { path: own, createMissingParents: true });
|
||||
const ownSub = (
|
||||
await events().subscribeDurable(guest.actor, { subject: `fs:${own}` })
|
||||
await events().subscribeDurable(guest.actor, {
|
||||
subject: `fs:${own}`,
|
||||
})
|
||||
).sub;
|
||||
|
||||
await unshare(shared, 'list');
|
||||
@@ -646,10 +823,16 @@ describe('what a revoked grant settles', () => {
|
||||
`fs:${await uidOf(two)}:list`,
|
||||
);
|
||||
const held = [
|
||||
(await events().subscribeDurable(appActor, { subject: `fs:${one}` }))
|
||||
.sub,
|
||||
(await events().subscribeDurable(appActor, { subject: `fs:${two}` }))
|
||||
.sub,
|
||||
(
|
||||
await events().subscribeDurable(appActor, {
|
||||
subject: `fs:${one}`,
|
||||
})
|
||||
).sub,
|
||||
(
|
||||
await events().subscribeDurable(appActor, {
|
||||
subject: `fs:${two}`,
|
||||
})
|
||||
).sub,
|
||||
];
|
||||
const before = (await endedNotifications(owner.id)).length;
|
||||
|
||||
|
||||
@@ -198,6 +198,12 @@ export const notifMatchOn = (
|
||||
export const isKvToken = (token: string): boolean =>
|
||||
token.startsWith(KV_TOKEN_PREFIX);
|
||||
|
||||
export const isFsToken = (token: string): boolean =>
|
||||
token.startsWith(FS_TOKEN_PREFIX);
|
||||
|
||||
export const isNotifToken = (token: string): boolean =>
|
||||
token.startsWith(NOTIF_TOKEN_PREFIX);
|
||||
|
||||
/**
|
||||
* The handle a stored row was made through, or `null` for one on the holder's
|
||||
* own namespace. Read off the subject rather than a column of its own: the
|
||||
|
||||
@@ -23,7 +23,10 @@ import {
|
||||
EVENTS_SUSPENDED_PENDING_CAP,
|
||||
} from '../../controllers/events/limits.js';
|
||||
import type { DurableSubscription } from '../../stores/events/types.js';
|
||||
import type { SuspendedReason } from '../../stores/events/DurableSubscriptionStore.js';
|
||||
import {
|
||||
SUSPENDED_REASONS,
|
||||
type SuspendedReason,
|
||||
} from '../../stores/events/DurableSubscriptionStore.js';
|
||||
|
||||
/**
|
||||
* Suspended-versus-active on a durable subscription, and what each reason does
|
||||
@@ -76,6 +79,14 @@ export const backlogPolicyFor = (reason: SuspendedReason): BacklogPolicy =>
|
||||
export const isResumable = (reason: SuspendedReason): boolean =>
|
||||
BACKLOG_POLICY[reason].resumable;
|
||||
|
||||
/**
|
||||
* The reasons that lift, and therefore the ones a revocation has to re-stamp: a
|
||||
* row left holding a backlog under one of these hands it over the moment it
|
||||
* resumes.
|
||||
*/
|
||||
export const RESUMABLE_REASONS: readonly SuspendedReason[] =
|
||||
SUSPENDED_REASONS.filter(isResumable);
|
||||
|
||||
/** Whether a suspended row is in the state a given resume would lift. */
|
||||
export const suspendedFor = (
|
||||
row: Pick<DurableSubscription, 'suspendedAt' | 'suspendedReason'>,
|
||||
|
||||
@@ -232,10 +232,7 @@ export class FSService extends PuterService {
|
||||
if (actor.app || actor.accessToken) return undefined;
|
||||
if (!actor.user?.id) return undefined;
|
||||
|
||||
const stripped = permission.replaceAll(
|
||||
`${MANAGE_PERM_PREFIX}:`,
|
||||
'',
|
||||
);
|
||||
const stripped = PermissionUtil.stripManageArms(permission);
|
||||
const parts = PermissionUtil.split(stripped);
|
||||
const uid = parts[1];
|
||||
if (!uid) return undefined;
|
||||
@@ -269,10 +266,7 @@ export class FSService extends PuterService {
|
||||
if (actor.app || actor.accessToken) return undefined;
|
||||
if (!actor.user?.id) return undefined;
|
||||
|
||||
const stripped = permission.replaceAll(
|
||||
`${MANAGE_PERM_PREFIX}:`,
|
||||
'',
|
||||
);
|
||||
const stripped = PermissionUtil.stripManageArms(permission);
|
||||
const uid = PermissionUtil.split(stripped)[1];
|
||||
if (!uid) return undefined;
|
||||
|
||||
|
||||
@@ -189,6 +189,33 @@ export class SharePathMasker {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The address to publish for a path under a shared anchor, to a holder who does
|
||||
* not own it.
|
||||
*
|
||||
* The request-scoped masker is no use here: event dispatch runs outside a
|
||||
* request, and the subscription's anchor is the share root the holder came in
|
||||
* through anyway. A path that does not sit under the anchor — the stored anchor
|
||||
* path goes stale when something above it is renamed — falls back to the node's
|
||||
* own uid, which says no more than its name.
|
||||
*/
|
||||
export function maskUnderAnchor(
|
||||
anchor: { uid: string; path: string },
|
||||
entry: { path: string; uid: string },
|
||||
): string {
|
||||
const owner = entry.path.split('/')[1];
|
||||
if (!owner) return entry.path;
|
||||
if (
|
||||
entry.path === anchor.path ||
|
||||
entry.path.startsWith(`${anchor.path}/`)
|
||||
) {
|
||||
const root = `/${owner}/${anchor.uid}/${pathPosix.basename(anchor.path)}`;
|
||||
return root + entry.path.slice(anchor.path.length);
|
||||
}
|
||||
const name = pathPosix.basename(entry.path);
|
||||
return name ? `/${owner}/${entry.uid}/${name}` : entry.path;
|
||||
}
|
||||
|
||||
/**
|
||||
* The masker for the current request, created on first use.
|
||||
*
|
||||
|
||||
@@ -246,6 +246,7 @@ export class LocalWorkerService extends PuterService {
|
||||
);
|
||||
} else {
|
||||
const session = await this.services.auth.createWorkerSessionToken(
|
||||
ownerActor,
|
||||
ownerUser,
|
||||
workerName,
|
||||
);
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
* Puter is free software: you can redistribute it and/or modify it under the
|
||||
* terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option) any
|
||||
* later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
@@ -85,6 +86,27 @@ describe('PermissionUtil.isManage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PermissionUtil.stripManageArms', () => {
|
||||
it('takes the leading arms and stops', () => {
|
||||
expect(PermissionUtil.stripManageArms('manage:fs:uid')).toBe('fs:uid');
|
||||
expect(PermissionUtil.stripManageArms('manage:manage:fs:uid')).toBe(
|
||||
'fs:uid',
|
||||
);
|
||||
expect(PermissionUtil.stripManageArms('fs:uid')).toBe('fs:uid');
|
||||
});
|
||||
|
||||
it('leaves a component that happens to be `manage` alone', () => {
|
||||
// Unanchored stripping would reduce this to `fs:uid`, and the check
|
||||
// that follows would then run against the wrong slot.
|
||||
expect(PermissionUtil.stripManageArms('fs:manage:uid')).toBe(
|
||||
'fs:manage:uid',
|
||||
);
|
||||
expect(PermissionUtil.stripManageArms('manage:fs:manage:uid')).toBe(
|
||||
'fs:manage:uid',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PermissionUtil.permission_scan_cache_prefix_for_app_under_user', () => {
|
||||
it('builds a stable, escaped cache prefix for an app-under-user actor', () => {
|
||||
const prefix =
|
||||
|
||||
@@ -68,6 +68,19 @@ const unescape_permission_component = (component: string): string => {
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop the leading `manage:` arms from a permission, leaving the permission
|
||||
* they delegate over. Anchored, and only at the front: a component further in
|
||||
* may itself be `manage`, and taking those out would name a different
|
||||
* permission entirely.
|
||||
*/
|
||||
const stripManageArms = (permission: string): string => {
|
||||
const arm = `${MANAGE_PERM_PREFIX}:`;
|
||||
let out = permission;
|
||||
while (out.startsWith(arm)) out = out.slice(arm.length);
|
||||
return out;
|
||||
};
|
||||
|
||||
const escape_permission_component = (component: string): string => {
|
||||
let out = '';
|
||||
for (let i = 0; i < component.length; i++) {
|
||||
@@ -88,6 +101,7 @@ const escape_permission_component = (component: string): string => {
|
||||
export const PermissionUtil = {
|
||||
unescape_permission_component,
|
||||
escape_permission_component,
|
||||
stripManageArms,
|
||||
|
||||
split(permission: string): string[] {
|
||||
return permission.split(':').map(unescape_permission_component);
|
||||
|
||||
@@ -72,7 +72,10 @@ describe('ShareService', () => {
|
||||
return entry;
|
||||
};
|
||||
|
||||
/** A directory and a file inside it, so the file inherits the folder's shares. */
|
||||
/**
|
||||
* A directory and a file inside it, so the file inherits the folder's
|
||||
* shares.
|
||||
*/
|
||||
const makeDirWithFile = async (owner: { id: number; username: string }) => {
|
||||
const dirUuid = uuidv4();
|
||||
const dirName = `d-${dirUuid.slice(0, 8)}`;
|
||||
@@ -112,7 +115,8 @@ describe('ShareService', () => {
|
||||
actor,
|
||||
{
|
||||
path,
|
||||
resolveAncestors: () => server.services.fs.getAncestorChain(path),
|
||||
resolveAncestors: () =>
|
||||
server.services.fs.getAncestorChain(path),
|
||||
},
|
||||
'read',
|
||||
);
|
||||
@@ -399,6 +403,36 @@ describe('ShareService', () => {
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('refuses an app handing out `manage`, even where its reach is total', async () => {
|
||||
// Inside its own AppData the ACL short-circuit answers every mode, so
|
||||
// reach is no bound here — and `manage` is the right to re-share
|
||||
// onward in the user's name, which the user delegated to the app, not
|
||||
// through it.
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const app = await makeApp(owner.user.id);
|
||||
const file = await makeFile(owner.user);
|
||||
await grantAppReach(owner, app, file, 'manage');
|
||||
|
||||
await expect(
|
||||
share(asApp(owner, app), {
|
||||
uid: file.uuid,
|
||||
recipient: { username: recipient.user.username },
|
||||
mode: 'manage',
|
||||
}),
|
||||
).rejects.toMatchObject({ legacyCode: 'cannot_delegate_manage' });
|
||||
|
||||
// What it may still do is unchanged.
|
||||
await grantAppReach(owner, app, file, 'read');
|
||||
await expect(
|
||||
share(asApp(owner, app), {
|
||||
uid: file.uuid,
|
||||
recipient: { username: recipient.user.username },
|
||||
mode: 'read',
|
||||
}),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows the owner a share a manage delegate issued', async () => {
|
||||
const owner = await makeUser();
|
||||
const delegate = await makeUser();
|
||||
@@ -720,12 +754,10 @@ describe('ShareService', () => {
|
||||
expect(listed.items[0].path).toBe(
|
||||
`/${owner.user.username}/${file.uuid}/${file.name}`,
|
||||
);
|
||||
expect(listed.items[0].owner?.username).toBe(
|
||||
owner.user.username,
|
||||
);
|
||||
expect(listed.items[0].owner?.username).toBe(owner.user.username);
|
||||
});
|
||||
|
||||
it('drops a delegate-issued share from both listings once revoked, never from the recipient\'s', async () => {
|
||||
it("drops a delegate-issued share from both listings once revoked, never from the recipient's", async () => {
|
||||
const owner = await makeUser();
|
||||
const delegate = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
@@ -744,7 +776,10 @@ describe('ShareService', () => {
|
||||
|
||||
const heldByRecipient = (
|
||||
items: Array<{ holder: { username: string | null } }>,
|
||||
) => items.some((i) => i.holder.username === recipient.user.username);
|
||||
) =>
|
||||
items.some(
|
||||
(i) => i.holder.username === recipient.user.username,
|
||||
);
|
||||
|
||||
expect(
|
||||
heldByRecipient((await listSharedByMe(owner.actor)).items),
|
||||
@@ -1105,8 +1140,7 @@ describe('ShareService', () => {
|
||||
});
|
||||
|
||||
it('lets a session filter to one app, or to what it shared itself', async () => {
|
||||
const { owner, recipient, apps, files } =
|
||||
await shareThroughApps(2);
|
||||
const { owner, recipient, apps, files } = await shareThroughApps(2);
|
||||
const byHand = await makeFile(owner.user);
|
||||
await share(owner.actor, {
|
||||
uid: byHand.uuid,
|
||||
@@ -1154,14 +1188,13 @@ describe('ShareService', () => {
|
||||
|
||||
it('keeps the grouped view to user sessions', async () => {
|
||||
const { owner, apps } = await shareThroughApps(1);
|
||||
await expect(
|
||||
listApps(asApp(owner, apps[0])),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
await expect(listApps(asApp(owner, apps[0]))).rejects.toMatchObject(
|
||||
{ statusCode: 403 },
|
||||
);
|
||||
});
|
||||
|
||||
it('still shows and revokes what a removed app left behind', async () => {
|
||||
const { owner, recipient, apps, files } =
|
||||
await shareThroughApps(1);
|
||||
const { owner, recipient, apps, files } = await shareThroughApps(1);
|
||||
await server.stores.app.delete(apps[0].id);
|
||||
|
||||
const grouped = await listApps(owner.actor);
|
||||
@@ -1283,9 +1316,9 @@ describe('ShareService', () => {
|
||||
|
||||
const listed = await listSharedByMe(owner.actor);
|
||||
expect(listed.items[0].pending).toBe(true);
|
||||
expect(
|
||||
await revokeByUid(owner.actor, listed.items[0].uid),
|
||||
).toEqual({ revoked: 1 });
|
||||
expect(await revokeByUid(owner.actor, listed.items[0].uid)).toEqual(
|
||||
{ revoked: 1 },
|
||||
);
|
||||
expect(await server.stores.share.listPendingByEmail(email)).toEqual(
|
||||
[],
|
||||
);
|
||||
@@ -1318,14 +1351,12 @@ describe('ShareService', () => {
|
||||
recipient: { username: recipient.user.username },
|
||||
mode: 'write',
|
||||
});
|
||||
expect((await listSharedByMe(asApp(owner, app))).items).toEqual(
|
||||
[],
|
||||
);
|
||||
expect((await listSharedByMe(asApp(owner, app))).items).toEqual([]);
|
||||
const manual = await listSharedByMe(owner.actor, { appUid: null });
|
||||
expect(manual.items.map((i) => i.uid)).toEqual([uid]);
|
||||
await expect(revokeByUid(asApp(owner, app), uid)).rejects.toMatchObject(
|
||||
{ statusCode: 404 },
|
||||
);
|
||||
await expect(
|
||||
revokeByUid(asApp(owner, app), uid),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
|
||||
// And back: re-shared through the app, the same row is the app's
|
||||
// again.
|
||||
@@ -1343,7 +1374,7 @@ describe('ShareService', () => {
|
||||
|
||||
// Every uid the caller may not act on answers alike, or the endpoint
|
||||
// becomes a way to ask whether one exists.
|
||||
it('answers 404 for an unknown uid and for another account\'s', async () => {
|
||||
it("answers 404 for an unknown uid and for another account's", async () => {
|
||||
const owner = await makeUser();
|
||||
const stranger = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
@@ -1364,9 +1395,8 @@ describe('ShareService', () => {
|
||||
expect(await canRead(recipient.actor, file.path)).toBe(true);
|
||||
});
|
||||
|
||||
it('answers 404 when an app names another app\'s share', async () => {
|
||||
const { owner, recipient, apps, files } =
|
||||
await shareThroughApps(2);
|
||||
it("answers 404 when an app names another app's share", async () => {
|
||||
const { owner, recipient, apps, files } = await shareThroughApps(2);
|
||||
const listed = await listSharedByMe(asApp(owner, apps[1]));
|
||||
|
||||
await expect(
|
||||
@@ -1550,9 +1580,9 @@ describe('ShareService', () => {
|
||||
(i) => i.issuer.username === owner.user.username,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
trail.items.some((i) => i.entryUid === theirs.uuid),
|
||||
).toBe(false);
|
||||
expect(trail.items.some((i) => i.entryUid === theirs.uuid)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses the trail of an item the caller cannot manage', async () => {
|
||||
@@ -2330,7 +2360,15 @@ describe('ShareService', () => {
|
||||
const uuid = uuidv4();
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`, `parent_uid`) VALUES (?, ?, ?, ?, 1, ?, ?, ?)',
|
||||
[uuid, segment, dirPath, owner.user.id, now, parentId, parentUid],
|
||||
[
|
||||
uuid,
|
||||
segment,
|
||||
dirPath,
|
||||
owner.user.id,
|
||||
now,
|
||||
parentId,
|
||||
parentUid,
|
||||
],
|
||||
);
|
||||
const row = await server.stores.fsEntry.getEntryByPath(dirPath);
|
||||
parentId = row.id;
|
||||
@@ -2340,7 +2378,15 @@ describe('ShareService', () => {
|
||||
const filePath = `${dirPath}/state.json`;
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`, `parent_uid`) VALUES (?, ?, ?, ?, 0, ?, ?, ?)',
|
||||
[uuid, 'state.json', filePath, owner.user.id, now, parentId, parentUid],
|
||||
[
|
||||
uuid,
|
||||
'state.json',
|
||||
filePath,
|
||||
owner.user.id,
|
||||
now,
|
||||
parentId,
|
||||
parentUid,
|
||||
],
|
||||
);
|
||||
return server.stores.fsEntry.getEntryByPath(filePath);
|
||||
};
|
||||
@@ -4165,9 +4211,7 @@ describe('ShareService', () => {
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
await server.services.share.listBlockedSenders(
|
||||
recipient.actor,
|
||||
),
|
||||
await server.services.share.listBlockedSenders(recipient.actor),
|
||||
).toMatchObject({
|
||||
all: true,
|
||||
items: [{ username: sender.user.username }],
|
||||
@@ -4260,10 +4304,7 @@ describe('ShareService', () => {
|
||||
// since it was sent.
|
||||
const claimer = await makeUser();
|
||||
await server.stores.user.update(claimer.user.id, { email });
|
||||
await server.services.share.setBlockAllSenders(
|
||||
claimer.actor,
|
||||
true,
|
||||
);
|
||||
await server.services.share.setBlockAllSenders(claimer.actor, true);
|
||||
|
||||
expect(
|
||||
await server.services.share.claimPendingShares(
|
||||
@@ -4392,7 +4433,6 @@ describe('ShareService', () => {
|
||||
expect(await canRead(claimer.actor, file.path)).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
it('claims invites when the address arrives via OIDC signup', async () => {
|
||||
const owner = await makeUser();
|
||||
const file = await makeFile(owner.user);
|
||||
@@ -4484,8 +4524,14 @@ describe('ShareService', () => {
|
||||
// The row is claimed before any grant is written, so whichever
|
||||
// call loses the row has granted nothing it must take back.
|
||||
const [a, b] = await Promise.all([
|
||||
server.services.share.claimPendingShares(claimer.user.id, email),
|
||||
server.services.share.claimPendingShares(claimer.user.id, email),
|
||||
server.services.share.claimPendingShares(
|
||||
claimer.user.id,
|
||||
email,
|
||||
),
|
||||
server.services.share.claimPendingShares(
|
||||
claimer.user.id,
|
||||
email,
|
||||
),
|
||||
]);
|
||||
|
||||
expect(a.length + b.length).toBe(1);
|
||||
|
||||
@@ -3013,6 +3013,18 @@ export class ShareService extends PuterService {
|
||||
entry: FSEntry,
|
||||
mode: AclMode = 'see',
|
||||
): Promise<void> {
|
||||
// `manage` is a delegation right, not access: it lets the recipient
|
||||
// re-share onward in the user's name. An app inherits the authority to
|
||||
// share but not the authority to pass that on — and inside its own
|
||||
// AppData the ACL short-circuit would otherwise supply every mode.
|
||||
if (mode === MANAGE_PERM_PREFIX && actor.effectiveApp) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'An app cannot grant edit & share access',
|
||||
{ legacyCode: 'cannot_delegate_manage' },
|
||||
);
|
||||
}
|
||||
|
||||
// Authority to share lives with the user: they own the node, or hold a
|
||||
// `manage` grant on it. An app inherits that authority but is not the
|
||||
// one who has it, so this asks the user behind the actor.
|
||||
|
||||
@@ -30,6 +30,7 @@ import { EVENTS_DURABLE_SUBSCRIPTIONS_MAX } from '../../controllers/events/limit
|
||||
import { isHttpError } from '../../core/http/HttpError.js';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { EVENT_SUBSCRIPTION_WIDTHS } from './columnWidths.js';
|
||||
import type { DurableSubscriptionInput } from './DurableSubscriptionStore.js';
|
||||
|
||||
const BOOT_TIMEOUT_MS = 120_000;
|
||||
@@ -73,6 +74,14 @@ const input = (
|
||||
const codeOf = (code: string) => (err: unknown) =>
|
||||
isHttpError(err) && err.legacyCode === code;
|
||||
|
||||
type GuardedColumn = keyof typeof EVENT_SUBSCRIPTION_WIDTHS;
|
||||
|
||||
const atWidth = (column: GuardedColumn): string =>
|
||||
'a'.repeat(EVENT_SUBSCRIPTION_WIDTHS[column].max);
|
||||
|
||||
const overWidth = (column: GuardedColumn): string =>
|
||||
'a'.repeat(EVENT_SUBSCRIPTION_WIDTHS[column].max + 1);
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({ events: { enabled: true } } as IConfig);
|
||||
const user = await env.server.stores.user.getByUsername(
|
||||
@@ -268,6 +277,67 @@ describe('validation at the row write', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('column-width guards at the row write', () => {
|
||||
it('accepts an `anchorUid` right at the column width', async () => {
|
||||
await expect(
|
||||
durable().create(input({ anchorUid: atWidth('anchorUid') })),
|
||||
).resolves.toMatchObject({ row: { anchorUid: atWidth('anchorUid') } });
|
||||
});
|
||||
|
||||
it('refuses an `anchorUid` one character over the column width', async () => {
|
||||
await expect(
|
||||
durable().create(input({ anchorUid: overWidth('anchorUid') })),
|
||||
).rejects.toSatisfy(codeOf('events_value_too_large'));
|
||||
await expect(durable().countForHolder(userId)).resolves.toMatchObject({
|
||||
total: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a `token` right at the column width', async () => {
|
||||
await expect(
|
||||
durable().create(input({ token: atWidth('token') })),
|
||||
).resolves.toMatchObject({ row: { token: atWidth('token') } });
|
||||
});
|
||||
|
||||
it('refuses a `token` one character over the column width', async () => {
|
||||
await expect(
|
||||
durable().create(input({ token: overWidth('token') })),
|
||||
).rejects.toSatisfy(codeOf('events_value_too_large'));
|
||||
});
|
||||
|
||||
it('refuses a `subject` one character over the column width', async () => {
|
||||
await expect(
|
||||
durable().create(input({ subject: overWidth('subject') })),
|
||||
).rejects.toSatisfy(codeOf('events_value_too_large'));
|
||||
});
|
||||
|
||||
it('refuses an `anchorPath` one character over the column width', async () => {
|
||||
await expect(
|
||||
durable().create(input({ anchorPath: overWidth('anchorPath') })),
|
||||
).rejects.toSatisfy(codeOf('events_value_too_large'));
|
||||
});
|
||||
|
||||
it('refuses an over-long `anchorPath` on reanchor and leaves the row unchanged', async () => {
|
||||
const { row } = await durable().create(input());
|
||||
|
||||
await expect(
|
||||
durable().reanchor(row, {
|
||||
token: 'f#reanchored',
|
||||
anchorUid,
|
||||
anchorPath: overWidth('anchorPath'),
|
||||
match: 'm',
|
||||
ownerUserId: userId,
|
||||
}),
|
||||
).rejects.toSatisfy(codeOf('events_value_too_large'));
|
||||
|
||||
await expect(durable().getBySubId(row.subId)).resolves.toMatchObject({
|
||||
token: row.token,
|
||||
anchorUid: row.anchorUid,
|
||||
anchorPath: row.anchorPath,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('the per-account cap', () => {
|
||||
const fill = async (count: number, appUid: string | null = null) => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
@@ -31,6 +31,10 @@ import {
|
||||
type PageResult,
|
||||
} from '../../util/pagination.js';
|
||||
import { PuterStore } from '../types.js';
|
||||
import {
|
||||
assertColumnWidths,
|
||||
EVENT_SUBSCRIPTION_WIDTHS,
|
||||
} from './columnWidths.js';
|
||||
import type { GenerationBump } from './EventSubscriptionStore.js';
|
||||
import {
|
||||
isSubscriptionTarget,
|
||||
@@ -276,6 +280,16 @@ export class DurableSubscriptionStore extends PuterStore {
|
||||
input.appUid,
|
||||
);
|
||||
this.#assertContext(input.context);
|
||||
assertColumnWidths(EVENT_SUBSCRIPTION_WIDTHS, {
|
||||
token: input.token,
|
||||
appUid: input.appUid,
|
||||
subject: input.subject,
|
||||
anchorUid: input.anchorUid,
|
||||
anchorPath: input.anchorPath,
|
||||
match: input.match,
|
||||
handlerName: input.handlerName,
|
||||
permission: input.permission,
|
||||
});
|
||||
|
||||
const perUser = Math.min(
|
||||
input.limits?.perUser ?? EVENTS_DURABLE_SUBSCRIPTIONS_MAX,
|
||||
@@ -312,6 +326,7 @@ export class DurableSubscriptionStore extends PuterStore {
|
||||
suspendedReason: null,
|
||||
createdAt: nowSeconds(),
|
||||
};
|
||||
assertColumnWidths(EVENT_SUBSCRIPTION_WIDTHS, { subId: row.subId });
|
||||
|
||||
await this.clients.db.insert(TABLE, {
|
||||
sub_id: row.subId,
|
||||
@@ -360,20 +375,30 @@ export class DurableSubscriptionStore extends PuterStore {
|
||||
async suspend(
|
||||
rows: readonly DurableSubscription[],
|
||||
reason: SuspendedReason,
|
||||
opts: { override?: readonly SuspendedReason[] } = {},
|
||||
): Promise<{ suspended: DurableSubscription[]; bumps: GenerationBump[] }> {
|
||||
if (rows.length === 0) return { suspended: [], bumps: [] };
|
||||
|
||||
// One conditional write per row, so two settles racing over the same
|
||||
// rows — an unshare withdraws several grant strings in a row — each
|
||||
// learn exactly which rows they were the one to suspend.
|
||||
//
|
||||
// `override` re-stamps a row already out of service for one of the
|
||||
// named reasons, so a revocation reaches it and its backlog is purged
|
||||
// rather than waiting for a resume that must never come.
|
||||
const override = opts.override ?? [];
|
||||
const condition = override.length
|
||||
? '(`suspended_at` IS NULL OR `suspended_reason` IN ' +
|
||||
`(${override.map(() => '?').join(', ')}))`
|
||||
: '`suspended_at` IS NULL';
|
||||
const at = nowSeconds();
|
||||
const suspended: DurableSubscription[] = [];
|
||||
for (const row of rows) {
|
||||
const written = await this.clients.db.write(
|
||||
`UPDATE \`${TABLE}\` SET \`suspended_at\` = ?, ` +
|
||||
'`suspended_reason` = ? ' +
|
||||
'WHERE `sub_id` = ? AND `suspended_at` IS NULL',
|
||||
[at, reason, row.subId],
|
||||
`WHERE \`sub_id\` = ? AND ${condition}`,
|
||||
[at, reason, row.subId, ...override],
|
||||
);
|
||||
if (written.anyRowsAffected)
|
||||
suspended.push({
|
||||
@@ -442,6 +467,13 @@ export class DurableSubscriptionStore extends PuterStore {
|
||||
row: DurableSubscription,
|
||||
next: ReanchorInput,
|
||||
): Promise<{ row: DurableSubscription; bumps: GenerationBump[] }> {
|
||||
assertColumnWidths(EVENT_SUBSCRIPTION_WIDTHS, {
|
||||
token: next.token,
|
||||
anchorUid: next.anchorUid,
|
||||
anchorPath: next.anchorPath,
|
||||
match: next.match,
|
||||
});
|
||||
|
||||
await this.clients.db.write(
|
||||
`UPDATE \`${TABLE}\` SET \`token\` = ?, \`anchor_uid\` = ?, ` +
|
||||
'`anchor_path` = ?, `match` = ?, `owner_user_id` = ? ' +
|
||||
@@ -601,15 +633,20 @@ export class DurableSubscriptionStore extends PuterStore {
|
||||
* index the listing and the quota use — passing `appUid` narrows to one
|
||||
* app's rows, which is what a grant made to that app can have authorized.
|
||||
*
|
||||
* `includeSuspended` also returns rows already out of service. A revocation
|
||||
* needs them: the three resumable reasons hold a backlog, and a row skipped
|
||||
* here comes back in service on the next resume and hands it over.
|
||||
*
|
||||
* Bounded by the per-account quota, so the whole set fits one read.
|
||||
*/
|
||||
async listActiveForHolder(
|
||||
holderUserId: number,
|
||||
appUid: string | null,
|
||||
opts: { includeSuspended?: boolean } = {},
|
||||
): Promise<DurableSubscription[]> {
|
||||
const where = [
|
||||
'`holder_user_id` = ?',
|
||||
'`suspended_at` IS NULL',
|
||||
...(opts.includeSuspended ? [] : ['`suspended_at` IS NULL']),
|
||||
this.#unexpiredClause(),
|
||||
];
|
||||
const params: unknown[] = [holderUserId, nowSeconds()];
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The store's own guard against a row wider than the column that holds it.
|
||||
*
|
||||
* `EventsService.mintKvHandle` already bounds `keyPrefix` and `permission` well
|
||||
* under these widths before the store ever sees them, so reaching the guard
|
||||
* here means calling the store directly.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { isHttpError } from '../../core/http/HttpError.js';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { KV_SHARE_HANDLE_WIDTHS } from './columnWidths.js';
|
||||
import type { MintKvShareHandleInput } from './KvShareHandleStore.js';
|
||||
|
||||
const BOOT_TIMEOUT_MS = 120_000;
|
||||
|
||||
let env: PuterTestEnv;
|
||||
let ownerUserId: number;
|
||||
let granteeUserId: number;
|
||||
|
||||
const store = () => env.server.stores.kvShareHandle;
|
||||
|
||||
const input = (
|
||||
over: Partial<MintKvShareHandleInput> = {},
|
||||
): MintKvShareHandleInput => ({
|
||||
ownerUserId,
|
||||
granteeUserId,
|
||||
appUid: 'app-column-widths',
|
||||
keyPrefix: 'workspace:',
|
||||
permission: 'kv-share:owner-uuid:app-column-widths:workspace',
|
||||
...over,
|
||||
});
|
||||
|
||||
const codeOf = (code: string) => (err: unknown) =>
|
||||
isHttpError(err) && err.legacyCode === code;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({ events: { enabled: true } } as IConfig);
|
||||
const owner = await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
);
|
||||
ownerUserId = owner!.id;
|
||||
const grantee = await env.server.stores.user.getByUsername(
|
||||
env.users.other.username,
|
||||
);
|
||||
granteeUserId = grantee!.id;
|
||||
}, BOOT_TIMEOUT_MS);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
describe('minting rejects a value the column would truncate', () => {
|
||||
it('accepts a `keyPrefix` right at the column width', async () => {
|
||||
const max = KV_SHARE_HANDLE_WIDTHS.keyPrefix.max;
|
||||
const row = await store().mint(input({ keyPrefix: 'a'.repeat(max) }));
|
||||
expect(row.keyPrefix).toBe('a'.repeat(max));
|
||||
});
|
||||
|
||||
it('refuses a `keyPrefix` one character over the column width', async () => {
|
||||
const max = KV_SHARE_HANDLE_WIDTHS.keyPrefix.max;
|
||||
await expect(
|
||||
store().mint(input({ keyPrefix: 'a'.repeat(max + 1) })),
|
||||
).rejects.toSatisfy(codeOf('events_value_too_large'));
|
||||
});
|
||||
|
||||
it('accepts a `permission` right at the column width', async () => {
|
||||
const max = KV_SHARE_HANDLE_WIDTHS.permission.max;
|
||||
const row = await store().mint(input({ permission: 'a'.repeat(max) }));
|
||||
expect(row.permission).toBe('a'.repeat(max));
|
||||
});
|
||||
|
||||
it('refuses a `permission` one character over the column width', async () => {
|
||||
const max = KV_SHARE_HANDLE_WIDTHS.permission.max;
|
||||
await expect(
|
||||
store().mint(input({ permission: 'a'.repeat(max + 1) })),
|
||||
).rejects.toSatisfy(codeOf('events_value_too_large'));
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type PageResult,
|
||||
} from '../../util/pagination.js';
|
||||
import { PuterStore } from '../types.js';
|
||||
import { assertColumnWidths, KV_SHARE_HANDLE_WIDTHS } from './columnWidths.js';
|
||||
|
||||
/**
|
||||
* Opaque names for shared regions of a user's key-value namespace.
|
||||
@@ -109,6 +110,13 @@ export class KvShareHandleStore extends PuterStore {
|
||||
createdAt: nowSeconds(),
|
||||
revokedAt: null,
|
||||
};
|
||||
assertColumnWidths(KV_SHARE_HANDLE_WIDTHS, {
|
||||
handle: row.handle,
|
||||
appUid: row.appUid,
|
||||
keyPrefix: row.keyPrefix,
|
||||
permission: row.permission,
|
||||
});
|
||||
|
||||
await this.clients.db.insert(TABLE, {
|
||||
handle: row.handle,
|
||||
owner_user_id: row.ownerUserId,
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { compareMigrationFilenames } from '../../clients/database/migrationFilenames.js';
|
||||
import { isHttpError } from '../../core/http/HttpError.js';
|
||||
import {
|
||||
assertColumnWidths,
|
||||
ENUM_COLUMNS,
|
||||
EVENT_SUBSCRIPTION_WIDTHS,
|
||||
KV_SHARE_HANDLE_WIDTHS,
|
||||
} from './columnWidths.js';
|
||||
|
||||
/** One character past a column's width. */
|
||||
const over = (spec: { max: number }): string => 'a'.repeat(spec.max + 1);
|
||||
|
||||
/** The message of the rejection `run` must produce. */
|
||||
const messageFor = (run: () => void): string => {
|
||||
try {
|
||||
run();
|
||||
} catch (err) {
|
||||
if (isHttpError(err)) return err.message;
|
||||
throw err;
|
||||
}
|
||||
throw new Error('expected assertColumnWidths to throw');
|
||||
};
|
||||
|
||||
const expectTooLarge = (run: () => void): void => {
|
||||
try {
|
||||
run();
|
||||
} catch (err) {
|
||||
if (!isHttpError(err)) throw err;
|
||||
expect(err.statusCode).toBe(413);
|
||||
expect(err.legacyCode).toBe('events_value_too_large');
|
||||
return;
|
||||
}
|
||||
throw new Error('expected assertColumnWidths to throw');
|
||||
};
|
||||
|
||||
describe.each([
|
||||
['EVENT_SUBSCRIPTION_WIDTHS', EVENT_SUBSCRIPTION_WIDTHS],
|
||||
['KV_SHARE_HANDLE_WIDTHS', KV_SHARE_HANDLE_WIDTHS],
|
||||
] as const)('%s', (_label, widths) => {
|
||||
for (const [key, spec] of Object.entries(widths)) {
|
||||
it(`accepts \`${key}\` right at ${spec.max} characters`, () => {
|
||||
expect(() =>
|
||||
assertColumnWidths(widths, { [key]: 'a'.repeat(spec.max) }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it(`refuses \`${key}\` one character over ${spec.max}`, () => {
|
||||
expectTooLarge(() =>
|
||||
assertColumnWidths(widths, {
|
||||
[key]: 'a'.repeat(spec.max + 1),
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('assertColumnWidths', () => {
|
||||
it('skips null and undefined values', () => {
|
||||
expect(() =>
|
||||
assertColumnWidths(EVENT_SUBSCRIPTION_WIDTHS, {
|
||||
anchorUid: null,
|
||||
anchorPath: undefined,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('reports the caller-facing field name, not the object key', () => {
|
||||
expect(
|
||||
messageFor(() =>
|
||||
assertColumnWidths(EVENT_SUBSCRIPTION_WIDTHS, {
|
||||
anchorUid: over(EVENT_SUBSCRIPTION_WIDTHS.anchorUid),
|
||||
}),
|
||||
),
|
||||
).toBe('`anchor.uid` may not exceed 40 characters');
|
||||
|
||||
expect(
|
||||
messageFor(() =>
|
||||
assertColumnWidths(EVENT_SUBSCRIPTION_WIDTHS, {
|
||||
anchorPath: over(EVENT_SUBSCRIPTION_WIDTHS.anchorPath),
|
||||
}),
|
||||
),
|
||||
).toContain('`anchor.path`');
|
||||
|
||||
expect(
|
||||
messageFor(() =>
|
||||
assertColumnWidths(KV_SHARE_HANDLE_WIDTHS, {
|
||||
keyPrefix: over(KV_SHARE_HANDLE_WIDTHS.keyPrefix),
|
||||
}),
|
||||
),
|
||||
).toContain('`prefix`');
|
||||
});
|
||||
|
||||
it('falls back to the object key when no field label is set', () => {
|
||||
expect(
|
||||
messageFor(() =>
|
||||
assertColumnWidths(EVENT_SUBSCRIPTION_WIDTHS, {
|
||||
token: over(EVENT_SUBSCRIPTION_WIDTHS.token),
|
||||
}),
|
||||
),
|
||||
).toBe('`token` may not exceed 255 characters');
|
||||
});
|
||||
|
||||
it('rejects a value made entirely of astral characters at half the real capacity', () => {
|
||||
// An astral character is 2 code units to JS and 1 character to MySQL,
|
||||
// so a run of them is rejected early — never late.
|
||||
const max = EVENT_SUBSCRIPTION_WIDTHS.appUid.max;
|
||||
const atLimit = '\u{1F600}'.repeat(max / 2);
|
||||
const overLimit = '\u{1F600}'.repeat(max / 2 + 1);
|
||||
expect(atLimit.length).toBe(max);
|
||||
|
||||
expect(() =>
|
||||
assertColumnWidths(EVENT_SUBSCRIPTION_WIDTHS, { appUid: atLimit }),
|
||||
).not.toThrow();
|
||||
expectTooLarge(() =>
|
||||
assertColumnWidths(EVENT_SUBSCRIPTION_WIDTHS, {
|
||||
appUid: overLimit,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -- Schema drift ------------------------------------------------------
|
||||
|
||||
const MIGRATIONS_DIR = join(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'../../clients/database/migrations/mysql',
|
||||
);
|
||||
|
||||
/**
|
||||
* The width of every `(var)char` column a table declares, as of the last
|
||||
* migration that touched it: the initial `CREATE TABLE`, then every later
|
||||
* `MODIFY [COLUMN]` in numeric migration order.
|
||||
*/
|
||||
const declaredWidths = (table: string): Map<string, number> => {
|
||||
const widths = new Map<string, number>();
|
||||
const columnPattern = /`(\w+)`\s+(?:var)?char\((\d+)\)/gi;
|
||||
const files = readdirSync(MIGRATIONS_DIR)
|
||||
.filter((name) => name.endsWith('.sql'))
|
||||
.sort(compareMigrationFilenames);
|
||||
|
||||
for (const file of files) {
|
||||
const sql = readFileSync(join(MIGRATIONS_DIR, file), 'utf8');
|
||||
|
||||
const create = new RegExp(
|
||||
`CREATE TABLE IF NOT EXISTS \`${table}\`[\\s\\S]*?\\)\\s*ENGINE=`,
|
||||
'i',
|
||||
).exec(sql)?.[0];
|
||||
if (create)
|
||||
for (const m of create.matchAll(columnPattern))
|
||||
widths.set(m[1], Number(m[2]));
|
||||
|
||||
const alterPattern = new RegExp(
|
||||
`ALTER TABLE \`${table}\`\\s+MODIFY(?:\\s+COLUMN)?\\s+` +
|
||||
'`(\\w+)`\\s+(?:var)?char\\((\\d+)\\)',
|
||||
'gi',
|
||||
);
|
||||
for (const m of sql.matchAll(alterPattern))
|
||||
widths.set(m[1], Number(m[2]));
|
||||
}
|
||||
|
||||
return widths;
|
||||
};
|
||||
|
||||
describe('declared column widths, read from the mysql migrations', () => {
|
||||
it.each([
|
||||
['event_subscriptions', EVENT_SUBSCRIPTION_WIDTHS],
|
||||
['kv_share_handles', KV_SHARE_HANDLE_WIDTHS],
|
||||
] as const)(
|
||||
'%s: every guarded column matches its migration width',
|
||||
(table, widths) => {
|
||||
const declared = declaredWidths(table);
|
||||
for (const spec of Object.values(widths))
|
||||
expect(declared.get(spec.column)).toBe(spec.max);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['event_subscriptions', EVENT_SUBSCRIPTION_WIDTHS],
|
||||
['kv_share_handles', KV_SHARE_HANDLE_WIDTHS],
|
||||
] as const)(
|
||||
'%s: every declared char/varchar column is guarded or a known enum',
|
||||
(table, widths) => {
|
||||
const guarded = new Set(
|
||||
Object.values(widths).map((spec) => spec.column),
|
||||
);
|
||||
const declared = declaredWidths(table);
|
||||
// Without this the loop below passes on an empty parse.
|
||||
expect(declared.size).toBeGreaterThanOrEqual(guarded.size);
|
||||
for (const column of declared.keys())
|
||||
expect(
|
||||
guarded.has(column) || ENUM_COLUMNS.includes(column),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
|
||||
/** How wide one column is, and the name a caller knows the value by. */
|
||||
interface ColumnWidth {
|
||||
/** Column in the migrations, so a schema change is greppable from here. */
|
||||
column: string;
|
||||
max: number;
|
||||
/** Caller-facing name for the error message; defaults to the key. */
|
||||
field?: string;
|
||||
}
|
||||
|
||||
export const EVENT_SUBSCRIPTION_WIDTHS = {
|
||||
subId: { column: 'sub_id', max: 80 },
|
||||
token: { column: 'token', max: 255 },
|
||||
appUid: { column: 'app_uid', max: 40 },
|
||||
subject: { column: 'subject', max: 4096 },
|
||||
anchorUid: { column: 'anchor_uid', max: 40, field: 'anchor.uid' },
|
||||
anchorPath: { column: 'anchor_path', max: 4096, field: 'anchor.path' },
|
||||
match: { column: 'match', max: 1024 },
|
||||
handlerName: { column: 'handler_name', max: 128 },
|
||||
permission: { column: 'permission', max: 1024 },
|
||||
} as const satisfies Record<string, ColumnWidth>;
|
||||
|
||||
export const KV_SHARE_HANDLE_WIDTHS = {
|
||||
handle: { column: 'handle', max: 64 },
|
||||
appUid: { column: 'app_uid', max: 40 },
|
||||
keyPrefix: { column: 'key_prefix', max: 1024, field: 'prefix' },
|
||||
permission: { column: 'permission', max: 1024 },
|
||||
} as const satisfies Record<string, ColumnWidth>;
|
||||
|
||||
/** Columns on these tables held to a server-side enum rather than a width. */
|
||||
export const ENUM_COLUMNS: readonly string[] = [
|
||||
'delivery',
|
||||
'ops',
|
||||
'suspended_reason',
|
||||
];
|
||||
|
||||
const valueTooLong = (field: string, max: number): HttpError =>
|
||||
new HttpError(413, `\`${field}\` may not exceed ${max} characters`, {
|
||||
legacyCode: 'events_value_too_large',
|
||||
});
|
||||
|
||||
/**
|
||||
* Refuse a value the column would otherwise truncate. A truncated grant string
|
||||
* is a different grant, so this cannot be left to the database's `sql_mode`.
|
||||
*
|
||||
* Counts UTF-16 code units, not code points, like every other length check in
|
||||
* this codebase — it only ever rejects early relative to MySQL, never late.
|
||||
*/
|
||||
export const assertColumnWidths = <K extends string>(
|
||||
widths: Record<K, ColumnWidth>,
|
||||
values: Partial<Record<K, string | null | undefined>>,
|
||||
): void => {
|
||||
for (const key of Object.keys(values) as K[]) {
|
||||
const value = values[key];
|
||||
if (typeof value !== 'string') continue;
|
||||
const spec = widths[key];
|
||||
if (value.length > spec.max)
|
||||
throw valueTooLong(spec.field ?? key, spec.max);
|
||||
}
|
||||
};
|
||||
@@ -525,6 +525,38 @@ describe('PermissionStore', () => {
|
||||
).toEqual(['fs:abc:read', 'fs:abc:write']);
|
||||
});
|
||||
|
||||
it('does not let a wildcard or a partial segment widen a prefix', async () => {
|
||||
const issuer = await makeUser();
|
||||
const holder = await makeUser();
|
||||
|
||||
await store.upsertUserUserPerm(holder.id, issuer.id, 'fs:abc', {});
|
||||
await store.upsertUserUserPerm(
|
||||
holder.id,
|
||||
issuer.id,
|
||||
'fs:abcdef:read',
|
||||
{},
|
||||
);
|
||||
await store.upsertUserUserPerm(holder.id, issuer.id, 'fsXy', {});
|
||||
|
||||
// `_` is a LIKE wildcard, so `fs_` unescaped would take `fsXy`.
|
||||
expect(
|
||||
await store.queryIssuerHolderPermsByPrefix(
|
||||
issuer.id,
|
||||
holder.id,
|
||||
'fs_',
|
||||
),
|
||||
).toEqual([]);
|
||||
|
||||
// A prefix ends on a segment boundary: `fs:abc` is not `fs:abcdef`.
|
||||
expect(
|
||||
await store.queryIssuerHolderPermsByPrefix(
|
||||
issuer.id,
|
||||
holder.id,
|
||||
'fs:abc',
|
||||
),
|
||||
).toEqual(['fs:abc']);
|
||||
});
|
||||
|
||||
it('lists the apps an issuer granted under a prefix', async () => {
|
||||
const issuer = await makeUser();
|
||||
const app = await makeApp(issuer.id);
|
||||
@@ -1139,8 +1171,13 @@ describe('PermissionStore', () => {
|
||||
await record(issuer.id, holder.id, permission, 'grant', { appUid });
|
||||
await record(issuer.id, holder.id, permission, 'revoke');
|
||||
|
||||
const page = await store.listUserUserAudit({ permissions: [permission] });
|
||||
expect(page.items.map((r) => r.action)).toEqual(['revoke', 'grant']);
|
||||
const page = await store.listUserUserAudit({
|
||||
permissions: [permission],
|
||||
});
|
||||
expect(page.items.map((r) => r.action)).toEqual([
|
||||
'revoke',
|
||||
'grant',
|
||||
]);
|
||||
expect(page.items[1].extra).toEqual({ appUid });
|
||||
expect(page.items[1].issuer_user_id).toBe(issuer.id);
|
||||
expect(page.items[1].holder_user_id).toBe(holder.id);
|
||||
@@ -1186,9 +1223,9 @@ describe('PermissionStore', () => {
|
||||
const page = await store.listUserUserAudit({
|
||||
issuerUserId: issuer.id,
|
||||
});
|
||||
expect(page.items.every((r) => r.issuer_user_id === issuer.id)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
page.items.every((r) => r.issuer_user_id === issuer.id),
|
||||
).toBe(true);
|
||||
await expect(store.listUserUserAudit({})).rejects.toThrow(
|
||||
/requires a filter/u,
|
||||
);
|
||||
|
||||
@@ -116,6 +116,14 @@ export interface FlatPermRef {
|
||||
permission: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A prefix as `subtreeClause` wants it. A caller may write the delimiter it is
|
||||
* matching under (`fs:`) or leave it off (`fs:<uuid>`); both mean the same
|
||||
* subtree, and the clause supplies the delimiter itself.
|
||||
*/
|
||||
const subtreeRoot = (prefix: string): string =>
|
||||
prefix.endsWith(':') ? prefix.slice(0, -1) : prefix;
|
||||
|
||||
/**
|
||||
* Match a permission and everything beneath it.
|
||||
*
|
||||
@@ -1064,10 +1072,11 @@ export class PermissionStore extends PuterStore {
|
||||
groupId: number,
|
||||
prefix: string,
|
||||
): Promise<string[]> {
|
||||
const subtree = subtreeClause([subtreeRoot(prefix)]);
|
||||
const rows = await this.clients.db.read(
|
||||
'SELECT permission FROM `user_to_group_permissions` ' +
|
||||
'WHERE `user_id` = ? AND `group_id` = ? AND permission LIKE ?',
|
||||
[issuerUserId, groupId, `${prefix}%`],
|
||||
`WHERE \`user_id\` = ? AND \`group_id\` = ? AND (${subtree.where})`,
|
||||
[issuerUserId, groupId, ...subtree.params],
|
||||
);
|
||||
return rows.map((r) => String(r.permission));
|
||||
}
|
||||
@@ -1153,15 +1162,20 @@ export class PermissionStore extends PuterStore {
|
||||
}
|
||||
|
||||
// -- SQL: issuer-prefix queries (share discovery, etc.) ----------
|
||||
//
|
||||
// All of these go through `subtreeClause`: a prefix is a permission and
|
||||
// everything under it, so `fs:abc` must not reach `fs:abcdef`, and a `%`
|
||||
// or `_` in one must not widen the match.
|
||||
|
||||
async queryIssuerUserPermsByPrefix(
|
||||
issuerUserId: number,
|
||||
prefix: string,
|
||||
): Promise<Array<{ holder_user_id: number; permission: string }>> {
|
||||
const subtree = subtreeClause([subtreeRoot(prefix)]);
|
||||
const rows = await this.clients.db.read(
|
||||
'SELECT DISTINCT holder_user_id, permission FROM `user_to_user_permissions` ' +
|
||||
'WHERE issuer_user_id = ? AND permission LIKE ?',
|
||||
[issuerUserId, `${prefix}%`],
|
||||
`WHERE issuer_user_id = ? AND (${subtree.where})`,
|
||||
[issuerUserId, ...subtree.params],
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
holder_user_id: Number(r.holder_user_id),
|
||||
@@ -1173,10 +1187,11 @@ export class PermissionStore extends PuterStore {
|
||||
issuerUserId: number,
|
||||
prefix: string,
|
||||
): Promise<Array<{ app_id: number; permission: string }>> {
|
||||
const subtree = subtreeClause([subtreeRoot(prefix)]);
|
||||
const rows = await this.clients.db.read(
|
||||
'SELECT DISTINCT app_id, permission FROM `user_to_app_permissions` ' +
|
||||
'WHERE user_id = ? AND permission LIKE ?',
|
||||
[issuerUserId, `${prefix}%`],
|
||||
`WHERE user_id = ? AND (${subtree.where})`,
|
||||
[issuerUserId, ...subtree.params],
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
app_id: Number(r.app_id),
|
||||
@@ -1189,10 +1204,11 @@ export class PermissionStore extends PuterStore {
|
||||
holderUserId: number,
|
||||
prefix: string,
|
||||
): Promise<string[]> {
|
||||
const subtree = subtreeClause([subtreeRoot(prefix)]);
|
||||
const rows = await this.clients.db.read(
|
||||
'SELECT permission FROM `user_to_user_permissions` ' +
|
||||
'WHERE issuer_user_id = ? AND holder_user_id = ? AND permission LIKE ?',
|
||||
[issuerUserId, holderUserId, `${prefix}%`],
|
||||
`WHERE issuer_user_id = ? AND holder_user_id = ? AND (${subtree.where})`,
|
||||
[issuerUserId, holderUserId, ...subtree.params],
|
||||
);
|
||||
return rows.map((r) => String(r.permission));
|
||||
}
|
||||
|
||||
@@ -279,6 +279,7 @@ export const createTestUser = async (
|
||||
// worker does (WorkerDriver falls back to createWorkerSessionToken).
|
||||
const { token: workerToken } =
|
||||
await server.services.auth.createWorkerSessionToken(
|
||||
makeActor({ user }),
|
||||
user,
|
||||
'puter-test-env-worker',
|
||||
);
|
||||
|
||||
@@ -284,7 +284,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.
|
||||
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`.
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user