Skip to content
Open
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
## Unreleased

- Updated maintenance dependencies and image baselines: workerd runtime images now use distroless `base-debian13`, local and Kubernetes Valkey use `valkey/valkey:9.1-alpine`, and root development tooling moved to current patch/minor releases.
- Consolidated duplicated Control request/error/retry paths, Redis locks and key grammars, queue and cron projections, S3 retry policy, observability helpers, and Rust sidecar contracts into canonical owners with shared behavioral and cross-language fixture coverage.
- Hardened the tenant/runtime boundary: gateway strips private `x-wdl-*` headers, generated wrappers and D1/R2/DO facades resist tenant prototype mutation, owner records and forwarding targets require service-specific private endpoints, ordinary nested DO calls cannot override the parent request id through request headers or public facade options, and DO ownership retries rely on private do-runtime markers without replaying unknown-outcome non-idempotent requests.
- Tightened forward-only input contracts: dynamic Workers reject explicit `compatibility_date` values earlier than `2026-04-01` and error-serialization flags that conflict with the effective date or WDL's required enhanced serialization, internal auth tokens must be visible ASCII without whitespace or commas, request ids are visible ASCII, secret names reject reserved `Object.prototype` keys, DO class names are capped so every shard fits the host-id limit, and `Headers`-form R2 metadata requires a canonical IMF-fixdate `Expires` value.
- Made required bundle metadata, queue base64 bodies, and persisted secret envelopes fail closed under their owning contracts; JavaScript and Rust now share the secret-envelope, queue-key, request-id, internal-auth, worker-version, scheduler-projection, and workflow-limit fixtures.
- Normalized `PLATFORM_DOMAIN` across routing tiers, stopped `/whoami` from advertising the internal `workers.local` fallback as a public namespace URL, and aligned Compose/Kubernetes/test wiring with the consolidated owners.
- Removed the unreachable DO inline worker-code test hook and its Terraform switch; do-runtime invoke paths now accept only canonical persisted bundle identities and reject non-canonical or oversized host ids.
- Aligned scheduler and Workflows Compose, Kubernetes, and ECS stop windows with their 25-second application drain so rollout and scale-in do not terminate in-flight work before the configured drain completes.
- Upgraded `@wdl-dev/aws-sigv4` to 3.0.1; WDL's S3-only URL-input paths preserve their existing signatures while adopting stable request snapshots, fail-closed redirects, lowercase region validation, and non-blocking response cleanup.
- Closed workflow restart/version-delete races, made malformed workflow and pattern projections fail closed, and ensured whole-worker delete removes orphan workflow definitions.

## wdl.20260701.1 - 2026-07-08

Expand Down
95 changes: 56 additions & 39 deletions auth/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ import {
tokenKey,
} from "auth-runtime";
import { ROLES } from "shared-auth-roles";
import {
acquireTokenLock,
createTokenLock,
releaseTokenLock,
} from "shared-redis-lock";
import { NAMESPACES_KEY } from "shared-version";

const utf8Decoder = new TextDecoder();

