mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-03 16:10:31 +00:00
Merge branch 'main' of https://github.com/HeyPuter/puter
This commit is contained in:
@@ -27,6 +27,17 @@ declare global {
|
||||
/** The raw token string, if one was presented and parsed. */
|
||||
token?: string;
|
||||
|
||||
/**
|
||||
* Set by the global auth probe when a token was extracted from
|
||||
* the request but failed to resolve to an actor (bad signature,
|
||||
* expired, references a deleted session/user/app, or has a
|
||||
* legacy shape v2 can't authenticate). Lets `requireAuthGate`
|
||||
* distinguish "no token" from "token present but invalid" and
|
||||
* emit the legacy `token_auth_failed` error so old clients
|
||||
* trigger their re-login flow.
|
||||
*/
|
||||
tokenAuthFailed?: boolean;
|
||||
|
||||
/**
|
||||
* Raw request body bytes, captured by the global JSON parser's
|
||||
* `verify` callback. Available for any JSON request — needed by
|
||||
|
||||
@@ -49,9 +49,21 @@ export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
|
||||
if (actor) {
|
||||
req.actor = actor;
|
||||
req.token = token;
|
||||
} else {
|
||||
// Token was present but didn't resolve — covers v1-shape
|
||||
// tokens v2 can't authenticate (e.g. FPE-encrypted session
|
||||
// UUIDs), as well as tokens whose session/user/app rows
|
||||
// have been deleted. `requireAuthGate` reads this flag
|
||||
// to emit `token_auth_failed` (matching v1) so clients
|
||||
// trigger their re-login flow instead of retrying.
|
||||
req.tokenAuthFailed = true;
|
||||
}
|
||||
} catch {
|
||||
// Probe never rejects — invalid tokens just leave `req.actor` undefined.
|
||||
// Probe never rejects — invalid tokens just leave `req.actor`
|
||||
// undefined. Same flag as above so the gate's response shape
|
||||
// is identical regardless of whether the failure came from
|
||||
// jwt.verify or a downstream lookup.
|
||||
req.tokenAuthFailed = true;
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
import type { RequestHandler } from 'express';
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
import { HttpError } from '../HttpError';
|
||||
|
||||
// Make sure the `Express.Request.actor` augmentation is in scope.
|
||||
import '../expressAugmentation';
|
||||
|
||||
/**
|
||||
* Build the 401 a route gate emits when `req.actor` is absent. Splits the
|
||||
* legacy code so old clients can tell the two cases apart: `token_missing`
|
||||
* means "send a token", `token_auth_failed` means "your token didn't work,
|
||||
* re-login". Matches the v1 backend's auth-failure shape so the existing
|
||||
* client retry-vs-reauth logic doesn't need to change.
|
||||
*/
|
||||
const rejectAuth = (req: Request): HttpError => {
|
||||
if (req.tokenAuthFailed) {
|
||||
return new HttpError(401, 'Authentication failed', {
|
||||
legacyCode: 'token_auth_failed',
|
||||
});
|
||||
}
|
||||
return new HttpError(401, 'Missing authentication token', {
|
||||
legacyCode: 'token_missing',
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-route gate middlewares.
|
||||
*
|
||||
@@ -55,11 +73,7 @@ export const subdomainGate = (allowed: string | string[]): RequestHandler => {
|
||||
export const requireAuthGate = (): RequestHandler => {
|
||||
return (req, _res, next) => {
|
||||
if (!req.actor) {
|
||||
next(
|
||||
new HttpError(401, 'Authentication required', {
|
||||
legacyCode: 'token_required',
|
||||
}),
|
||||
);
|
||||
next(rejectAuth(req));
|
||||
return;
|
||||
}
|
||||
if (req.actor.user.suspended) {
|
||||
@@ -86,11 +100,7 @@ export const requireUserActorGate = (): RequestHandler => {
|
||||
const actor = req.actor;
|
||||
// requireAuth runs first; this gate just narrows the actor type.
|
||||
if (!actor) {
|
||||
next(
|
||||
new HttpError(401, 'Authentication required', {
|
||||
legacyCode: 'token_required',
|
||||
}),
|
||||
);
|
||||
next(rejectAuth(req));
|
||||
return;
|
||||
}
|
||||
if (actor.app || actor.accessToken) {
|
||||
|
||||
@@ -341,7 +341,7 @@ export async function resolvePrivateIdentity(opts: {
|
||||
: null;
|
||||
if (privateCookieToken) {
|
||||
try {
|
||||
const claims = authService.verifyPrivateAssetToken(
|
||||
const claims = await authService.verifyPrivateAssetToken(
|
||||
privateCookieToken,
|
||||
{
|
||||
expectedAppUid,
|
||||
@@ -356,7 +356,7 @@ export async function resolvePrivateIdentity(opts: {
|
||||
hasValidPrivateCookie: true,
|
||||
};
|
||||
} catch {
|
||||
/* fall through — stale / mismatched cookie */
|
||||
/* fall through — stale / mismatched / logged-out cookie */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -424,20 +424,20 @@ export class AuthService extends PuterService {
|
||||
});
|
||||
}
|
||||
|
||||
verifyPrivateAssetToken(
|
||||
async verifyPrivateAssetToken(
|
||||
token: string,
|
||||
expected: {
|
||||
expectedAppUid?: string;
|
||||
expectedSubdomain?: string;
|
||||
expectedPrivateHost?: string;
|
||||
} = {},
|
||||
): {
|
||||
): Promise<{
|
||||
userUid: string;
|
||||
sessionUuid?: string;
|
||||
appUid?: string;
|
||||
subdomain?: string;
|
||||
privateHost?: string;
|
||||
} {
|
||||
}> {
|
||||
const decoded = this.#verifyHostedAssetToken(token, 'private');
|
||||
this.#assertExpected(
|
||||
decoded,
|
||||
@@ -457,9 +457,25 @@ export class AuthService extends PuterService {
|
||||
expected.expectedPrivateHost,
|
||||
'expectedPrivateHost',
|
||||
);
|
||||
|
||||
// Bind the cookie to the user's session lifetime: the session row
|
||||
// referenced at mint must still exist. Logging out drops the row,
|
||||
// which transitively invalidates every private-app cookie issued
|
||||
// under that session — matching v1's `expectedSessionUuid` check
|
||||
// (private apps only; public hosted-actor cookies stay informational).
|
||||
// Cookies minted without a session_uuid (e.g. from an access-token
|
||||
// actor) skip this check; nothing to bind.
|
||||
const sessionUuid = decoded.session_uuid as string | undefined;
|
||||
if (sessionUuid) {
|
||||
const session = await this.stores.session.getByUuid(sessionUuid);
|
||||
if (!session) {
|
||||
throw new Error('private-asset token session no longer valid');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
userUid: decoded.user_uid as string,
|
||||
sessionUuid: decoded.session_uuid as string | undefined,
|
||||
sessionUuid,
|
||||
appUid: decoded.app_uid as string | undefined,
|
||||
subdomain: decoded.subdomain as string | undefined,
|
||||
privateHost: decoded.host as string | undefined,
|
||||
|
||||
@@ -87,7 +87,10 @@ export class SessionStore extends PuterStore {
|
||||
await this.clients.db.write('DELETE FROM `sessions` WHERE `uuid` = ?', [
|
||||
uuid,
|
||||
]);
|
||||
await this.publishCacheKeys({ keys: [this.#cacheKey(uuid)] });
|
||||
await this.publishCacheKeys({
|
||||
keys: [this.#cacheKey(uuid)],
|
||||
broadcast: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Update session activity timestamp. */
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
SYSTEM_ACTOR_UUID,
|
||||
} from '../../core/actor';
|
||||
import { PUTER_KV_STORE_TABLE_DEFINITION } from './tableDefinition';
|
||||
import { HttpError } from '../../core/http';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -38,6 +39,7 @@ export interface RecursiveRecord<T> {
|
||||
const GLOBAL_APP_KEY = 'os-global';
|
||||
const SYSTEM_NAMESPACE = `v1:${SYSTEM_ACTOR_UUID}:${GLOBAL_APP_KEY}`;
|
||||
const MAX_KEY_BYTES = 1024;
|
||||
const MAX_VALUE_BYTES = 399 * 1024;
|
||||
const BATCH_GET_CHUNK = 100;
|
||||
const PATH_CLEANER_REGEX = /[:\-+/*]/g;
|
||||
|
||||
@@ -67,9 +69,27 @@ const getNamespace = (actor: Actor, appUuidOverride?: string): string => {
|
||||
};
|
||||
|
||||
const assertKey = (key: string): void => {
|
||||
if (key === '') throw new Error('kv: key is empty');
|
||||
if (key === '')
|
||||
throw new HttpError(400, 'kv: key is empty', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
if (Buffer.byteLength(key, 'utf8') > MAX_KEY_BYTES) {
|
||||
throw new Error(`kv: key exceeds ${MAX_KEY_BYTES} byte limit`);
|
||||
throw new HttpError(
|
||||
400,
|
||||
`kv: key exceeds ${MAX_KEY_BYTES} byte limit`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const assertValueSize = (value: unknown): void => {
|
||||
const size = Buffer.byteLength(JSON.stringify(value ?? null), 'utf8');
|
||||
if (size > MAX_VALUE_BYTES) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`kv: value exceeds ${MAX_VALUE_BYTES} byte limit`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -93,7 +113,9 @@ const decodeCursor = (
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
throw new Error('kv: invalid cursor');
|
||||
throw new HttpError(400, 'kv: invalid cursor', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -102,7 +124,9 @@ const normalizeLimit = (limit?: number): number | undefined => {
|
||||
if (limit === undefined || limit === null) return undefined;
|
||||
const parsed = Number(limit);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new Error('kv: limit must be a positive number');
|
||||
throw new HttpError(400, 'kv: limit must be a positive number', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
};
|
||||
@@ -110,7 +134,9 @@ const normalizeLimit = (limit?: number): number | undefined => {
|
||||
const normalizePattern = (pattern?: string): string | undefined => {
|
||||
if (pattern === undefined || pattern === null) return undefined;
|
||||
if (typeof pattern !== 'string')
|
||||
throw new Error('kv: pattern must be a string');
|
||||
throw new HttpError(400, 'kv: pattern must be a string', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
const trimmed = pattern.trim();
|
||||
if (trimmed === '') return undefined;
|
||||
if (trimmed.endsWith('*')) {
|
||||
@@ -226,6 +252,7 @@ export class SystemKVStore extends PuterStore {
|
||||
opts?: KVOpts,
|
||||
): Promise<KVResult<boolean>> {
|
||||
assertKey(key);
|
||||
assertValueSize(value);
|
||||
const actor = ensureActor(opts);
|
||||
const namespace = getNamespace(actor, opts?.appUuid);
|
||||
|
||||
@@ -261,6 +288,7 @@ export class SystemKVStore extends PuterStore {
|
||||
for (const item of items) {
|
||||
const k = String(item.key);
|
||||
assertKey(k);
|
||||
assertValueSize(item.value);
|
||||
byKey.set(k, {
|
||||
key: k,
|
||||
value: item.value,
|
||||
@@ -368,7 +396,11 @@ export class SystemKVStore extends PuterStore {
|
||||
|
||||
const kind = as ?? 'entries';
|
||||
if (!['keys', 'values', 'entries'].includes(kind)) {
|
||||
throw new Error('kv: list "as" must be keys, values, or entries');
|
||||
throw new HttpError(
|
||||
400,
|
||||
'kv: list "as" must be keys, values, or entries',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
|
||||
let items: string[] | unknown[] | { key: string; value: unknown }[] =
|
||||
@@ -459,12 +491,16 @@ export class SystemKVStore extends PuterStore {
|
||||
> {
|
||||
assertKey(key);
|
||||
if (!pathAndAmountMap)
|
||||
throw new Error('kv: incr requires pathAndAmountMap');
|
||||
throw new HttpError(400, 'kv: incr requires pathAndAmountMap', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
if (
|
||||
Object.values(pathAndAmountMap).some((v) => typeof v !== 'number')
|
||||
) {
|
||||
throw new Error(
|
||||
throw new HttpError(
|
||||
400,
|
||||
'kv: all values in pathAndAmountMap must be numbers',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -544,7 +580,12 @@ export class SystemKVStore extends PuterStore {
|
||||
): Promise<KVResult<unknown>> {
|
||||
assertKey(key);
|
||||
if (!pathAndValueMap || Object.keys(pathAndValueMap).length === 0) {
|
||||
throw new Error('kv: add requires pathAndValueMap');
|
||||
throw new HttpError(400, 'kv: add requires pathAndValueMap', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
for (const val of Object.values(pathAndValueMap)) {
|
||||
assertValueSize(val);
|
||||
}
|
||||
|
||||
const actor = ensureActor(opts);
|
||||
@@ -608,7 +649,9 @@ export class SystemKVStore extends PuterStore {
|
||||
): Promise<KVResult<unknown>> {
|
||||
assertKey(key);
|
||||
if (!paths || paths.length === 0) {
|
||||
throw new Error('kv: remove requires paths');
|
||||
throw new HttpError(400, 'kv: remove requires paths', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
|
||||
const actor = ensureActor(opts);
|
||||
@@ -684,7 +727,12 @@ export class SystemKVStore extends PuterStore {
|
||||
): Promise<KVResult<unknown>> {
|
||||
assertKey(key);
|
||||
if (!pathAndValueMap || Object.keys(pathAndValueMap).length === 0) {
|
||||
throw new Error('kv: update requires pathAndValueMap');
|
||||
throw new HttpError(400, 'kv: update requires pathAndValueMap', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
for (const val of Object.values(pathAndValueMap)) {
|
||||
assertValueSize(val);
|
||||
}
|
||||
|
||||
const actor = ensureActor(opts);
|
||||
@@ -728,7 +776,9 @@ export class SystemKVStore extends PuterStore {
|
||||
if (ttl !== undefined) {
|
||||
const ttlSeconds = Number(ttl);
|
||||
if (Number.isNaN(ttlSeconds))
|
||||
throw new Error('kv: ttl must be a number');
|
||||
throw new HttpError(400, 'kv: ttl must be a number', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
const timestamp = Math.floor(Date.now() / 1000) + ttlSeconds;
|
||||
setStatements.push('#ttl = :ttl');
|
||||
valueAttributeValues[':ttl'] = timestamp;
|
||||
|
||||
Reference in New Issue
Block a user