feat(ql3): gate local secret transitions before activation

This commit is contained in:
whyour
2026-08-13 20:29:47 +08:00
parent 326783ff14
commit c877147d0f
37 changed files with 2495 additions and 39 deletions
@@ -5,6 +5,8 @@ import {
import {
InvalidPluginPackageInstallError,
PluginPackageInstallTransitionConflictError,
PluginPackageInstallUnavailableError,
assertPluginPackageInstallMatchesLock,
normalizePluginPackageLock,
pluginPackageInstallCommit,
transitionPluginPackageInstall,
@@ -33,6 +35,20 @@ export interface PluginPackageStageProvider {
): Promise<Readonly<PluginPackageStageEvidence>>;
}
export type PluginPackageActivationPrerequisiteObservation =
| Readonly<{ status: 'ready' }>
| Readonly<{
status: 'deferred';
reason: 'secret_binding_transition_required';
}>;
export interface PluginPackageActivationPrerequisite {
inspect(
record: Readonly<PluginPackageInstallRecord>,
lock: Readonly<PluginPackageLock>,
): Promise<Readonly<PluginPackageActivationPrerequisiteObservation>>;
}
export interface InstallPluginPackageOptions {
readonly lock: PluginPackageLock;
readonly proposalDigest: string;
@@ -139,15 +155,23 @@ export function normalizePluginPackageStageEvidence(
export class PluginPackageInstallationCoordinator {
readonly #repository: PluginPackageAdmissionRepository;
readonly #activation: PluginPackageActivationCoordinator;
readonly #activationPrerequisite?: PluginPackageActivationPrerequisite;
constructor(options: {
readonly repository: PluginPackageAdmissionRepository;
readonly publisher: PluginPackageActivationPublisher;
readonly activationPrerequisite?: PluginPackageActivationPrerequisite;
}) {
const value = dataRecord(options, 'installation coordinator options');
exactKeys(
value,
['repository', 'publisher'],
[
'repository',
'publisher',
...(options.activationPrerequisite === undefined
? []
: ['activationPrerequisite']),
],
'installation coordinator options',
);
if (
@@ -157,7 +181,10 @@ export class PluginPackageInstallationCoordinator {
typeof options.repository.create !== 'function' ||
typeof options.repository.commit !== 'function' ||
typeof options.repository.admit !== 'function' ||
typeof options.repository.findAdmissionReceipt !== 'function'
typeof options.repository.findAdmissionReceipt !== 'function' ||
(options.activationPrerequisite !== undefined &&
(!options.activationPrerequisite ||
typeof options.activationPrerequisite.inspect !== 'function'))
) {
throw new InvalidPluginPackageInstallError(
'installation coordinator authority is invalid',
@@ -168,6 +195,9 @@ export class PluginPackageInstallationCoordinator {
repository: options.repository,
publisher: options.publisher,
});
if (options.activationPrerequisite !== undefined) {
this.#activationPrerequisite = options.activationPrerequisite;
}
}
async #convergeActivation(
@@ -186,6 +216,14 @@ export class PluginPackageInstallationCoordinator {
installationId: record.installationId,
};
if (record.state === 'staged') {
const lock = await this.#repository.findLock(record.lockDigest);
if (!lock) throw new PluginPackageInstallUnavailableError();
assertPluginPackageInstallMatchesLock(lock, record);
const prerequisite = await this.#activationPrerequisite?.inspect(
record,
lock,
);
if (prerequisite?.status === 'deferred') return record;
return this.#activation.activate({
...identity,
activationStartedMutationId: options.activationStartedMutationId,
@@ -26,6 +26,7 @@ import {
} from './pluginPackageInstall';
import {
normalizePluginPackageStageEvidence,
type PluginPackageActivationPrerequisite,
type PluginPackageStageProvider,
} from './pluginPackageInstallation';
@@ -37,6 +38,7 @@ export const PLUGIN_PACKAGE_RECOVERY_ITEM_STATUSES = [
'retry',
'manual_required',
'superseded',
'deferred',
] as const;
export type PluginPackageRecoveryItemStatus =
@@ -64,6 +66,7 @@ export interface PluginPackageRecoveryCycleResult {
readonly retry: number;
readonly manualRequired: number;
readonly superseded: number;
readonly deferred: number;
readonly remaining: boolean;
readonly safeToAdmit: boolean;
}
@@ -235,18 +238,20 @@ export class PluginPackageRecoveryCoordinator {
readonly #stageProvider: PluginPackageStageProvider;
readonly #activation: PluginPackageActivationCoordinator;
readonly #now: () => number | Promise<number>;
readonly #activationPrerequisite?: PluginPackageActivationPrerequisite;
constructor(options: {
readonly repository: PluginPackageInstallRepository;
readonly stageProvider: PluginPackageStageProvider;
readonly publisher: PluginPackageActivationPublisher;
readonly now: () => number | Promise<number>;
readonly activationPrerequisite?: PluginPackageActivationPrerequisite;
}) {
const value = dataRecord(options, 'recovery coordinator options');
exactKeys(
value,
['repository', 'stageProvider', 'publisher', 'now'],
[],
['activationPrerequisite'],
'recovery coordinator options',
);
if (
@@ -257,7 +262,10 @@ export class PluginPackageRecoveryCoordinator {
typeof options.repository.listRecoveryPage !== 'function' ||
!options.stageProvider ||
typeof options.stageProvider.stage !== 'function' ||
typeof options.now !== 'function'
typeof options.now !== 'function' ||
(options.activationPrerequisite !== undefined &&
(!options.activationPrerequisite ||
typeof options.activationPrerequisite.inspect !== 'function'))
) {
throw new InvalidPluginPackageInstallError(
'recovery coordinator authority is invalid',
@@ -270,6 +278,9 @@ export class PluginPackageRecoveryCoordinator {
publisher: options.publisher,
});
this.#now = options.now;
if (options.activationPrerequisite !== undefined) {
this.#activationPrerequisite = options.activationPrerequisite;
}
}
async #current(
@@ -292,6 +303,14 @@ export class PluginPackageRecoveryCoordinator {
installationId: record.installationId,
};
if (record.state === 'staged') {
const lock = await this.#repository.findLock(record.lockDigest);
if (!lock) throw new PluginPackageInstallUnavailableError();
assertPluginPackageInstallMatchesLock(lock, record);
const prerequisite = await this.#activationPrerequisite?.inspect(
record,
lock,
);
if (prerequisite?.status === 'deferred') return record;
return this.#activation.activate({
...identity,
activationStartedMutationId: mutationId(
@@ -374,6 +393,18 @@ export class PluginPackageRecoveryCoordinator {
current = committed;
}
current = await this.#convergeActivation(current, occurredAtMs);
if (current.state === 'staged' && this.#activationPrerequisite) {
const lock = await this.#repository.findLock(current.lockDigest);
if (!lock) throw new PluginPackageInstallUnavailableError();
assertPluginPackageInstallMatchesLock(lock, current);
const prerequisite = await this.#activationPrerequisite.inspect(
current,
lock,
);
if (prerequisite.status === 'deferred') {
return result(source, action, 'deferred', current);
}
}
if (current.state === 'active' || current.state === 'failed') {
return result(source, action, 'settled', current);
}
@@ -477,6 +508,7 @@ export class PluginPackageRecoveryCoordinator {
retry: 0,
manualRequired: 0,
superseded: 0,
deferred: 0,
};
let after: Readonly<PluginPackageInstallRecoveryCursor> | undefined;
let exhausted = false;
@@ -501,6 +533,9 @@ export class PluginPackageRecoveryCoordinator {
case 'superseded':
counts.superseded += 1;
break;
case 'deferred':
counts.deferred += 1;
break;
}
}
if (!page.truncated) {
@@ -517,7 +552,8 @@ export class PluginPackageRecoveryCoordinator {
return Object.freeze({
...counts,
remaining,
safeToAdmit: !remaining && counts.manualRequired === 0,
safeToAdmit:
!remaining && counts.retry === 0 && counts.manualRequired === 0,
});
}
}
@@ -0,0 +1,305 @@
import { createHash } from 'node:crypto';
import {
createPluginPackageSecretBindingFromEntries,
normalizePluginPackageSecretBinding,
type PluginPackageSecretBinding,
type PluginPackageSecretBindingAuthorityKind,
} from './binding';
import {
normalizePluginPackageSecretBindingTransitionPlan,
type PluginPackageSecretBindingTransitionPlan,
} from './transitionPlan';
export const PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_SCHEMA =
'qinglong/plugin-package-secret-binding-transition-receipt@v1' as const;
export const MAX_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_JSON_BYTES =
192 * 1024;
export interface PluginPackageSecretBindingTransitionReceiptAuthority {
readonly kind: PluginPackageSecretBindingAuthorityKind;
readonly evidenceDigest: string;
}
export interface PluginPackageSecretBindingTransitionReceipt {
readonly schema: typeof PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_SCHEMA;
readonly transitionPlan: Readonly<PluginPackageSecretBindingTransitionPlan>;
readonly authority: Readonly<PluginPackageSecretBindingTransitionReceiptAuthority>;
readonly bindingDigest: string | null;
readonly committedAtMs: number;
readonly receiptDigest: string;
}
export interface CreatePluginPackageSecretBindingTransitionReceiptInput {
readonly transitionPlan: Readonly<PluginPackageSecretBindingTransitionPlan>;
readonly authority: Readonly<PluginPackageSecretBindingTransitionReceiptAuthority>;
readonly binding: Readonly<PluginPackageSecretBinding> | null;
readonly committedAtMs: number;
}
const DIGEST = /^[0-9a-f]{64}$/;
const RECEIPT_DIGEST_DOMAIN = Buffer.from(
'qinglong/plugin-package-secret-binding-transition-receipt-digest@v1\0',
'utf8',
);
function invalid(message: string): never {
throw new TypeError(
`Plugin Package Secret binding transition receipt is invalid: ${message}`,
);
}
function dataRecord(value: unknown, label: string): Record<string, unknown> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
(Object.getPrototypeOf(value) !== Object.prototype &&
Object.getPrototypeOf(value) !== null)
) {
return invalid(`${label} must be an object`);
}
const descriptors = Object.getOwnPropertyDescriptors(value);
if (
Object.values(descriptors).some(
(descriptor) =>
descriptor.get !== undefined ||
descriptor.set !== undefined ||
descriptor.enumerable !== true,
)
) {
return invalid(`${label} must contain enumerable data properties`);
}
return value as Record<string, unknown>;
}
function exactKeys(
value: object,
expected: readonly string[],
label: string,
): void {
const actual = Reflect.ownKeys(value);
const strings = actual.filter(
(key): key is string => typeof key === 'string',
);
const canonical = [...expected].sort();
if (
actual.length !== canonical.length ||
strings.length !== canonical.length ||
strings.sort().some((key, index) => key !== canonical[index])
) {
invalid(`${label} shape is invalid`);
}
}
function timestamp(value: unknown): number {
if (!Number.isSafeInteger(value) || (value as number) < 0) {
return invalid('committedAtMs is invalid');
}
return value as number;
}
function authority(
value: unknown,
): Readonly<PluginPackageSecretBindingTransitionReceiptAuthority> {
const candidate = dataRecord(value, 'authority');
exactKeys(candidate, ['evidenceDigest', 'kind'], 'authority');
if (
candidate.kind !== 'approved-action-execution' &&
candidate.kind !== 'local-owner-confirmation'
) {
return invalid('authority kind is invalid');
}
if (
typeof candidate.evidenceDigest !== 'string' ||
!DIGEST.test(candidate.evidenceDigest)
) {
return invalid('authority evidence digest is invalid');
}
return Object.freeze({
kind: candidate.kind,
evidenceDigest: candidate.evidenceDigest,
});
}
function unsigned(
transitionPlan: Readonly<PluginPackageSecretBindingTransitionPlan>,
normalizedAuthority: Readonly<PluginPackageSecretBindingTransitionReceiptAuthority>,
bindingDigest: string | null,
committedAtMs: number,
): Omit<PluginPackageSecretBindingTransitionReceipt, 'receiptDigest'> {
return Object.freeze({
schema: PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_SCHEMA,
transitionPlan,
authority: normalizedAuthority,
bindingDigest,
committedAtMs,
});
}
function receiptDigest(
value: Omit<PluginPackageSecretBindingTransitionReceipt, 'receiptDigest'>,
): string {
return createHash('sha256')
.update(RECEIPT_DIGEST_DOMAIN)
.update(JSON.stringify(value), 'utf8')
.digest('hex');
}
function bounded(
value: Readonly<PluginPackageSecretBindingTransitionReceipt>,
): Readonly<PluginPackageSecretBindingTransitionReceipt> {
if (
Buffer.byteLength(JSON.stringify(value), 'utf8') >
MAX_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_JSON_BYTES
) {
return invalid('durable JSON byte budget exceeded');
}
return value;
}
function assertBinding(
transitionPlan: Readonly<PluginPackageSecretBindingTransitionPlan>,
bindingValue: Readonly<PluginPackageSecretBinding> | null,
normalizedAuthority: Readonly<PluginPackageSecretBindingTransitionReceiptAuthority>,
committedAtMs: number,
): Readonly<PluginPackageSecretBinding> | null {
if (transitionPlan.nextBindingPlan === null) {
if (bindingValue !== null) {
return invalid(
'binding is forbidden when the transition revokes all requirements',
);
}
return null;
}
if (bindingValue === null) {
return invalid('binding is required by the transition plan');
}
const binding = normalizePluginPackageSecretBinding(bindingValue);
if (
JSON.stringify(binding.target) !==
JSON.stringify(transitionPlan.nextTarget) ||
JSON.stringify(binding.entries) !==
JSON.stringify(transitionPlan.nextBindingPlan.entries) ||
binding.authority.kind !== normalizedAuthority.kind ||
binding.authority.evidenceDigest !== normalizedAuthority.evidenceDigest ||
binding.boundAtMs !== committedAtMs
) {
return invalid(
'binding does not match the transition, authority, and commit',
);
}
return binding;
}
export function createPluginPackageSecretBindingFromTransitionPlan(
planValue: Readonly<PluginPackageSecretBindingTransitionPlan>,
authorityKind: PluginPackageSecretBindingAuthorityKind,
evidenceDigest: string,
boundAtMs: number,
): Readonly<PluginPackageSecretBinding> | null {
const plan = normalizePluginPackageSecretBindingTransitionPlan(planValue);
if (plan.nextBindingPlan === null) return null;
return createPluginPackageSecretBindingFromEntries({
target: plan.nextTarget,
entries: plan.nextBindingPlan.entries,
authority: { kind: authorityKind, evidenceDigest },
boundAtMs,
});
}
export function createPluginPackageSecretBindingTransitionReceipt(
input: CreatePluginPackageSecretBindingTransitionReceiptInput,
): Readonly<PluginPackageSecretBindingTransitionReceipt> {
const candidate = dataRecord(input, 'receipt input');
exactKeys(
candidate,
['authority', 'binding', 'committedAtMs', 'transitionPlan'],
'receipt input',
);
const transitionPlan = normalizePluginPackageSecretBindingTransitionPlan(
input.transitionPlan,
);
const normalizedAuthority = authority(input.authority);
const committedAtMs = timestamp(input.committedAtMs);
if (committedAtMs < (transitionPlan.nextBindingPlan?.plannedAtMs ?? 0)) {
return invalid('commit precedes the reviewed next binding plan');
}
const binding = assertBinding(
transitionPlan,
input.binding,
normalizedAuthority,
committedAtMs,
);
const value = unsigned(
transitionPlan,
normalizedAuthority,
binding?.bindingDigest ?? null,
committedAtMs,
);
return bounded(
Object.freeze({ ...value, receiptDigest: receiptDigest(value) }),
);
}
export function normalizePluginPackageSecretBindingTransitionReceipt(
value: unknown,
): Readonly<PluginPackageSecretBindingTransitionReceipt> {
const candidate = dataRecord(value, 'receipt');
exactKeys(
candidate,
[
'authority',
'bindingDigest',
'committedAtMs',
'receiptDigest',
'schema',
'transitionPlan',
],
'receipt',
);
if (
candidate.schema !== PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_SCHEMA
) {
return invalid('schema is unsupported');
}
const transitionPlan = normalizePluginPackageSecretBindingTransitionPlan(
candidate.transitionPlan,
);
const normalizedAuthority = authority(candidate.authority);
const committedAtMs = timestamp(candidate.committedAtMs);
if (committedAtMs < (transitionPlan.nextBindingPlan?.plannedAtMs ?? 0)) {
return invalid('commit precedes the reviewed next binding plan');
}
const bindingDigestValue = candidate.bindingDigest;
if (
(bindingDigestValue !== null &&
(typeof bindingDigestValue !== 'string' ||
!DIGEST.test(bindingDigestValue))) ||
(transitionPlan.nextBindingPlan === null) !== (bindingDigestValue === null)
) {
return invalid('binding digest presence is inconsistent with transition');
}
const normalized = unsigned(
transitionPlan,
normalizedAuthority,
bindingDigestValue as string | null,
committedAtMs,
);
if (
typeof candidate.receiptDigest !== 'string' ||
!DIGEST.test(candidate.receiptDigest) ||
candidate.receiptDigest !== receiptDigest(normalized)
) {
return invalid('receipt digest does not match content');
}
return bounded(
Object.freeze({ ...normalized, receiptDigest: candidate.receiptDigest }),
);
}
export interface PluginPackageSecretBindingTransitionReceiptRepository {
find(
generationDigest: string,
): Promise<Readonly<PluginPackageSecretBindingTransitionReceipt> | null>;
}