Expand Down Expand Up @@ -128,47 +134,40 @@ async function scanTokenRecords(runtime, session) {

/**
* @param {import("shared-redis").RedisSession} session
* @param {string} key
* @param {string} token
*/
async function acquireDelegatedIssueLock(session, key, token) {
const reply = await session.multi()
.set(key, token, { nx: true, ttl: DELEGATED_ISSUE_LOCK_TTL_SECONDS })
.exec();
return Array.isArray(reply) && reply[0] === "OK";
}

/**
* @param {import("shared-redis").RedisSession} session
* @param {string} key
* @param {string} token
* @param {{ key: string, token: string }} lock
* @param {ReturnType<typeof bindAuthRuntime>} runtime
* @param {string | null} requestId
*/
async function releaseDelegatedIssueLock(session, key, token, runtime, requestId) {
try {
await session.delIfEq(key, token);
} catch (err) {
runtime.logAuth("warn", "auth_delegated_issue_lock_release_failed", requestId, {
async function releaseDelegatedIssueLock(session, lock, runtime, requestId) {
await releaseTokenLock(session, lock, {
onError: (err) => runtime.logAuth("warn", "auth_delegated_issue_lock_release_failed", requestId, {
...formatError(err),
});
// Do not turn a completed issue into an unknown/retryable outcome; the
// token-scoped lock has a short TTL and will self-clear.
}
}),
});
}

/**
* @param {import("shared-redis").RedisSession} session
* @param {string} key
* @param {string} token
* @param {{ key: string, token: string }} lock
* @param {ReturnType<typeof bindAuthRuntime>} runtime
* @param {string} issuerTokenId
* @param {string} templateId
*/
async function watchDelegatedIssueLockHeld(session, key, token) {
await session.watch(key);
const current = await session.get(key);
if (current !== token) {
async function watchDelegatedIssuePrerequisites(
session,
lock,
runtime,
issuerTokenId,
templateId,
) {
await session.watch(lock.key, tokenKey(issuerTokenId));
const current = await session.get(lock.key);
if (current !== lock.token) {
throw new AuthPolicyError(409, "delegated_issue_busy",
"delegated issue lock expired before the token could be issued; retry");
}
const issuer = await runtime.readRecord(session, issuerTokenId);
validateIssuerForDelegatedIssue(issuer, templateId);
}

/** @param {number} lockAttemptStartedAtMs */
Expand Down Expand Up @@ -225,7 +224,7 @@ async function chooseDelegatedNamespace(session, records, template) {
for (let attempt = 0; attempt < DELEGATED_ISSUE_NAMESPACE_RETRIES; attempt += 1) {
const ns = template.nsGenerator.prefix + randomHex(template.nsGenerator.randomHexBytes);
if (!isValidTenantNs(ns)) continue;
if (await session.sIsMember("namespaces", ns)) continue;
if (await session.sIsMember(NAMESPACES_KEY, ns)) continue;
if (tokenNamespaceClaimExists(records, ns)) continue;
const label = renderDelegatedIssueLabel(template, ns);
return { ns, label };
Expand Down Expand Up @@ -486,19 +485,25 @@ export default class Auth extends WorkerEntrypoint {
}

const lockKey = delegatedIssueLockKey(issuerTokenId, templateId);
const lockToken = randomHex(16);
const issueLock = createTokenLock(lockKey);
const lockAttemptStartedAtMs = Date.now();
let locked;
try {
const locked = await acquireDelegatedIssueLock(session, lockKey, lockToken);
if (!locked) {
throw new AuthPolicyError(409, "delegated_issue_busy",
"delegated issue is already in progress");
}
locked = await acquireTokenLock(session, issueLock, {
ttlSeconds: DELEGATED_ISSUE_LOCK_TTL_SECONDS,
});
} catch (err) {
await releaseDelegatedIssueLock(session, lockKey, lockToken, runtime, requestId);
// A failed SET reply has an unknown server-side outcome and leaves this
// RESP session untrustworthy. The lock TTL is the recovery path.
return runtime.rethrowPolicy(requestId, "delegated_issue", err);
}
if (!locked) {
return runtime.rethrowPolicy(requestId, "delegated_issue",
new AuthPolicyError(409, "delegated_issue_busy",
"delegated issue is already in progress"));
}

let canReleaseIssueLock = true;
try {
const nowMs = Date.now();
const tokenRecords = await scanTokenRecords(runtime, session);
Expand All @@ -513,7 +518,13 @@ export default class Auth extends WorkerEntrypoint {
}
const namespaceChoice = await chooseDelegatedNamespace(session, tokenRecords, template);
assertDelegatedIssueLockLocalBudget(lockAttemptStartedAtMs);
await watchDelegatedIssueLockHeld(session, lockKey, lockToken);
await watchDelegatedIssuePrerequisites(
session,
issueLock,
runtime,
issuerTokenId,
templateId,
);
const tokenId = generateTokenId();
const { ns, label } = namespaceChoice;
const expiresAt = new Date(nowMs + template.ttlSeconds * 1000).toISOString();
Expand Down Expand Up @@ -542,6 +553,10 @@ export default class Auth extends WorkerEntrypoint {
throw new AuthPolicyError(409, "delegated_issue_busy",
"delegated issue lock changed before the token could be issued; retry");
}
// Any other EXEC failure has an unknown transaction outcome. Keep
// the lock and let its TTL bound recovery rather than retrying an
// issuance that may already have committed.
canReleaseIssueLock = false;
throw err;
}
runtime.recordLifecycleOk("delegated_issue", requestId, {
Expand All @@ -565,7 +580,9 @@ export default class Auth extends WorkerEntrypoint {
} catch (err) {
return runtime.rethrowPolicy(requestId, "delegated_issue", err);
} finally {
await releaseDelegatedIssueLock(session, lockKey, lockToken, runtime, requestId);
if (canReleaseIssueLock) {
await releaseDelegatedIssueLock(session, issueLock, runtime, requestId);
}
}
});
}
Expand Down
6 changes: 3 additions & 3 deletions auth/lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ export { randomHex };
* }} TokenRecord
*/

// Mirrors control/routing.js#RoutingError shape — index.js maps thrown
// instances to HTTP code so policy rejections stay distinct from Redis /
// code exceptions (which 503 fail-closed).
// Mirrors the control/routing status + machine-code shape on purpose, but stays
// auth-local: enhanced_error_serialization preserves {status, reason}, and
// control maps that distinct policy contract without importing auth internals.
export class AuthPolicyError extends Error {
/**
* @param {number} status
Expand Down
40 changes: 22 additions & 18 deletions auth/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import {
actionCategory,
} from "shared-auth-roles";
import { withOptimisticRetries } from "shared-optimistic-retry";

/**
* @typedef {import("shared-redis").RedisSession} RedisSession
Expand Down Expand Up @@ -256,28 +257,31 @@ async function ensureBootstrapHash(redis, requestId, logAuth, desiredHash) {
return true;
};

for (let attempt = 0; attempt < 5; attempt++) {
try {
await withOptimisticRetries(
async () => {
if (typeof redis.session === "function") await redis.session(rotateBootstrap);
else await rotateBootstrap(/** @type {RedisAuthSession} */ (redis));
bootstrapMissingLogged = false;
verifyBootstrapHash = desiredHash;
logAuth("info", "auth_bootstrap", requestId, {
outcome: "ok",
token_id: BOOTSTRAP_TOKEN_ID,
rotated: rotatedFromExisting,
});
return;
} catch (err) {
if (err instanceof WatchError) continue;
throw err;
return true;
},
{
attempts: 5,
isRetryableError: (err) => err instanceof WatchError,
onExhausted: () => {
logAuth("error", "auth_bootstrap", requestId, {
outcome: "error",
reason: "bootstrap_watch_exhausted",
});
throw new Error("bootstrap upsert WATCH retry exhausted");
},
}
}
logAuth("error", "auth_bootstrap", requestId, {
outcome: "error",
reason: "bootstrap_watch_exhausted",
);
bootstrapMissingLogged = false;
verifyBootstrapHash = desiredHash;
logAuth("info", "auth_bootstrap", requestId, {
outcome: "ok",
token_id: BOOTSTRAP_TOKEN_ID,
rotated: rotatedFromExisting,
});
throw new Error("bootstrap upsert WATCH retry exhausted");
}

/** @param {AuthLogger} logAuth @param {string | null} requestId @param {string} command @param {unknown} err @returns {never} */
Expand Down
16 changes: 12 additions & 4 deletions control/bindings.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import {
QUEUE_NAME_RE,
BINDING_NAME_RE,
KV_ID_RE,
D1_DATABASE_ID_RE,
R2_BUCKET_NAME_RE,
WDL_RESERVED_BINDING_RE,
RESERVED_OBJECT_KEYS,
RESERVED_TENANT_NS,
WDL_RESERVED_ENTRYPOINT_RE,
MAX_DO_CLASS_NAME_BYTES,
isValidJsIdentifier,
isValidJsClassDeclarationName,
} from "shared-ns-pattern";
Expand All @@ -26,8 +28,6 @@ import {
MAX_QUEUE_DELAY_SECONDS,
} from "control-lib";

const D1_DATABASE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;

// Narrower than SECRET_KEY_RE (no lowercase) so platform slot names read
// as registered identifiers rather than local vars.
export const PLATFORM_KEY_RE = /^[A-Z_][A-Z0-9_]*$/;
Expand Down Expand Up @@ -143,9 +143,14 @@ const BINDING_VALIDATORS = Object.assign(Object.create(null), /** @type {Record<
},
do(b, name) {
const className = b.className;
if (typeof className !== "string" || !isValidJsClassDeclarationName(className)) {
if (
typeof className !== "string" ||
!isValidJsClassDeclarationName(className) ||
className.length > MAX_DO_CLASS_NAME_BYTES
) {
throw new Error(
`bindings.${name}: do className must be a valid JS class declaration name, got ${JSON.stringify(className)}`
`bindings.${name}: do className must be a valid JS class declaration name of at most ` +
`${MAX_DO_CLASS_NAME_BYTES} bytes, got ${JSON.stringify(className)}`
);
}
assertNotRuntimeReservedEntrypoint(`bindings.${name}`, className);
Expand Down Expand Up @@ -457,6 +462,9 @@ export async function linkServiceBinding({
targetMeta = await lookupTargetMeta(targetNs, service, targetVersion);
if (!targetMeta) targetMeta = {};
} catch (err) {
// The injected reader may classify persisted target metadata separately
// from transport failures; preserve that domain error at the link boundary.
if (err instanceof LinkError) throw err;
const message = errorMessage(err);
throw new LinkError(502, "service_binding_target_meta_unavailable",
`bindings.${bindingName}: failed to read target meta: ${message}`);
Expand Down
Loading
Loading