Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion sdks/node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -492,4 +492,4 @@ compiled in via `--features postgres,redis`.

## Not yet covered

Resources / dependency-injection and Python⇄Node cross-language interop.
End-to-end interop test coverage for the shared cross-SDK wire contract.
10 changes: 10 additions & 0 deletions sdks/node/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,16 @@ export class PredicateRejectedError extends TaskitoError {
}
}

// ── Proxies ─────────────────────────────────────────────────────────────────

/** Thrown on proxy handler, signature, expiry, purpose, or allowlist failures. */
export class ProxyError extends TaskitoError {
constructor(message: string) {
super(message);
this.name = "ProxyError";
}
}

/** Thrown when an enqueue interceptor rejects, misbehaves, or redirects illegally. */
export class InterceptionError extends TaskitoError {
constructor(message: string) {
Expand Down
3 changes: 3 additions & 0 deletions sdks/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {
LockNotAcquiredError,
NotesValidationError,
PredicateRejectedError,
ProxyError,
QueueError,
ResourceError,
ResourceNotFoundError,
Expand All @@ -30,6 +31,8 @@ export {
type Predicate,
type PredicateContext,
} from "./predicates";
export type { ProxyHandler, ProxyRef } from "./proxies";
export { canonicalJson, FileProxyHandler, FileReference, Proxies } from "./proxies";
export { Queue, type QueueOptions } from "./queue";
export {
type MockResource,
Expand Down
43 changes: 43 additions & 0 deletions sdks/node/src/proxies/canonical.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { ProxyError } from "../errors";

/**
* Canonical compact JSON with recursively sorted keys — the signing form of
* the cross-SDK contract. Keys sort by UTF-16 code units; `undefined` object
* values are dropped (wire peers have no undefined); non-safe-integer numbers
* are rejected because their textual rendering is not stable across
* implementations — encode decimals as strings.
*
* Hand-rolled rather than round-tripping through an object: JS objects
* reorder integer-like keys numerically, which would diverge from the
* contract's lexical ordering.
*/
export function canonicalJson(value: unknown): string {
if (value === null || value === undefined) {
return "null";
}
switch (typeof value) {
case "string":
case "boolean":
return JSON.stringify(value);
case "number":
if (!Number.isSafeInteger(value)) {
throw new ProxyError(
`proxy reference numbers must be safe integers, got ${value} — encode decimals as strings`,
);
}
return JSON.stringify(value);
default:
break;
}
if (Array.isArray(value)) {
return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
}
if (typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, v]) => v !== undefined)
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`);
return `{${entries.join(",")}}`;
}
throw new ProxyError(`proxy reference values must be JSON-serializable, got ${typeof value}`);
}
83 changes: 83 additions & 0 deletions sdks/node/src/proxies/file-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { existsSync, realpathSync } from "node:fs";
import { dirname, normalize, relative, resolve, sep } from "node:path";
import { ProxyError } from "../errors";
import type { ProxyHandler } from "./types";

/** A file identified by path — the value type {@link FileProxyHandler} proxies. */
export class FileReference {
readonly path: string;

constructor(path: string) {
this.path = path;
}
}

/**
* Proxies a {@link FileReference} by its absolute path. An optional allowlist
* of root directories is enforced on reconstruct, so a tampered or hostile
* ref cannot resolve to a path outside them (an empty allowlist permits any
* path). Roots and candidate paths are compared by their real (symlink-
* resolved) locations.
*/
export class FileProxyHandler implements ProxyHandler<FileReference> {
readonly id = "file";
private readonly allowedRoots: readonly string[];

constructor(allowedRoots: readonly string[] = []) {
// Resolve each root to its real path so containment is checked against
// the true filesystem location, not a symlinked alias.
this.allowedRoots = allowedRoots.map((root) => realPath(resolve(root)));
}

handles(value: unknown): boolean {
return value instanceof FileReference;
}

deconstruct(value: FileReference): Record<string, unknown> {
return { path: resolve(value.path) };
}

reconstruct(reference: Record<string, unknown>): FileReference {
const path = reference.path;
if (typeof path !== "string") {
throw new ProxyError('file proxy ref missing "path"');
}
const resolved = normalize(resolve(path));
if (!this.allowed(resolved)) {
throw new ProxyError(`file path not in allowlist: ${resolved}`);
}
return new FileReference(resolved);
}

private allowed(path: string): boolean {
if (this.allowedRoots.length === 0) {
return true;
}
const real = realPath(path);
return this.allowedRoots.some((root) => real === root || real.startsWith(root + sep));
}
}

/**
* The real (symlink-resolved) path. Since the target may not exist yet,
* resolve the nearest existing ancestor to its real path — collapsing any
* symlinked ancestor — then re-append the remaining segments. A path whose
* ancestors do not exist yet has no symlink to hide behind, so its normalized
* form stands.
*/
function realPath(path: string): string {
let existing = path;
while (!existsSync(existing)) {
const parent = dirname(existing);
if (parent === existing) {
return normalize(path);
}
existing = parent;
}
try {
const realExisting = realpathSync(existing);
return normalize(resolve(realExisting, relative(existing, path)));
} catch (error) {
throw new ProxyError(`failed to resolve real path for ${path} (${String(error)})`);
}
}
4 changes: 4 additions & 0 deletions sdks/node/src/proxies/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { canonicalJson } from "./canonical";
export { FileProxyHandler, FileReference } from "./file-handler";
export { Proxies } from "./proxies";
export type { ProxyHandler, ProxyRef } from "./types";
112 changes: 112 additions & 0 deletions sdks/node/src/proxies/proxies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { ProxyError } from "../errors";
import { canonicalJson } from "./canonical";
import type { ProxyHandler, ProxyRef } from "./types";

/**
* Registry that deconstructs resources into signed {@link ProxyRef}s and
* reconstructs them. Construct with an HMAC key (shared by producer and
* worker), register a {@link ProxyHandler} per resource type, then
* `deconstruct` on the producer and `reconstruct` (or `resolve`) on the
* worker. Signature layout is the cross-SDK contract, so refs verify across
* SDKs sharing the key.
*/
export class Proxies {
private readonly handlers = new Map<string, ProxyHandler>();
private readonly key: Buffer;

constructor(hmacKey: Uint8Array) {
if (hmacKey.length === 0) {
throw new ProxyError("proxy HMAC key must not be empty");
}
this.key = Buffer.from(hmacKey);
}

/** Register a handler under its non-empty, unique id; returns `this`. */
register<T>(handler: ProxyHandler<T>): this {
if (!handler.id) {
throw new ProxyError("proxy handler id must not be empty");
}
// Fail fast on a duplicate: silently overwriting would let a producer and
// worker disagree on what a given ProxyRef's handler id means.
if (this.handlers.has(handler.id)) {
throw new ProxyError(`proxy handler "${handler.id}" is already registered`);
}
this.handlers.set(handler.id, handler as ProxyHandler);
return this;
}

/**
* Deconstruct `value` into a signed ref, optionally bound to a TTL and a
* purpose; throws if no registered handler accepts it (registration order,
* first match wins).
*/
deconstruct(value: unknown, opts?: { ttlMs?: number; purpose?: string }): ProxyRef {
if (value === null || value === undefined) {
throw new ProxyError("cannot deconstruct null");
}
const expiresAtMs = opts?.ttlMs === undefined ? null : Date.now() + opts.ttlMs;
const purpose = opts?.purpose ?? null;
for (const handler of this.handlers.values()) {
if (handler.handles(value)) {
const reference = handler.deconstruct(value);
const signature = this.sign(handler.id, reference, expiresAtMs, purpose);
return { handler: handler.id, reference, signature, expiresAtMs, purpose };
}
}
throw new ProxyError(`no proxy handler for value of type ${typeof value}`);
}

/**
* Verify a ref's signature, expiry, and (when `expectedPurpose` is given)
* its bound purpose, then reconstruct the resource.
*/
reconstruct(ref: ProxyRef, expectedPurpose?: string): unknown {
const handler = this.handlers.get(ref.handler);
if (!handler) {
throw new ProxyError(`unknown proxy handler "${ref.handler}"`);
}
this.verify(ref, expectedPurpose);
return handler.reconstruct(ref.reference);
}

/** {@link Proxies.reconstruct} cast to the caller's type. */
resolve<T>(ref: ProxyRef, expectedPurpose?: string): T {
return this.reconstruct(ref, expectedPurpose) as T;
}

private verify(ref: ProxyRef, expectedPurpose?: string): void {
const expected = Buffer.from(
this.sign(ref.handler, ref.reference, ref.expiresAtMs, ref.purpose),
"utf8",
);
const actual = Buffer.from(ref.signature ?? "", "utf8");
// timingSafeEqual throws on length mismatch — treat that as a plain mismatch.
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
throw new ProxyError(`proxy signature mismatch for handler "${ref.handler}"`);
}
if (ref.expiresAtMs != null && Date.now() > ref.expiresAtMs) {
throw new ProxyError(`proxy ref expired for handler "${ref.handler}"`);
}
if (expectedPurpose !== undefined && (ref.purpose ?? null) !== expectedPurpose) {
throw new ProxyError(`proxy purpose mismatch for handler "${ref.handler}"`);
}
}

private sign(
handlerId: string,
reference: Record<string, unknown>,
expiresAtMs: number | null | undefined,
purpose: string | null | undefined,
): string {
// Cross-SDK contract: an absent expiry signs as the literal string "null";
// an absent purpose signs as the empty string.
const message = [
handlerId,
canonicalJson(reference),
expiresAtMs == null ? "null" : String(expiresAtMs),
purpose ?? "",
].join("\n");
return createHmac("sha256", this.key).update(message, "utf8").digest("base64");
}
}
39 changes: 39 additions & 0 deletions sdks/node/src/proxies/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* A signed, serializable reference to a non-serializable resource. Produced by
* {@link Proxies.deconstruct} on the producer, carried in a task payload, and
* resolved back on the worker. Wire peers emit explicit JSON nulls for the
* optional fields, so both `null` and `undefined` must be accepted.
*/
export interface ProxyRef {
/** Id of the handler that produced (and can resolve) this ref. */
handler: string;
/** Serializable reference data (e.g. a file path). */
reference: Record<string, unknown>;
/** Base64 HMAC-SHA256 over the ref's canonical form — the cross-SDK contract. */
signature: string;
/** Unix-ms expiry, or null/absent for no expiry. */
expiresAtMs?: number | null;
/** Optional purpose the ref is bound to; workers may require a match. */
purpose?: string | null;
}

/**
* Deconstructs a non-serializable resource of type `T` into a serializable
* reference, and reconstructs it on the worker. Register handlers with a
* {@link Proxies} registry.
*
* Reference values must stay within the canonical signing form — strings,
* booleans, safe integers, nulls, and nested objects/arrays of those.
* Non-integer numbers are rejected at signing time (their textual form is not
* stable across the cross-SDK contract); encode decimals as strings.
*/
export interface ProxyHandler<T = unknown> {
/** Stable id stored in the {@link ProxyRef} and used to find this handler on the worker. */
readonly id: string;
/** Whether this handler can proxy `value`. */
handles(value: unknown): boolean;
/** Reduce `value` to a serializable reference (e.g. a file path, a config map). */
deconstruct(value: T): Record<string, unknown>;
/** Rebuild the resource from a reference produced by {@link ProxyHandler.deconstruct}. */
reconstruct(reference: Record<string, unknown>): T;
}
Loading