feat(ql3): apply legacy data transformation

This commit is contained in:
whyour
2026-08-21 12:06:41 +08:00
parent 69c322fa8d
commit d4813e48a4
32 changed files with 3877 additions and 64 deletions
+5
View File
@@ -115,6 +115,11 @@
"require": "./dist/security-administration/secretAdministration.js",
"default": "./dist/security-administration/secretAdministration.js"
},
"./data-directory-adoption": {
"types": "./dist/data-directory-adoption/dataDirectoryAdoption.d.ts",
"require": "./dist/data-directory-adoption/dataDirectoryAdoption.js",
"default": "./dist/data-directory-adoption/dataDirectoryAdoption.js"
},
"./task-definition-administration": {
"types": "./dist/automation-administration/taskDefinitionAdministration.d.ts",
"require": "./dist/automation-administration/taskDefinitionAdministration.js",
@@ -0,0 +1,547 @@
import { createHash } from 'node:crypto';
import {
createLocalDataDirectoryAdoptionReceipt,
createLocalDataDirectoryAdoptionSecretItem,
createLocalDataDirectorySourceNameDigest,
openLocalSqliteDataDirectoryAdoptionDatabase,
type LocalDataDirectoryAdoptionRecord,
type LocalDataDirectoryAdoptionSecretPublication,
type LocalDataDirectoryAppliedModel,
} from '@qinglong/local-sqlite/data-directory-adoption';
import {
encryptLocalSecretEnvelope,
ownedLocalSecretKeyMaterial,
} from '@qinglong/local-secret';
import {
LOCAL_SECRET_ALGORITHM,
assertLocalSecretName,
assertLocalSecretPlaintext,
assertLocalSecretProjectId,
type LocalSecretKeyProvider,
} from '@qinglong/runtime-core/local-secret';
import {
ProjectPolicyEngine,
ProjectPolicyUnavailableError,
} from '@qinglong/runtime-core/project-policy';
import {
normalizeSecurityPrincipal,
type SecurityPolicyDecision,
type SecurityPrincipal,
} from '@qinglong/runtime-core/security';
import {
normalizeSecurityAuditRecord,
type SecurityAuditRecord,
} from '@qinglong/runtime-core/security-audit';
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
const UUID_V4_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const VALUE_FILE_PATTERN = /^secret-values\/[0-9a-f]{64}\.json$/;
const STRONG_USER_ASSURANCES = new Set([
'multi_factor',
'hardware',
'local_console',
]);
export interface PreparedLocalDataDirectoryAdoptionSecret {
readonly kind: 'environment' | 'ssh_private_key';
readonly sourceName: string;
readonly targetName: string;
readonly expectedCurrentVersion: 0;
readonly valueFile: string;
readonly valueDigest: string;
readonly plaintext: string;
}
export interface PreparedLocalDataDirectoryAdoptionModel {
readonly model: Readonly<LocalDataDirectoryAppliedModel>;
readonly secrets: readonly Readonly<PreparedLocalDataDirectoryAdoptionSecret>[];
}
export interface ApplyReviewedLocalDataDirectoryAdoptionOptions {
readonly databasePath: string;
readonly profile: 'edge' | 'standalone';
readonly projectId: string;
readonly mutationId: string;
readonly failureAuditEventId: string;
readonly requestId: string;
readonly sourceStageManifestDigest: string;
readonly transformationDigest: string;
readonly modelDigest: string;
readonly principal: Readonly<SecurityPrincipal>;
readonly keyProvider: LocalSecretKeyProvider;
readonly observedAtMs: number;
readonly busyTimeoutMs?: number;
readonly loadPreparedModel: () =>
| Readonly<PreparedLocalDataDirectoryAdoptionModel>
| Promise<Readonly<PreparedLocalDataDirectoryAdoptionModel>>;
readonly confirmAuthenticationAuthority: () => void | Promise<void>;
readonly confirmPreparedAuthority: () => void | Promise<void>;
}
export interface ApplyReviewedLocalDataDirectoryAdoptionResult {
readonly status: 'inserted' | 'existing';
readonly adoption: Readonly<LocalDataDirectoryAdoptionRecord>;
}
export class LocalDataDirectoryAdoptionApplicationConfigurationError extends TypeError {
readonly code = 'LOCAL_DATA_DIRECTORY_ADOPTION_APPLICATION_INVALID';
constructor(message: string, readonly cause?: unknown) {
super(`Local data directory adoption application is invalid: ${message}`);
this.name = 'LocalDataDirectoryAdoptionApplicationConfigurationError';
}
}
export class LocalDataDirectoryAdoptionApplicationAuthenticationError extends Error {
readonly code =
'LOCAL_DATA_DIRECTORY_ADOPTION_APPLICATION_AUTHENTICATION_REQUIRED';
constructor() {
super(
'Local data directory adoption application requires a strong principal',
);
this.name = 'LocalDataDirectoryAdoptionApplicationAuthenticationError';
}
}
export class LocalDataDirectoryAdoptionApplicationAuthorizationError extends Error {
readonly code = 'LOCAL_DATA_DIRECTORY_ADOPTION_APPLICATION_FORBIDDEN';
constructor() {
super('Local data directory adoption application is not authorized');
this.name = 'LocalDataDirectoryAdoptionApplicationAuthorizationError';
}
}
export class LocalDataDirectoryAdoptionApplicationUnavailableError extends Error {
readonly code = 'LOCAL_DATA_DIRECTORY_ADOPTION_APPLICATION_UNAVAILABLE';
constructor(readonly cause?: unknown) {
super('Local data directory adoption application is unavailable');
this.name = 'LocalDataDirectoryAdoptionApplicationUnavailableError';
}
}
function exactKeys(value: object, expected: readonly string[]): boolean {
const actual = Object.keys(value).sort();
const canonical = [...expected].sort();
return (
actual.length === canonical.length &&
actual.every((key, index) => key === canonical[index])
);
}
function assertOptions(
options: ApplyReviewedLocalDataDirectoryAdoptionOptions,
): void {
if (
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
!UUID_V4_PATTERN.test(options.mutationId) ||
!UUID_V4_PATTERN.test(options.failureAuditEventId) ||
options.failureAuditEventId === options.mutationId ||
!REQUEST_ID_PATTERN.test(options.requestId) ||
(options.profile !== 'edge' && options.profile !== 'standalone') ||
![
options.sourceStageManifestDigest,
options.transformationDigest,
options.modelDigest,
].every((digest) => DIGEST_PATTERN.test(digest)) ||
!Number.isSafeInteger(options.observedAtMs) ||
options.observedAtMs < 0 ||
typeof options.loadPreparedModel !== 'function' ||
typeof options.confirmAuthenticationAuthority !== 'function' ||
typeof options.confirmPreparedAuthority !== 'function' ||
!options.keyProvider ||
typeof options.keyProvider.active !== 'function' ||
typeof options.keyProvider.resolve !== 'function' ||
(options.busyTimeoutMs !== undefined &&
(!Number.isSafeInteger(options.busyTimeoutMs) ||
options.busyTimeoutMs < 100 ||
options.busyTimeoutMs > 30_000))
) {
throw new LocalDataDirectoryAdoptionApplicationConfigurationError(
'options are invalid',
);
}
try {
assertLocalSecretProjectId(options.projectId);
} catch (error) {
throw new LocalDataDirectoryAdoptionApplicationConfigurationError(
'projectId is invalid',
error,
);
}
}
function strongPrincipal(
value: Readonly<SecurityPrincipal>,
nowMs: number,
): Readonly<SecurityPrincipal> {
let principal: Readonly<SecurityPrincipal>;
try {
principal = normalizeSecurityPrincipal(value, nowMs);
} catch {
throw new LocalDataDirectoryAdoptionApplicationAuthenticationError();
}
const human =
principal.subject.type === 'user' &&
STRONG_USER_ASSURANCES.has(principal.assurance);
const system =
principal.subject.type === 'system' && principal.assurance === 'service';
if (!human && !system) {
throw new LocalDataDirectoryAdoptionApplicationAuthenticationError();
}
return principal;
}
function auditRecord(options: {
readonly eventId: string;
readonly requestId: string;
readonly projectId: string;
readonly principal: Readonly<SecurityPrincipal> | null;
readonly operationId: string;
readonly outcome: SecurityAuditRecord['outcome'];
readonly reasons: readonly string[];
readonly decision: Readonly<SecurityPolicyDecision> | null;
readonly occurredAtMs: number;
}): Readonly<SecurityAuditRecord> {
return normalizeSecurityAuditRecord({
eventId: options.eventId,
requestId: options.requestId,
operationId: options.operationId,
projectId: options.projectId,
subject: options.principal?.subject ?? null,
authenticationId: options.principal?.authenticationId ?? null,
outcome: options.outcome,
reasons: options.reasons,
fence: options.decision?.fence ?? null,
occurredAtMs: options.occurredAtMs,
});
}
function deterministicMutationId(
batchMutationId: string,
identity: string,
): string {
const bytes = createHash('sha256')
.update('qinglong3.legacy-data-directory-secret-mutation.v1\0')
.update(batchMutationId)
.update('\0')
.update(identity)
.digest()
.subarray(0, 16);
bytes[6] = ((bytes[6] as number) & 0x0f) | 0x40;
bytes[8] = ((bytes[8] as number) & 0x3f) | 0x80;
const hex = bytes.toString('hex');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(
12,
16,
)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
function assertPreparedModel(
value: Readonly<PreparedLocalDataDirectoryAdoptionModel>,
profile: 'edge' | 'standalone',
): void {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, ['model', 'secrets']) ||
!Array.isArray(value.secrets) ||
value.secrets.length > (profile === 'edge' ? 128 : 512)
) {
throw new LocalDataDirectoryAdoptionApplicationConfigurationError(
'prepared model shape is invalid',
);
}
const targets = new Set<string>();
for (const entry of value.secrets) {
if (
!entry ||
typeof entry !== 'object' ||
Array.isArray(entry) ||
!exactKeys(entry, [
'expectedCurrentVersion',
'kind',
'plaintext',
'sourceName',
'targetName',
'valueDigest',
'valueFile',
]) ||
(entry.kind !== 'environment' && entry.kind !== 'ssh_private_key') ||
typeof entry.sourceName !== 'string' ||
entry.sourceName.length < 1 ||
entry.sourceName.includes('\0') ||
entry.expectedCurrentVersion !== 0 ||
!VALUE_FILE_PATTERN.test(entry.valueFile) ||
!DIGEST_PATTERN.test(entry.valueDigest) ||
createHash('sha256').update(entry.plaintext, 'utf8').digest('hex') !==
entry.valueDigest ||
targets.has(entry.targetName)
) {
throw new LocalDataDirectoryAdoptionApplicationConfigurationError(
'prepared Secret entry is invalid',
);
}
try {
assertLocalSecretName(entry.targetName);
assertLocalSecretPlaintext(entry.plaintext);
} catch (error) {
throw new LocalDataDirectoryAdoptionApplicationConfigurationError(
'prepared Secret value is invalid',
error,
);
}
targets.add(entry.targetName);
}
}
function sameExpected(
record: Readonly<LocalDataDirectoryAdoptionRecord>,
options: Readonly<ApplyReviewedLocalDataDirectoryAdoptionOptions>,
): boolean {
return (
record.mutationId === options.mutationId &&
record.projectId === options.projectId &&
record.profile === options.profile &&
record.sourceStageManifestDigest === options.sourceStageManifestDigest &&
record.transformationDigest === options.transformationDigest &&
record.modelDigest === options.modelDigest
);
}
export async function applyReviewedLocalDataDirectoryAdoption(
options: Readonly<ApplyReviewedLocalDataDirectoryAdoptionOptions>,
): Promise<Readonly<ApplyReviewedLocalDataDirectoryAdoptionResult>> {
assertOptions(options);
const database = await openLocalSqliteDataDirectoryAdoptionDatabase({
databasePath: options.databasePath,
profile: options.profile,
...(options.busyTimeoutMs === undefined
? {}
: { busyTimeoutMs: options.busyTimeoutMs }),
});
try {
let principal: Readonly<SecurityPrincipal>;
try {
principal = strongPrincipal(options.principal, options.observedAtMs);
await options.confirmAuthenticationAuthority();
} catch (error) {
try {
await database.securityAudit.record(
auditRecord({
eventId: options.failureAuditEventId,
requestId: options.requestId,
projectId: options.projectId,
principal: null,
operationId: 'legacy-data.apply',
outcome: 'authentication_rejected',
reasons: ['strong_authentication_required'],
decision: null,
occurredAtMs: options.observedAtMs,
}),
);
} catch {
throw new LocalDataDirectoryAdoptionApplicationUnavailableError();
}
throw error;
}
const policy = new ProjectPolicyEngine(database.projectPolicy);
let decision: Readonly<SecurityPolicyDecision>;
try {
decision = await policy.authorize(
principal,
options.projectId,
'secret.manage',
);
} catch (error) {
if (!(error instanceof ProjectPolicyUnavailableError)) {
throw new LocalDataDirectoryAdoptionApplicationUnavailableError(error);
}
try {
await database.securityAudit.record(
auditRecord({
eventId: options.failureAuditEventId,
requestId: options.requestId,
projectId: options.projectId,
principal,
operationId: 'legacy-data.apply',
outcome: 'authorization_unavailable',
reasons: ['policy_unavailable'],
decision: null,
occurredAtMs: options.observedAtMs,
}),
);
} catch {
throw new LocalDataDirectoryAdoptionApplicationUnavailableError();
}
throw new LocalDataDirectoryAdoptionApplicationUnavailableError();
}
if (decision.effect !== 'allow') {
try {
await database.securityAudit.record(
auditRecord({
eventId: options.failureAuditEventId,
requestId: options.requestId,
projectId: options.projectId,
principal,
operationId: 'legacy-data.apply',
outcome:
decision.effect === 'require_approval'
? 'approval_required'
: 'denied',
reasons: decision.reasons,
decision,
occurredAtMs: options.observedAtMs,
}),
);
} catch {
throw new LocalDataDirectoryAdoptionApplicationUnavailableError();
}
throw new LocalDataDirectoryAdoptionApplicationAuthorizationError();
}
if (!decision.fence || decision.fence.bindingVersion === null) {
throw new LocalDataDirectoryAdoptionApplicationUnavailableError();
}
const existing = await database.publisher.resolve(options.mutationId);
if (existing) {
if (!sameExpected(existing, options)) {
throw new LocalDataDirectoryAdoptionApplicationConfigurationError(
'mutation replay does not match the committed adoption',
);
}
await options.confirmAuthenticationAuthority();
return Object.freeze({ status: 'existing', adoption: existing });
}
const prepared = await options.loadPreparedModel();
assertPreparedModel(prepared, options.profile);
const material = ownedLocalSecretKeyMaterial(
await options.keyProvider.active(),
);
const publications: LocalDataDirectoryAdoptionSecretPublication[] = [];
try {
for (const [index, secret] of prepared.secrets.entries()) {
const ordinal = index + 1;
const secretMutationId = deterministicMutationId(
options.mutationId,
`${secret.kind}\0${secret.targetName}`,
);
const envelope = encryptLocalSecretEnvelope(
{
projectId: options.projectId,
name: secret.targetName,
version: 1,
mutationId: secretMutationId,
keyId: material.keyId,
algorithm: LOCAL_SECRET_ALGORITHM,
createdAtMs: options.observedAtMs,
},
secret.plaintext,
material.key,
);
const item = createLocalDataDirectoryAdoptionSecretItem({
projectId: options.projectId,
ordinal,
kind: secret.kind,
sourceNameDigest: createLocalDataDirectorySourceNameDigest(
secret.kind,
secret.sourceName,
),
secretName: secret.targetName,
secretMutationId,
valueFile: secret.valueFile,
valueDigest: secret.valueDigest,
});
publications.push(
Object.freeze({
ordinal,
kind: secret.kind,
sourceNameDigest: item.sourceNameDigest,
valueFile: secret.valueFile,
valueDigest: secret.valueDigest,
envelope,
secretRef: item.secretRef,
itemDigest: item.itemDigest,
audit: auditRecord({
eventId: secretMutationId,
requestId: options.requestId,
projectId: options.projectId,
principal,
operationId: 'secret.create',
outcome: 'allowed',
reasons: decision.reasons,
decision,
occurredAtMs: options.observedAtMs,
}),
}),
);
}
} finally {
material.key.fill(0);
}
const receipt = createLocalDataDirectoryAdoptionReceipt({
mutationId: options.mutationId,
projectId: options.projectId,
profile: options.profile,
sourceStageManifestDigest: options.sourceStageManifestDigest,
transformationDigest: options.transformationDigest,
modelDigest: options.modelDigest,
secrets: publications.map(({ envelope, ...item }) =>
Object.freeze({
ordinal: item.ordinal,
kind: item.kind,
sourceNameDigest: item.sourceNameDigest,
secretName: envelope.name,
secretVersion: 1 as const,
secretMutationId: envelope.mutationId,
valueFile: item.valueFile,
valueDigest: item.valueDigest,
secretRef: item.secretRef,
itemDigest: item.itemDigest,
}),
),
committedAtMs: options.observedAtMs,
});
const applied = await database.publisher.publish({
mutationId: options.mutationId,
projectId: options.projectId,
profile: options.profile,
sourceStageManifestDigest: options.sourceStageManifestDigest,
transformationDigest: options.transformationDigest,
modelDigest: options.modelDigest,
model: prepared.model,
subject: principal.subject,
fence: decision.fence,
audit: auditRecord({
eventId: options.mutationId,
requestId: options.requestId,
projectId: options.projectId,
principal,
operationId: 'legacy-data.apply',
outcome: 'allowed',
reasons: decision.reasons,
decision,
occurredAtMs: options.observedAtMs,
}),
secrets: Object.freeze(publications),
receipt,
async confirmExternalAuthority() {
await options.confirmPreparedAuthority();
await options.confirmAuthenticationAuthority();
},
});
return Object.freeze(applied);
} finally {
await database.close();
}
}
@@ -0,0 +1,272 @@
import {
applyReviewedLocalDataDirectoryAdoption,
LocalDataDirectoryAdoptionApplicationConfigurationError,
} from '@qinglong/local-admin/data-directory-adoption';
import { LocalSecretKeyringFileProvider } from '@qinglong/local-secret';
import { openLocalSqliteBootstrapDatabase } from '@qinglong/local-sqlite/bootstrap';
import {
establishAuthenticatedLocalCommand,
type AuthenticatedLocalCommand,
} from '@qinglong/local-owner-console/authenticated-command';
import {
LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_OPERATION,
LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_VERIFY_OPERATION,
LOCAL_DATA_DIRECTORY_ADOPTION_VERIFY_OPERATION,
LocalDataDirectoryAdoptionConfigurationError,
type ApplyLocalDataDirectoryAdoptionCommand,
type VerifyLocalDataDirectoryAdoptionApplicationCommand,
type VerifyLocalDataDirectoryAdoptionCommand,
} from '../contract';
import { verifyLocalDataDirectoryAdoption } from '../staging';
import { transformationAuthority } from '../transformation/files';
import {
loadStaticTransformation,
verifyTransformationManifestBinding,
} from '../transformation/manifest';
import {
reclaimCommittedTransformationModel,
verifyCommittedTransformationModel,
} from './cleanup';
export interface LocalDataDirectoryApplicationResult {
readonly schemaVersion: 1;
readonly operation:
| typeof LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_OPERATION
| typeof LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_VERIFY_OPERATION;
readonly status: 'committed' | 'verified';
readonly evidence: Readonly<{
profile: 'edge' | 'standalone';
databaseStatus: 'inserted' | 'existing';
sourceStageManifestDigest: string;
transformationDigest: string;
modelDigest: string;
publicationDigest: string;
receiptDigest: string;
commitDigest: string;
secretCount: number;
environmentSecretCount: number;
sshSecretCount: number;
committedAtMs: number;
modelReclaimed: true;
plaintextFilesRemoved: true;
physicalErasureGuaranteed: false;
}>;
}
type ApplicationCommand =
| Readonly<ApplyLocalDataDirectoryAdoptionCommand>
| Readonly<VerifyLocalDataDirectoryAdoptionApplicationCommand>;
function sourceVerificationCommand(
command: ApplicationCommand,
): Readonly<VerifyLocalDataDirectoryAdoptionCommand> {
return Object.freeze({
schemaVersion: 1,
operation: LOCAL_DATA_DIRECTORY_ADOPTION_VERIFY_OPERATION,
options: Object.freeze({
deploymentRoot: command.options.deploymentRoot,
dataRoot: command.options.dataRoot,
stagingRoot: command.options.stagingRoot,
profile: command.options.profile,
sqlite: command.options.sqlite,
expectedManifestDigest: command.options.expectedManifestDigest,
}),
});
}
async function loadPreparedModel(
command: ApplicationCommand,
authority: ReturnType<typeof transformationAuthority>,
) {
const before = await verifyLocalDataDirectoryAdoption(
sourceVerificationCommand(command),
);
const loaded = loadStaticTransformation({
authority,
profile: command.options.profile,
projectId: command.options.projectId,
sourceStageManifestDigest: command.options.expectedManifestDigest,
expectedTransformationDigest: command.options.expectedTransformationDigest,
});
if (
loaded.manifest.assessment !== 'ready' ||
loaded.manifest.model.manualCategories !== 0 ||
(loaded.prepared.model.manualReview as { readonly required?: unknown })
.required !== false
) {
throw new LocalDataDirectoryAdoptionApplicationConfigurationError(
'manual review must be resolved in a new transformation before apply',
);
}
const after = await verifyLocalDataDirectoryAdoption(
sourceVerificationCommand(command),
);
if (JSON.stringify(before.evidence) !== JSON.stringify(after.evidence)) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'staged source changed while preparing the application transaction',
);
}
return loaded.prepared;
}
function result(
operation: LocalDataDirectoryApplicationResult['operation'],
status: LocalDataDirectoryApplicationResult['status'],
databaseStatus: 'inserted' | 'existing',
adoption: Awaited<
ReturnType<typeof applyReviewedLocalDataDirectoryAdoption>
>['adoption'],
commit: ReturnType<typeof reclaimCommittedTransformationModel>,
): Readonly<LocalDataDirectoryApplicationResult> {
return Object.freeze({
schemaVersion: 1,
operation,
status,
evidence: Object.freeze({
profile: adoption.profile,
databaseStatus,
sourceStageManifestDigest: adoption.sourceStageManifestDigest,
transformationDigest: adoption.transformationDigest,
modelDigest: adoption.modelDigest,
publicationDigest: adoption.publicationDigest,
receiptDigest: adoption.receiptDigest,
commitDigest: commit.commitDigest,
secretCount: adoption.receipt.secretCount,
environmentSecretCount: adoption.receipt.environmentSecretCount,
sshSecretCount: adoption.receipt.sshSecretCount,
committedAtMs: adoption.committedAtMs,
modelReclaimed: true,
plaintextFilesRemoved: true,
physicalErasureGuaranteed: false,
}),
});
}
async function authenticatedAuthority(command: ApplicationCommand): Promise<
Readonly<{
authenticated: Readonly<AuthenticatedLocalCommand>;
close(): Promise<void>;
}>
> {
const database = await openLocalSqliteBootstrapDatabase({
databasePath: command.options.sqlite.targetPath,
profile: command.options.profile,
...(command.options.busyTimeoutMs === undefined
? {}
: { busyTimeoutMs: command.options.busyTimeoutMs }),
});
try {
const authenticated = await establishAuthenticatedLocalCommand(database, {
deploymentRoot: command.options.deploymentRoot,
databasePath: command.options.sqlite.targetPath,
ownerPepperKeyringDirectory: command.options.ownerPepperKeyringDirectory,
credentialFilePath: command.options.credentialFilePath,
authenticationNamespace: 'local_data_adoption',
});
return Object.freeze({
authenticated,
close: () => database.close(),
});
} catch (error) {
await database.close();
throw error;
}
}
async function apply(
command: ApplicationCommand,
verifyOnly: boolean,
): Promise<Readonly<LocalDataDirectoryApplicationResult>> {
const authority = transformationAuthority(command.options, false);
const manifest = verifyTransformationManifestBinding({
authority,
profile: command.options.profile,
projectId: command.options.projectId,
sourceStageManifestDigest: command.options.expectedManifestDigest,
expectedTransformationDigest: command.options.expectedTransformationDigest,
});
const authentication = await authenticatedAuthority(command);
try {
const applied = await applyReviewedLocalDataDirectoryAdoption({
databasePath: command.options.sqlite.targetPath,
profile: command.options.profile,
projectId: command.options.projectId,
mutationId: command.options.mutationId,
failureAuditEventId: command.options.failureAuditEventId,
requestId: command.options.requestId,
sourceStageManifestDigest: command.options.expectedManifestDigest,
transformationDigest: command.options.expectedTransformationDigest,
modelDigest: manifest.model.digest,
principal: authentication.authenticated.principal,
keyProvider: new LocalSecretKeyringFileProvider(
command.options.secretKeyringPath,
),
observedAtMs: Date.now(),
...(command.options.busyTimeoutMs === undefined
? {}
: { busyTimeoutMs: command.options.busyTimeoutMs }),
loadPreparedModel: verifyOnly
? () => {
throw new LocalDataDirectoryAdoptionConfigurationError(
'application receipt does not exist',
);
}
: () => loadPreparedModel(command, authority),
confirmAuthenticationAuthority: () =>
authentication.authenticated.confirm(),
async confirmPreparedAuthority() {
await loadPreparedModel(command, authority);
},
});
if (
applied.adoption.modelDigest !== manifest.model.digest ||
applied.adoption.transformationDigest !== manifest.transformationDigest
) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'database application does not match the transformation manifest',
);
}
if (verifyOnly) {
const verifyCommand =
command as Readonly<VerifyLocalDataDirectoryAdoptionApplicationCommand>;
const commit = verifyCommittedTransformationModel({
authority,
adoption: applied.adoption,
expectedReceiptDigest: verifyCommand.options.expectedReceiptDigest,
});
return result(
LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_VERIFY_OPERATION,
'verified',
applied.status,
applied.adoption,
commit,
);
}
const commit = reclaimCommittedTransformationModel({
authority,
adoption: applied.adoption,
});
return result(
LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_OPERATION,
'committed',
applied.status,
applied.adoption,
commit,
);
} finally {
await authentication.close();
}
}
export function applyLocalDataDirectoryAdoption(
command: Readonly<ApplyLocalDataDirectoryAdoptionCommand>,
): Promise<Readonly<LocalDataDirectoryApplicationResult>> {
return apply(command, false);
}
export function verifyLocalDataDirectoryAdoptionApplication(
command: Readonly<VerifyLocalDataDirectoryAdoptionApplicationCommand>,
): Promise<Readonly<LocalDataDirectoryApplicationResult>> {
return apply(command, true);
}
@@ -0,0 +1,468 @@
import fs from 'node:fs';
import path from 'node:path';
import {
createLocalDataDirectorySourceNameDigest,
type LocalDataDirectoryAdoptionRecord,
} from '@qinglong/local-sqlite/data-directory-adoption';
import { LocalDataDirectoryAdoptionConfigurationError } from '../contract';
import {
assertPrivateDirectory,
sameStat,
sortedNames,
syncDirectory,
} from '../filesystem';
import { sha256Text } from '../manifest';
import {
readStablePrivateUtf8File,
writePrivateJson,
type TransformationAuthority,
} from '../transformation/files';
import { verifyTransformationModel } from '../transformation/model';
import { verifyTransformationManifestBinding } from '../transformation/manifest';
export const APPLICATION_COMMIT_NAME = 'commit.json';
const COMMIT_INCOMPLETE_NAME = '.commit-incomplete';
const RECLAIMING_MODEL_NAME = '.reclaiming-model';
const MODEL_NAME = 'model';
const MAX_JSON_BYTES = 1024 * 1024;
const ZERO_CHUNK = Buffer.alloc(64 * 1024);
interface LocalDataDirectoryApplicationCommitPayload {
readonly schemaVersion: 1;
readonly kind: 'qinglong3-legacy-data-directory-application';
readonly state: 'committed';
readonly mutationId: string;
readonly profile: 'edge' | 'standalone';
readonly projectIdDigest: string;
readonly sourceStageManifestDigest: string;
readonly transformationDigest: string;
readonly modelDigest: string;
readonly publicationDigest: string;
readonly receiptDigest: string;
readonly secretCount: number;
readonly environmentSecretCount: number;
readonly sshSecretCount: number;
readonly committedAtMs: number;
readonly reclamation: Readonly<{
modelRemoved: true;
plaintextFilesRemoved: true;
physicalErasureGuaranteed: false;
}>;
}
export interface LocalDataDirectoryApplicationCommit
extends LocalDataDirectoryApplicationCommitPayload {
readonly commitDigest: string;
}
function exactKeys(value: object, expected: readonly string[]): boolean {
const actual = Object.keys(value).sort();
const canonical = [...expected].sort();
return (
actual.length === canonical.length &&
actual.every((key, index) => key === canonical[index])
);
}
function expectedCommit(
adoption: Readonly<LocalDataDirectoryAdoptionRecord>,
): Readonly<LocalDataDirectoryApplicationCommit> {
const payload: LocalDataDirectoryApplicationCommitPayload = {
schemaVersion: 1,
kind: 'qinglong3-legacy-data-directory-application',
state: 'committed',
mutationId: adoption.mutationId,
profile: adoption.profile,
projectIdDigest: sha256Text(adoption.projectId),
sourceStageManifestDigest: adoption.sourceStageManifestDigest,
transformationDigest: adoption.transformationDigest,
modelDigest: adoption.modelDigest,
publicationDigest: adoption.publicationDigest,
receiptDigest: adoption.receiptDigest,
secretCount: adoption.receipt.secretCount,
environmentSecretCount: adoption.receipt.environmentSecretCount,
sshSecretCount: adoption.receipt.sshSecretCount,
committedAtMs: adoption.committedAtMs,
reclamation: Object.freeze({
modelRemoved: true,
plaintextFilesRemoved: true,
physicalErasureGuaranteed: false,
}),
};
return Object.freeze({
...payload,
commitDigest: sha256Text(JSON.stringify(payload)),
});
}
function readJson(filePath: string, uid: number, label: string): unknown {
try {
return JSON.parse(
readStablePrivateUtf8File(filePath, uid, MAX_JSON_BYTES, label),
);
} catch (error) {
if (error instanceof LocalDataDirectoryAdoptionConfigurationError) {
throw error;
}
throw new LocalDataDirectoryAdoptionConfigurationError(
`${label} JSON is invalid`,
error,
);
}
}
function sameJson(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
function verifyMarker(
root: string,
uid: number,
adoption: Readonly<LocalDataDirectoryAdoptionRecord>,
): void {
const marker = readJson(
path.join(root, COMMIT_INCOMPLETE_NAME),
uid,
'application recovery marker',
);
if (
!marker ||
typeof marker !== 'object' ||
Array.isArray(marker) ||
!exactKeys(marker, [
'kind',
'mutationId',
'receiptDigest',
'schemaVersion',
'transformationDigest',
])
) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'application recovery marker shape is invalid',
);
}
const candidate = marker as Record<string, unknown>;
if (
candidate.schemaVersion !== 1 ||
candidate.kind !==
'qinglong3-legacy-data-directory-application-incomplete' ||
candidate.mutationId !== adoption.mutationId ||
candidate.transformationDigest !== adoption.transformationDigest ||
candidate.receiptDigest !== adoption.receiptDigest
) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'application recovery marker does not match the committed database',
);
}
}
function verifyCommit(
authority: Readonly<TransformationAuthority>,
adoption: Readonly<LocalDataDirectoryAdoptionRecord>,
): Readonly<LocalDataDirectoryApplicationCommit> {
const actual = readJson(
path.join(authority.transformationRoot, APPLICATION_COMMIT_NAME),
authority.uid,
'application commit',
);
const expected = expectedCommit(adoption);
if (!sameJson(actual, expected)) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'application commit does not match the durable database receipt',
);
}
return expected;
}
function assertPreparedMatches(
modelRoot: string,
authority: Readonly<TransformationAuthority>,
adoption: Readonly<LocalDataDirectoryAdoptionRecord>,
): void {
const manifest = verifyTransformationManifestBinding({
authority,
profile: adoption.profile,
projectId: adoption.projectId,
sourceStageManifestDigest: adoption.sourceStageManifestDigest,
expectedTransformationDigest: adoption.transformationDigest,
});
const prepared = verifyTransformationModel({
modelRoot,
uid: authority.uid,
projectId: adoption.projectId,
profile: adoption.profile,
expected: manifest.model,
});
if (
manifest.assessment !== 'ready' ||
manifest.model.manualCategories !== 0 ||
!sameJson(prepared.model, adoption.model) ||
prepared.secrets.length !== adoption.secrets.length ||
prepared.secrets.some((secret, index) => {
const stored = adoption.secrets[index];
return (
!stored ||
stored.ordinal !== index + 1 ||
stored.kind !== secret.kind ||
stored.sourceNameDigest !==
createLocalDataDirectorySourceNameDigest(
secret.kind,
secret.sourceName,
) ||
stored.secretName !== secret.targetName ||
stored.valueFile !== secret.valueFile ||
stored.valueDigest !== secret.valueDigest
);
})
) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'prepared model does not match the committed database receipt',
);
}
}
function privateEntry(
entryPath: string,
uid: number,
kind: 'file' | 'directory',
): fs.BigIntStats {
const stat = fs.lstatSync(entryPath, { bigint: true });
if (
stat.isSymbolicLink() ||
stat.uid !== BigInt(uid) ||
(stat.mode & 0o777n) !== (kind === 'file' ? 0o600n : 0o700n) ||
(kind === 'file'
? !stat.isFile() || stat.nlink !== 1n
: !stat.isDirectory())
) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'reclaiming model identity or mode is invalid',
);
}
return stat;
}
function overwriteAndUnlink(filePath: string, uid: number): void {
const expected = privateEntry(filePath, uid, 'file');
if (expected.size < 0n || expected.size > BigInt(32 * 1024)) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'reclaiming Secret file size is invalid',
);
}
const descriptor = fs.openSync(
filePath,
fs.constants.O_RDWR | (fs.constants.O_NOFOLLOW ?? 0),
);
try {
const opened = fs.fstatSync(descriptor, { bigint: true });
if (!sameStat(expected, opened)) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'reclaiming Secret identity changed before overwrite',
);
}
let remaining = Number(opened.size);
let offset = 0;
while (remaining > 0) {
const count = Math.min(remaining, ZERO_CHUNK.length);
const written = fs.writeSync(descriptor, ZERO_CHUNK, 0, count, offset);
if (written < 1) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'reclaiming Secret overwrite made no progress',
);
}
remaining -= written;
offset += written;
}
fs.fsyncSync(descriptor);
} finally {
fs.closeSync(descriptor);
}
fs.unlinkSync(filePath);
}
function reclaimRenamedModel(
root: string,
uid: number,
adoption: Readonly<LocalDataDirectoryAdoptionRecord>,
): void {
const modelRoot = path.join(root, RECLAIMING_MODEL_NAME);
privateEntry(modelRoot, uid, 'directory');
const allowedRootFiles = new Set([
'config.json',
'keyv.json',
'manual-review.json',
'secret-imports.json',
'ssh.json',
'secret-values',
]);
if (sortedNames(modelRoot).some((name) => !allowedRootFiles.has(name))) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'reclaiming model contains an unexpected entry',
);
}
const secretRoot = path.join(modelRoot, 'secret-values');
if (fs.existsSync(secretRoot)) {
privateEntry(secretRoot, uid, 'directory');
const expectedFiles = new Set(
adoption.secrets.map(({ valueFile }) => path.basename(valueFile)),
);
const actualFiles = sortedNames(secretRoot);
if (actualFiles.some((name) => !expectedFiles.has(name))) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'reclaiming Secret directory contains an unexpected entry',
);
}
for (const name of actualFiles) {
overwriteAndUnlink(path.join(secretRoot, name), uid);
}
fs.rmdirSync(secretRoot);
syncDirectory(modelRoot);
}
for (const name of [
'config.json',
'keyv.json',
'ssh.json',
'manual-review.json',
'secret-imports.json',
]) {
const filePath = path.join(modelRoot, name);
if (!fs.existsSync(filePath)) continue;
privateEntry(filePath, uid, 'file');
fs.unlinkSync(filePath);
}
syncDirectory(modelRoot);
if (sortedNames(modelRoot).length !== 0) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'reclaiming model could not be emptied',
);
}
fs.rmdirSync(modelRoot);
syncDirectory(root);
}
export function reclaimCommittedTransformationModel(options: {
readonly authority: Readonly<TransformationAuthority>;
readonly adoption: Readonly<LocalDataDirectoryAdoptionRecord>;
}): Readonly<LocalDataDirectoryApplicationCommit> {
const { authority, adoption } = options;
const root = authority.transformationRoot;
const before = assertPrivateDirectory(
root,
authority.uid,
'transformationRoot',
);
verifyTransformationManifestBinding({
authority,
profile: adoption.profile,
projectId: adoption.projectId,
sourceStageManifestDigest: adoption.sourceStageManifestDigest,
expectedTransformationDigest: adoption.transformationDigest,
});
const initialNames = sortedNames(root);
const allowedNames = new Set([
'manifest.json',
APPLICATION_COMMIT_NAME,
COMMIT_INCOMPLETE_NAME,
MODEL_NAME,
RECLAIMING_MODEL_NAME,
]);
if (
initialNames.some((name) => !allowedNames.has(name)) ||
!initialNames.includes('manifest.json') ||
(initialNames.includes(MODEL_NAME) &&
initialNames.includes(RECLAIMING_MODEL_NAME))
) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'application recovery root shape is invalid',
);
}
if (!initialNames.includes(COMMIT_INCOMPLETE_NAME)) {
if (
initialNames.includes(APPLICATION_COMMIT_NAME) &&
!initialNames.includes(MODEL_NAME) &&
!initialNames.includes(RECLAIMING_MODEL_NAME)
) {
return verifyCommit(authority, adoption);
}
writePrivateJson(path.join(root, COMMIT_INCOMPLETE_NAME), {
schemaVersion: 1,
kind: 'qinglong3-legacy-data-directory-application-incomplete',
mutationId: adoption.mutationId,
transformationDigest: adoption.transformationDigest,
receiptDigest: adoption.receiptDigest,
});
syncDirectory(root);
}
verifyMarker(root, authority.uid, adoption);
const modelRoot = path.join(root, MODEL_NAME);
const reclaimingRoot = path.join(root, RECLAIMING_MODEL_NAME);
if (fs.existsSync(modelRoot)) {
assertPreparedMatches(modelRoot, authority, adoption);
if (fs.existsSync(reclaimingRoot)) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'multiple application model recovery roots exist',
);
}
fs.renameSync(modelRoot, reclaimingRoot);
syncDirectory(root);
}
if (fs.existsSync(reclaimingRoot)) {
reclaimRenamedModel(root, authority.uid, adoption);
}
const commitPath = path.join(root, APPLICATION_COMMIT_NAME);
if (!fs.existsSync(commitPath)) {
writePrivateJson(commitPath, expectedCommit(adoption));
syncDirectory(root);
}
const commit = verifyCommit(authority, adoption);
fs.unlinkSync(path.join(root, COMMIT_INCOMPLETE_NAME));
syncDirectory(root);
if (
JSON.stringify(sortedNames(root)) !==
JSON.stringify([APPLICATION_COMMIT_NAME, 'manifest.json'].sort())
) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'committed transformation root contains unexpected entries',
);
}
const after = fs.lstatSync(root, { bigint: true });
if (
before.dev !== after.dev ||
before.ino !== after.ino ||
before.uid !== after.uid ||
(after.mode & 0o777n) !== 0o700n
) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'transformation root identity changed during application cleanup',
);
}
return commit;
}
export function verifyCommittedTransformationModel(options: {
readonly authority: Readonly<TransformationAuthority>;
readonly adoption: Readonly<LocalDataDirectoryAdoptionRecord>;
readonly expectedReceiptDigest: string;
}): Readonly<LocalDataDirectoryApplicationCommit> {
if (
options.adoption.receiptDigest !== options.expectedReceiptDigest ||
JSON.stringify(sortedNames(options.authority.transformationRoot)) !==
JSON.stringify([APPLICATION_COMMIT_NAME, 'manifest.json'].sort())
) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'committed transformation evidence is incomplete',
);
}
verifyTransformationManifestBinding({
authority: options.authority,
profile: options.adoption.profile,
projectId: options.adoption.projectId,
sourceStageManifestDigest: options.adoption.sourceStageManifestDigest,
expectedTransformationDigest: options.adoption.transformationDigest,
});
return verifyCommit(options.authority, options.adoption);
}
@@ -1,10 +1,17 @@
import {
LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_OPERATION,
LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_VERIFY_OPERATION,
LOCAL_DATA_DIRECTORY_ADOPTION_INSPECT_OPERATION,
LOCAL_DATA_DIRECTORY_ADOPTION_STAGE_OPERATION,
LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_OPERATION,
LOCAL_DATA_DIRECTORY_ADOPTION_VERIFY_OPERATION,
normalizeLocalDataDirectoryAdoptionCommand,
} from './contract';
import {
applyLocalDataDirectoryAdoption,
verifyLocalDataDirectoryAdoptionApplication,
type LocalDataDirectoryApplicationResult,
} from './application/application';
import {
inspectLocalDataDirectoryAdoption,
type LocalDataDirectoryAdoptionInspectResult,
@@ -23,7 +30,8 @@ import {
export type LocalDataDirectoryAdoptionProductCommandResult =
| LocalDataDirectoryAdoptionInspectResult
| LocalDataDirectoryAdoptionMutationResult
| LocalDataDirectoryTransformationResult;
| LocalDataDirectoryTransformationResult
| LocalDataDirectoryApplicationResult;
export async function runLocalDataDirectoryAdoptionProductCommand(
value: unknown,
@@ -41,5 +49,13 @@ export async function runLocalDataDirectoryAdoptionProductCommand(
if (command.operation === LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_OPERATION) {
return transformLocalDataDirectoryAdoption(command);
}
if (command.operation === LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_OPERATION) {
return applyLocalDataDirectoryAdoption(command);
}
if (
command.operation === LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_VERIFY_OPERATION
) {
return verifyLocalDataDirectoryAdoptionApplication(command);
}
return verifyLocalDataDirectoryAdoptionTransformation(command);
}
@@ -3,6 +3,9 @@ import path from 'node:path';
const MAX_PATH_BYTES = 4_096;
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
const PROJECT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const UUID_V4_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
export const LOCAL_DATA_DIRECTORY_ADOPTION_INSPECT_OPERATION =
'local-data-directory.adoption.inspect' as const;
@@ -14,13 +17,19 @@ export const LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_OPERATION =
'local-data-directory.adoption.transform' as const;
export const LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_VERIFY_OPERATION =
'local-data-directory.adoption.transform.verify' as const;
export const LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_OPERATION =
'local-data-directory.adoption.apply' as const;
export const LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_VERIFY_OPERATION =
'local-data-directory.adoption.apply.verify' as const;
export type LocalDataDirectoryAdoptionOperation =
| typeof LOCAL_DATA_DIRECTORY_ADOPTION_INSPECT_OPERATION
| typeof LOCAL_DATA_DIRECTORY_ADOPTION_STAGE_OPERATION
| typeof LOCAL_DATA_DIRECTORY_ADOPTION_VERIFY_OPERATION
| typeof LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_OPERATION
| typeof LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_VERIFY_OPERATION;
| typeof LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_VERIFY_OPERATION
| typeof LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_OPERATION
| typeof LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_VERIFY_OPERATION;
export interface InspectLocalDataDirectoryAdoptionCommand {
readonly schemaVersion: 1;
@@ -85,12 +94,40 @@ export interface VerifyLocalDataDirectoryAdoptionTransformationCommand {
};
}
interface LocalDataDirectoryAdoptionApplicationOptions
extends LocalDataDirectoryAdoptionTransformationOptions {
readonly expectedTransformationDigest: string;
readonly ownerPepperKeyringDirectory: string;
readonly credentialFilePath: string;
readonly secretKeyringPath: string;
readonly mutationId: string;
readonly failureAuditEventId: string;
readonly requestId: string;
readonly busyTimeoutMs?: number;
}
export interface ApplyLocalDataDirectoryAdoptionCommand {
readonly schemaVersion: 1;
readonly operation: typeof LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_OPERATION;
readonly options: LocalDataDirectoryAdoptionApplicationOptions;
}
export interface VerifyLocalDataDirectoryAdoptionApplicationCommand {
readonly schemaVersion: 1;
readonly operation: typeof LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_VERIFY_OPERATION;
readonly options: LocalDataDirectoryAdoptionApplicationOptions & {
readonly expectedReceiptDigest: string;
};
}
export type LocalDataDirectoryAdoptionCommand =
| InspectLocalDataDirectoryAdoptionCommand
| StageLocalDataDirectoryAdoptionCommand
| VerifyLocalDataDirectoryAdoptionCommand
| TransformLocalDataDirectoryAdoptionCommand
| VerifyLocalDataDirectoryAdoptionTransformationCommand;
| VerifyLocalDataDirectoryAdoptionTransformationCommand
| ApplyLocalDataDirectoryAdoptionCommand
| VerifyLocalDataDirectoryAdoptionApplicationCommand;
export class LocalDataDirectoryAdoptionConfigurationError extends TypeError {
readonly code = 'LOCAL_DATA_DIRECTORY_ADOPTION_CONFIGURATION_INVALID';
@@ -129,7 +166,19 @@ export function isLocalDataDirectoryAdoptionOperation(
value === LOCAL_DATA_DIRECTORY_ADOPTION_STAGE_OPERATION ||
value === LOCAL_DATA_DIRECTORY_ADOPTION_VERIFY_OPERATION ||
value === LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_OPERATION ||
value === LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_VERIFY_OPERATION
value === LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_VERIFY_OPERATION ||
value === LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_OPERATION ||
value === LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_VERIFY_OPERATION
);
}
function descendant(root: string, candidate: string): boolean {
const relative = path.relative(root, candidate);
return (
relative.length > 0 &&
relative !== '..' &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative)
);
}
@@ -219,6 +268,10 @@ export function normalizeLocalDataDirectoryAdoptionCommand(
);
}
const options = candidate.options as Record<string, unknown>;
const application =
candidate.operation === LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_OPERATION ||
candidate.operation ===
LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_VERIFY_OPERATION;
let expectedKeys: readonly string[];
if (candidate.operation === LOCAL_DATA_DIRECTORY_ADOPTION_INSPECT_OPERATION) {
expectedKeys = ['dataRoot', 'profile'];
@@ -245,9 +298,24 @@ export function normalizeLocalDataDirectoryAdoptionCommand(
? []
: ['projectId', 'transformationRoot']),
...(candidate.operation ===
LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_VERIFY_OPERATION
LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_VERIFY_OPERATION || application
? ['expectedTransformationDigest']
: []),
...(application
? [
'credentialFilePath',
'failureAuditEventId',
'mutationId',
'ownerPepperKeyringDirectory',
'requestId',
'secretKeyringPath',
...(options.busyTimeoutMs === undefined ? [] : ['busyTimeoutMs']),
]
: []),
...(candidate.operation ===
LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_VERIFY_OPERATION
? ['expectedReceiptDigest']
: []),
];
}
if (
@@ -282,7 +350,8 @@ export function normalizeLocalDataDirectoryAdoptionCommand(
candidate.operation ===
LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_OPERATION ||
candidate.operation ===
LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_VERIFY_OPERATION
LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_VERIFY_OPERATION ||
application
) {
if (
!normalizedAbsolutePath(options.transformationRoot) ||
@@ -294,8 +363,9 @@ export function normalizeLocalDataDirectoryAdoptionCommand(
);
}
if (
candidate.operation ===
LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_VERIFY_OPERATION &&
(candidate.operation ===
LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_VERIFY_OPERATION ||
application) &&
(typeof options.expectedTransformationDigest !== 'string' ||
!DIGEST_PATTERN.test(options.expectedTransformationDigest))
) {
@@ -303,6 +373,47 @@ export function normalizeLocalDataDirectoryAdoptionCommand(
'transformation digest is invalid',
);
}
if (application) {
for (const key of [
'ownerPepperKeyringDirectory',
'credentialFilePath',
'secretKeyringPath',
]) {
if (
!normalizedAbsolutePath(options[key]) ||
!descendant(
options.deploymentRoot as string,
options[key] as string,
)
) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'application authority path is invalid',
);
}
}
if (
options.credentialFilePath === options.secretKeyringPath ||
typeof options.mutationId !== 'string' ||
!UUID_V4_PATTERN.test(options.mutationId) ||
typeof options.failureAuditEventId !== 'string' ||
!UUID_V4_PATTERN.test(options.failureAuditEventId) ||
options.failureAuditEventId === options.mutationId ||
typeof options.requestId !== 'string' ||
!REQUEST_ID_PATTERN.test(options.requestId) ||
(options.busyTimeoutMs !== undefined &&
(!Number.isSafeInteger(options.busyTimeoutMs) ||
(options.busyTimeoutMs as number) < 100 ||
(options.busyTimeoutMs as number) > 30_000)) ||
(candidate.operation ===
LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_VERIFY_OPERATION &&
(typeof options.expectedReceiptDigest !== 'string' ||
!DIGEST_PATTERN.test(options.expectedReceiptDigest)))
) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'application authority binding is invalid',
);
}
}
}
}
return Object.freeze(value as LocalDataDirectoryAdoptionCommand);
@@ -13,6 +13,7 @@ import {
type LocalDataDirectoryTransformationManifest,
type TransformationModelEvidence,
type TransformationSourceEvidence,
type VerifiedTransformationModel,
} from './model';
export const TRANSFORMATION_MANIFEST_NAME = 'manifest.json';
@@ -210,6 +211,54 @@ export function verifyStaticTransformation(options: {
readonly sourceStageManifestDigest: string;
readonly expectedTransformationDigest: string;
}): Readonly<LocalDataDirectoryTransformationManifest> {
return loadStaticTransformation(options).manifest;
}
export function verifyTransformationManifestBinding(options: {
readonly authority: Readonly<TransformationAuthority>;
readonly profile: 'edge' | 'standalone';
readonly projectId: string;
readonly sourceStageManifestDigest: string;
readonly expectedTransformationDigest: string;
}): Readonly<LocalDataDirectoryTransformationManifest> {
const before = assertPrivateDirectory(
options.authority.transformationRoot,
options.authority.uid,
'transformationRoot',
);
const manifest = readManifest(
options.authority.transformationRoot,
options.authority.uid,
);
if (
manifest.transformationDigest !== options.expectedTransformationDigest ||
manifest.profile !== options.profile ||
manifest.projectIdDigest !== sha256Text(options.projectId) ||
manifest.sourceStageManifestDigest !== options.sourceStageManifestDigest ||
manifest.transformationRootPathDigest !==
sha256Text(options.authority.transformationRoot) ||
!sameStat(
before,
fs.lstatSync(options.authority.transformationRoot, { bigint: true }),
)
) {
throw new LocalDataDirectoryAdoptionConfigurationError(
'transformation manifest authority binding is invalid',
);
}
return manifest;
}
export function loadStaticTransformation(options: {
readonly authority: Readonly<TransformationAuthority>;
readonly profile: 'edge' | 'standalone';
readonly projectId: string;
readonly sourceStageManifestDigest: string;
readonly expectedTransformationDigest: string;
}): Readonly<{
manifest: Readonly<LocalDataDirectoryTransformationManifest>;
prepared: Readonly<VerifiedTransformationModel>;
}> {
const before = assertPrivateDirectory(
options.authority.transformationRoot,
options.authority.uid,
@@ -239,7 +288,7 @@ export function verifyStaticTransformation(options: {
'transformation manifest authority binding is invalid',
);
}
verifyTransformationModel({
const prepared = verifyTransformationModel({
modelRoot: path.join(options.authority.transformationRoot, 'model'),
uid: options.authority.uid,
projectId: options.projectId,
@@ -256,5 +305,5 @@ export function verifyStaticTransformation(options: {
'transformation root changed during verification',
);
}
return manifest;
return Object.freeze({ manifest, prepared });
}
@@ -58,6 +58,22 @@ interface SecretImportEntry {
readonly valueDigest: string;
}
export interface VerifiedTransformationSecret extends SecretImportEntry {
readonly plaintext: string;
}
export interface VerifiedTransformationModel {
readonly model: Readonly<{
schema: 'qinglong/legacy-data-directory-applied-model@v1';
activation: 'disabled';
config: Readonly<Record<string, unknown>>;
keyv: Readonly<Record<string, unknown>>;
ssh: Readonly<Record<string, unknown>>;
manualReview: Readonly<Record<string, unknown>>;
}>;
readonly secrets: readonly Readonly<VerifiedTransformationSecret>[];
}
function exactKeys(value: object, expected: readonly string[]): boolean {
const actual = Object.keys(value).sort();
const canonical = [...expected].sort();
@@ -252,7 +268,7 @@ export function verifyTransformationModel(options: {
readonly projectId: string;
readonly profile: 'edge' | 'standalone';
readonly expected: Readonly<TransformationModelEvidence>;
}): void {
}): Readonly<VerifiedTransformationModel> {
if (
JSON.stringify(sortedNames(options.modelRoot)) !==
JSON.stringify(
@@ -270,17 +286,17 @@ export function verifyTransformationModel(options: {
'transformation model root contains unexpected entries',
);
}
assertSchemaFile(
const config = assertSchemaFile(
path.join(options.modelRoot, 'config.json'),
options.uid,
'qinglong/legacy-config-transformation@v1',
);
assertSchemaFile(
const keyv = assertSchemaFile(
path.join(options.modelRoot, 'keyv.json'),
options.uid,
'qinglong/legacy-keyv-transformation@v1',
);
assertSchemaFile(
const ssh = assertSchemaFile(
path.join(options.modelRoot, 'ssh.json'),
options.uid,
'qinglong/legacy-ssh-transformation@v1',
@@ -329,6 +345,7 @@ export function verifyTransformationModel(options: {
}
const expectedFiles: string[] = [];
const targets = new Set<string>();
const secrets: VerifiedTransformationSecret[] = [];
let environmentSecrets = 0;
let sshSecrets = 0;
for (const value of candidate.imports) {
@@ -353,7 +370,13 @@ export function verifyTransformationModel(options: {
if (
(entry.kind !== 'environment' && entry.kind !== 'ssh_private_key') ||
typeof entry.sourceName !== 'string' ||
entry.sourceName.length < 1 ||
Buffer.byteLength(entry.sourceName, 'utf8') > 255 ||
/[\u0000-\u001f\u007f]/.test(entry.sourceName) ||
typeof entry.targetName !== 'string' ||
entry.targetName.length < 1 ||
Buffer.byteLength(entry.targetName, 'utf8') > 128 ||
/[\u0000-\u001f\u007f]/.test(entry.targetName) ||
entry.expectedCurrentVersion !== 0 ||
typeof entry.valueFile !== 'string' ||
!SECRET_FILE_PATTERN.test(entry.valueFile) ||
@@ -396,6 +419,17 @@ export function verifyTransformationModel(options: {
}
if (entry.kind === 'environment') environmentSecrets += 1;
else sshSecrets += 1;
secrets.push(
Object.freeze({
kind: entry.kind,
sourceName: entry.sourceName,
targetName: entry.targetName,
expectedCurrentVersion: 0,
valueFile: entry.valueFile,
valueDigest: entry.valueDigest,
plaintext: secretValue.value,
}) as Readonly<VerifiedTransformationSecret>,
);
}
expectedFiles.sort();
if (
@@ -427,4 +461,15 @@ export function verifyTransformationModel(options: {
'transformation model no longer matches the manifest',
);
}
return Object.freeze({
model: Object.freeze({
schema: 'qinglong/legacy-data-directory-applied-model@v1' as const,
activation: 'disabled' as const,
config: Object.freeze(config),
keyv: Object.freeze(keyv),
ssh: Object.freeze(ssh),
manualReview: Object.freeze(manual),
}),
secrets: Object.freeze(secrets),
});
}
@@ -6,6 +6,19 @@ const path = require('node:path');
const { DatabaseSync } = require('node:sqlite');
const { test } = require('node:test');
const {
provisionLocalOwnerPepperKey,
} = require('@qinglong/local-owner-console');
const {
LocalSecretKeyringFileProvider,
decryptLocalSecretEnvelopeToBuffer,
provisionLocalSecretKeyring,
} = require('@qinglong/local-secret');
const {
apiCredentialSecretDigest,
formatApiCredentialToken,
} = require('@qinglong/runtime-core/api-credential-token');
const BINARY = path.join(__dirname, '../dist/lifecycle/adoptionCli.js');
const DIRECTORY_INSPECT = 'local-data-directory.adoption.inspect';
const DIRECTORY_STAGE = 'local-data-directory.adoption.stage';
@@ -13,6 +26,18 @@ const DIRECTORY_VERIFY = 'local-data-directory.adoption.verify';
const DIRECTORY_TRANSFORM = 'local-data-directory.adoption.transform';
const DIRECTORY_TRANSFORM_VERIFY =
'local-data-directory.adoption.transform.verify';
const DIRECTORY_APPLY = 'local-data-directory.adoption.apply';
const DIRECTORY_APPLY_VERIFY = 'local-data-directory.adoption.apply.verify';
const APPLICATION_CREDENTIAL_ID = 'data-adoption-owner';
const APPLICATION_PEPPER_KEY_ID = 'data-adoption-owner-v1';
const APPLICATION_PEPPER = Buffer.alloc(32, 121).toString('base64url');
const APPLICATION_CREDENTIAL_SECRET = Buffer.alloc(32, 122).toString(
'base64url',
);
const APPLICATION_TOKEN = formatApiCredentialToken(
APPLICATION_CREDENTIAL_ID,
APPLICATION_CREDENTIAL_SECRET,
);
function privateDirectory(directoryPath) {
fs.mkdirSync(directoryPath, { recursive: true, mode: 0o700 });
@@ -25,6 +50,15 @@ function privateFile(filePath, content) {
fs.chmodSync(filePath, 0o600);
}
function privatizeTree(root) {
fs.chmodSync(root, 0o700);
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
const entryPath = path.join(root, entry.name);
if (entry.isDirectory()) privatizeTree(entryPath);
else fs.chmodSync(entryPath, 0o600);
}
}
function createLegacyDatabase(sourcePath) {
const source = new DatabaseSync(sourcePath);
source.exec(`
@@ -371,6 +405,184 @@ function transformationOptions(value, prepared, staged, projectId) {
};
}
async function provisionApplicationAuthority(value, role = 'owner') {
const ownerPepperKeyringDirectory = path.join(
value.deploymentRoot,
'owner-keys',
);
const credentialFilePath = path.join(
value.deploymentRoot,
'owner-credential.json',
);
const secretKeyringPath = path.join(
value.deploymentRoot,
'local-secret-keyring.json',
);
privateDirectory(ownerPepperKeyringDirectory);
await provisionLocalSecretKeyring(secretKeyringPath);
const pepper = provisionLocalOwnerPepperKey({
keyringDirectory: ownerPepperKeyringDirectory,
pepperKeyId: APPLICATION_PEPPER_KEY_ID,
randomBytes: () => Buffer.alloc(32, 121),
});
const now = Date.now();
const database = new DatabaseSync(value.targetPath);
try {
database
.prepare(
`INSERT INTO "QingLong3LocalOwnerPepperKeys" (
"pepper_key_id", "material_digest", "backup_digest", "state",
"version", "register_mutation_id", "activate_mutation_id",
"registered_at_ms", "activated_at_ms"
) VALUES (?, ?, ?, 'active', 2, ?, ?, ?, ?)`,
)
.run(
APPLICATION_PEPPER_KEY_ID,
pepper.digest,
'e'.repeat(64),
'd3870000-0000-4000-8000-000000000001',
'd3870000-0000-4000-8000-000000000002',
now - 2_000,
now - 1_500,
);
database
.prepare(
`INSERT INTO "QingLong3LocalOwnerPepperActivations" (
"generation", "mutation_id", "expected_generation",
"previous_pepper_key_id", "active_pepper_key_id",
"material_digest", "backup_digest", "activated_at_ms"
) VALUES (1, ?, 0, NULL, ?, ?, ?, ?)`,
)
.run(
'd3870000-0000-4000-8000-000000000002',
APPLICATION_PEPPER_KEY_ID,
pepper.digest,
'e'.repeat(64),
now - 1_500,
);
database
.prepare(
`INSERT INTO "QingLong3IdentitySubjects" (
"subject_type", "subject_id", "status", "version",
"created_at_ms", "updated_at_ms"
) VALUES ('user', 'data-adoption-owner', 'active', 1, ?, ?)`,
)
.run(now - 1_000, now - 1_000);
database
.prepare(
`INSERT INTO "QingLong3ApiCredentials" (
"credential_id", "version", "state", "subject_type",
"subject_id", "secret_digest", "created_at_ms",
"not_before_at_ms", "expires_at_ms"
) VALUES (?, 1, 'active', 'user', 'data-adoption-owner', ?, ?, ?, ?)`,
)
.run(
APPLICATION_CREDENTIAL_ID,
apiCredentialSecretDigest(
APPLICATION_PEPPER,
APPLICATION_CREDENTIAL_ID,
APPLICATION_CREDENTIAL_SECRET,
),
now - 1_000,
now - 1_000,
now + 10 * 60 * 1_000,
);
database
.prepare(
`INSERT INTO "QingLong3ApiCredentialPepperBindings" (
"credential_id", "credential_version", "pepper_key_id"
) VALUES (?, 1, ?)`,
)
.run(APPLICATION_CREDENTIAL_ID, APPLICATION_PEPPER_KEY_ID);
database
.prepare(
`INSERT INTO "QingLong3ProjectRoleBindings" (
"project_id", "subject_type", "subject_id", "version", "state",
"role", "mutation_id", "changed_by_type", "changed_by_id",
"created_at_ms"
) VALUES (
'default', 'user', 'data-adoption-owner', 1, 'active', ?,
'd387-owner-binding', 'user', 'data-adoption-owner', ?
)`,
)
.run(role, now - 500);
} finally {
database.close();
}
fs.chmodSync(value.targetPath, 0o600);
privateFile(
credentialFilePath,
`${JSON.stringify({
schemaVersion: 1,
kind: 'qinglong3-local-identity-credential-presentation',
token: APPLICATION_TOKEN,
})}\n`,
);
return {
ownerPepperKeyringDirectory,
credentialFilePath,
secretKeyringPath,
};
}
function applicationOptions(
value,
prepared,
staged,
authority,
transformationDigest,
) {
return {
...transformationOptions(value, prepared, staged, 'default'),
expectedTransformationDigest: transformationDigest,
...authority,
mutationId: 'd3870000-0000-4000-8000-000000000010',
failureAuditEventId: 'd3870000-0000-4000-8000-000000000011',
requestId: 'd387-data-directory-apply',
};
}
function applicationDatabaseRows(databasePath) {
const database = new DatabaseSync(databasePath, { readOnly: true });
try {
return {
adoptions: database
.prepare(
`SELECT mutation_id AS "mutationId", model_json AS "modelJson",
receipt_digest AS "receiptDigest"
FROM "QingLong3LegacyDataDirectoryAdoptions"`,
)
.all(),
items: database
.prepare(
`SELECT secret_name AS "secretName", value_digest AS "valueDigest"
FROM "QingLong3LegacyDataDirectoryAdoptionSecrets"
ORDER BY ordinal`,
)
.all(),
envelopes: database
.prepare(
`SELECT secret_name AS "secretName", version, mutation_id AS "mutationId",
key_id AS "keyId", algorithm, nonce, ciphertext,
auth_tag AS "authTag", created_at_ms AS "createdAtMs"
FROM "QingLong3LocalSecretEnvelopes"
ORDER BY secret_name`,
)
.all(),
audits: database
.prepare(
`SELECT operation_id AS "operationId", outcome
FROM "QingLong3SecurityAuditEvents"
WHERE operation_id IN ('legacy-data.apply', 'secret.create')
ORDER BY operation_id, event_id`,
)
.all(),
};
} finally {
database.close();
}
}
test('stages only reviewed payloads behind the real SQLite activation fence', (t) => {
const value = fixture(t);
const prepared = prepare(value);
@@ -927,3 +1139,285 @@ test('widened transformation commands fail closed before source access', (t) =>
'LOCAL_DATA_DIRECTORY_ADOPTION_CONFIGURATION_INVALID',
);
});
test('atomically applies a ready transformation, reclaims plaintext and exactly replays', async (t) => {
const value = fixture(t);
const secrets = configureTransformationInput(value);
const { prepared, staged } = stageForTransformation(value);
const authority = await provisionApplicationAuthority(value);
const transformed = run(
value,
'directory-transform-before-apply',
DIRECTORY_TRANSFORM,
transformationOptions(value, prepared, staged, 'default'),
).result;
const options = applicationOptions(
value,
prepared,
staged,
authority,
transformed.evidence.transformationDigest,
);
const recoveryModel = path.join(value.deploymentRoot, 'recovery-model');
fs.cpSync(path.join(value.transformationRoot, 'model'), recoveryModel, {
recursive: true,
preserveTimestamps: true,
});
privatizeTree(recoveryModel);
const applied = run(value, 'directory-apply', DIRECTORY_APPLY, options);
assert.equal(applied.result.status, 'committed');
assert.equal(applied.result.evidence.databaseStatus, 'inserted');
assert.equal(applied.result.evidence.secretCount, 2);
assert.equal(applied.result.evidence.environmentSecretCount, 1);
assert.equal(applied.result.evidence.sshSecretCount, 1);
assert.equal(applied.result.evidence.modelReclaimed, true);
assert.equal(applied.result.evidence.plaintextFilesRemoved, true);
assert.equal(applied.result.evidence.physicalErasureGuaranteed, false);
for (const sensitive of [
secrets.environmentValue,
secrets.sshValue,
secrets.authValue,
secrets.environmentName,
secrets.sshAlias,
]) {
assert.equal(applied.child.stdout.includes(sensitive), false);
}
assert.deepEqual(fs.readdirSync(value.transformationRoot).sort(), [
'commit.json',
'manifest.json',
]);
const commit = JSON.parse(
fs.readFileSync(path.join(value.transformationRoot, 'commit.json'), 'utf8'),
);
assert.equal(commit.reclamation.modelRemoved, true);
assert.equal(commit.reclamation.plaintextFilesRemoved, true);
assert.equal(commit.reclamation.physicalErasureGuaranteed, false);
assert.equal(commit.commitDigest, applied.result.evidence.commitDigest);
const rows = applicationDatabaseRows(value.targetPath);
assert.equal(rows.adoptions.length, 1);
assert.equal(rows.items.length, 2);
assert.equal(rows.envelopes.length, 2);
assert.deepEqual(
rows.audits.map(({ operationId, outcome }) => [operationId, outcome]),
[
['legacy-data.apply', 'allowed'],
['secret.create', 'allowed'],
['secret.create', 'allowed'],
],
);
const durableModel = JSON.parse(rows.adoptions[0].modelJson);
assert.equal(durableModel.activation, 'disabled');
assert.equal(durableModel.config.activation, 'disabled');
assert.equal(durableModel.keyv.activation, 'disabled');
assert.equal(durableModel.ssh.activation, 'disabled');
const provider = new LocalSecretKeyringFileProvider(
authority.secretKeyringPath,
);
const plaintexts = [];
for (const envelope of rows.envelopes) {
const material = await provider.resolve(envelope.keyId);
assert.ok(material);
const plaintext = decryptLocalSecretEnvelopeToBuffer(
{
projectId: 'default',
name: envelope.secretName,
version: envelope.version,
mutationId: envelope.mutationId,
keyId: envelope.keyId,
algorithm: envelope.algorithm,
nonce: Buffer.from(envelope.nonce).toString('base64url'),
ciphertext: Buffer.from(envelope.ciphertext).toString('base64url'),
authTag: Buffer.from(envelope.authTag).toString('base64url'),
createdAtMs: envelope.createdAtMs,
},
material.key,
);
try {
plaintexts.push(plaintext.toString('utf8'));
} finally {
plaintext.fill(0);
material.key.fill(0);
}
}
assert.deepEqual(
plaintexts.sort(),
[secrets.environmentValue, secrets.sshValue].sort(),
);
const replayed = run(
value,
'directory-apply-replay',
DIRECTORY_APPLY,
options,
).result;
assert.equal(replayed.evidence.databaseStatus, 'existing');
assert.equal(
replayed.evidence.receiptDigest,
applied.result.evidence.receiptDigest,
);
assert.equal(
replayed.evidence.commitDigest,
applied.result.evidence.commitDigest,
);
const verified = run(
value,
'directory-apply-verify',
DIRECTORY_APPLY_VERIFY,
{
...options,
expectedReceiptDigest: applied.result.evidence.receiptDigest,
},
).result;
assert.equal(verified.status, 'verified');
assert.equal(verified.evidence.databaseStatus, 'existing');
assert.equal(
verified.evidence.commitDigest,
applied.result.evidence.commitDigest,
);
fs.unlinkSync(path.join(value.transformationRoot, 'commit.json'));
fs.renameSync(
recoveryModel,
path.join(value.transformationRoot, '.reclaiming-model'),
);
privateFile(
path.join(value.transformationRoot, '.commit-incomplete'),
`${JSON.stringify({
schemaVersion: 1,
kind: 'qinglong3-legacy-data-directory-application-incomplete',
mutationId: options.mutationId,
transformationDigest: options.expectedTransformationDigest,
receiptDigest: applied.result.evidence.receiptDigest,
})}\n`,
);
const recovered = run(
value,
'directory-apply-recover-renamed-model',
DIRECTORY_APPLY,
options,
).result;
assert.equal(recovered.evidence.databaseStatus, 'existing');
assert.equal(
recovered.evidence.commitDigest,
applied.result.evidence.commitDigest,
);
assert.deepEqual(fs.readdirSync(value.transformationRoot).sort(), [
'commit.json',
'manifest.json',
]);
});
test('rolls back every new Secret when one application target already exists', async (t) => {
const value = fixture(t);
configureTransformationInput(value);
const { prepared, staged } = stageForTransformation(value);
const authority = await provisionApplicationAuthority(value);
const transformed = run(
value,
'directory-transform-before-conflict',
DIRECTORY_TRANSFORM,
transformationOptions(value, prepared, staged, 'default'),
).result;
const imports = JSON.parse(
fs.readFileSync(
path.join(value.transformationRoot, 'model', 'secret-imports.json'),
'utf8',
),
).imports;
assert.equal(imports.length, 2);
const conflictingName = imports[1].targetName;
const database = new DatabaseSync(value.targetPath);
try {
database
.prepare(
`INSERT INTO "QingLong3LocalSecretEnvelopes" (
"project_id", "secret_name", "version", "mutation_id", "key_id",
"algorithm", "nonce", "ciphertext", "auth_tag", "created_at_ms"
) VALUES ('default', ?, 1, ?, 'preexisting-v1', 'aes-256-gcm', ?, ?, ?, ?)`,
)
.run(
conflictingName,
'd3870000-0000-4000-8000-000000000020',
Buffer.alloc(12, 1),
Buffer.alloc(0),
Buffer.alloc(16, 2),
Date.now(),
);
} finally {
database.close();
}
const conflict = runRaw(
value,
'directory-apply-conflict',
DIRECTORY_APPLY,
applicationOptions(
value,
prepared,
staged,
authority,
transformed.evidence.transformationDigest,
),
);
assert.equal(conflict.status, 1);
assert.equal(conflict.stdout, '');
assert.equal(
JSON.parse(conflict.stderr).code,
'LOCAL_DATA_DIRECTORY_ADOPTION_CONFLICT',
);
const rows = applicationDatabaseRows(value.targetPath);
assert.equal(rows.adoptions.length, 0);
assert.equal(rows.items.length, 0);
assert.equal(rows.envelopes.length, 1);
assert.equal(rows.envelopes[0].secretName, conflictingName);
assert.equal(
fs.existsSync(path.join(value.transformationRoot, 'model')),
true,
);
});
test('rejects a non-admin application without publishing or reclaiming the model', async (t) => {
const value = fixture(t);
configureTransformationInput(value);
const { prepared, staged } = stageForTransformation(value);
const authority = await provisionApplicationAuthority(value, 'viewer');
const transformed = run(
value,
'directory-transform-before-denial',
DIRECTORY_TRANSFORM,
transformationOptions(value, prepared, staged, 'default'),
).result;
const denied = runRaw(
value,
'directory-apply-denied',
DIRECTORY_APPLY,
applicationOptions(
value,
prepared,
staged,
authority,
transformed.evidence.transformationDigest,
),
);
assert.equal(denied.status, 1);
assert.equal(denied.stdout, '');
assert.equal(
JSON.parse(denied.stderr).code,
'LOCAL_DATA_DIRECTORY_ADOPTION_APPLICATION_FORBIDDEN',
);
const rows = applicationDatabaseRows(value.targetPath);
assert.equal(rows.adoptions.length, 0);
assert.equal(rows.items.length, 0);
assert.equal(rows.envelopes.length, 0);
assert.deepEqual(
rows.audits.map(({ operationId, outcome }) => [operationId, outcome]),
[['legacy-data.apply', 'denied']],
);
assert.equal(
fs.existsSync(path.join(value.transformationRoot, 'model')),
true,
);
});
@@ -475,9 +475,9 @@ function composeDockerHarness(
'/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js',
],
Labels: {
'io.qinglong.local.sqlite-contract-min': '49',
'io.qinglong.local.sqlite-contract-max': '49',
'io.qinglong.local.sqlite-write-contract': '49',
'io.qinglong.local.sqlite-contract-min': '50',
'io.qinglong.local.sqlite-contract-max': '50',
'io.qinglong.local.sqlite-write-contract': '50',
'io.qinglong.local.application-config': '2',
'io.qinglong.local.compose-selection': '1',
'io.qinglong.ai': 'excluded',
@@ -1172,9 +1172,9 @@ test('preflights exact local image, Compose merge and SQLite capability', async
'/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js',
],
Labels: {
'io.qinglong.local.sqlite-contract-min': '49',
'io.qinglong.local.sqlite-contract-max': '49',
'io.qinglong.local.sqlite-write-contract': '49',
'io.qinglong.local.sqlite-contract-min': '50',
'io.qinglong.local.sqlite-contract-max': '50',
'io.qinglong.local.sqlite-write-contract': '50',
'io.qinglong.local.application-config': '2',
'io.qinglong.local.compose-selection': '1',
'io.qinglong.ai': 'excluded',
@@ -1226,7 +1226,7 @@ test('preflights exact local image, Compose merge and SQLite capability', async
assert.equal(result.status, 'ready');
assert.equal(result.generation, 1);
assert.equal(result.profile, 'edge');
assert.equal(result.sqlite.contractVersion, 49);
assert.equal(result.sqlite.contractVersion, 50);
assert.equal(result.image.architecture, 'arm64');
assert.equal(calls.length, 2);
assert.deepEqual(calls[0].slice(0, 2), ['image', 'inspect']);
@@ -1326,8 +1326,8 @@ test('applies one Compose generation and exactly replays its health receipt', as
assert.equal(mode(receiptPath), 0o600);
const receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf8'));
assert.deepEqual(receipt.sqlite, {
contractVersion: 49,
writeContractVersion: 49,
contractVersion: 50,
writeContractVersion: 50,
writeObservation: 'unchanged',
backup: null,
});
@@ -1628,8 +1628,8 @@ test('rolls a failed Compose candidate forward to a healthy prior digest', async
`${command.request.rolloutId}.sqlite`,
);
assert.equal(mode(backupPath), 0o600);
assert.equal(receipt.sqlite.contractVersion, 49);
assert.equal(receipt.sqlite.writeContractVersion, 49);
assert.equal(receipt.sqlite.contractVersion, 50);
assert.equal(receipt.sqlite.writeContractVersion, 50);
assert.equal(receipt.sqlite.writeObservation, 'changed');
assert.match(receipt.sqlite.backup.sha256, /^[0-9a-f]{64}$/);
assert.equal(receipt.sqlite.backup.bytes > 0, true);
@@ -34,8 +34,8 @@ test('inspects the exact fresh Profile schema without exposing its path', async
assert.equal(result.status, 'ready');
assert.equal(result.profile, 'edge');
assert.equal(result.storage.contractName, 'local-control-core');
assert.equal(result.storage.contractVersion, 49);
assert.equal(result.storage.migrationCount, 98);
assert.equal(result.storage.contractVersion, 50);
assert.equal(result.storage.migrationCount, 100);
assert.equal(result.storage.journalMode, 'delete');
assert.equal(JSON.stringify(result).includes(state.directory), false);
});
+5
View File
@@ -75,6 +75,11 @@
"require": "./dist/adoption/legacyAdoptionDatabase.js",
"default": "./dist/adoption/legacyAdoptionDatabase.js"
},
"./data-directory-adoption": {
"types": "./dist/adoption/data-directory/dataDirectoryAdoptionDatabase.d.ts",
"require": "./dist/adoption/data-directory/dataDirectoryAdoptionDatabase.js",
"default": "./dist/adoption/data-directory/dataDirectoryAdoptionDatabase.js"
},
"./plugin-package-install": {
"types": "./dist/plugin-package/pluginPackageInstallRepository.d.ts",
"require": "./dist/plugin-package/pluginPackageInstallRepository.js",
@@ -0,0 +1,994 @@
import { createHash } from 'node:crypto';
import type { DatabaseSync } from 'node:sqlite';
import {
createLocalSecretRef,
normalizeLocalSecretEnvelope,
type LocalSecretEnvelope,
} from '@qinglong/runtime-core/local-secret';
import {
normalizeProjectPolicySubject,
type ProjectPolicyRepository,
} from '@qinglong/runtime-core/project-policy';
import type {
SecurityPolicyFence,
SecuritySubject,
} from '@qinglong/runtime-core/security';
import {
normalizeSecurityAuditRecord,
type SecurityAuditRecord,
type SecurityAuditSink,
} from '@qinglong/runtime-core/security-audit';
import { LocalSqliteOperationAuthority } from '../../authority/operationAuthority';
import {
auditLocalSqliteReadiness,
type LocalSqliteReadinessEvidence,
} from '../../readiness/readiness';
import { LocalSqliteSecurityAuthorityStore } from '../../security/securityAuthorityStore';
import {
assertLocalSqliteOptions,
assertLocalSqlitePathBoundary,
openLocalSqliteClient,
type LocalSqliteDatabaseOptions,
type LocalSqliteProfile,
} from '../../storage/config';
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
const UUID_V4_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const VALUE_FILE_PATTERN = /^secret-values\/[0-9a-f]{64}\.json$/;
export interface LocalDataDirectoryAppliedModel {
readonly schema: 'qinglong/legacy-data-directory-applied-model@v1';
readonly activation: 'disabled';
readonly config: Readonly<Record<string, unknown>>;
readonly keyv: Readonly<Record<string, unknown>>;
readonly ssh: Readonly<Record<string, unknown>>;
readonly manualReview: Readonly<Record<string, unknown>>;
}
export interface LocalDataDirectoryAdoptionSecretPublication {
readonly ordinal: number;
readonly kind: 'environment' | 'ssh_private_key';
readonly sourceNameDigest: string;
readonly valueFile: string;
readonly valueDigest: string;
readonly envelope: Readonly<LocalSecretEnvelope>;
readonly secretRef: string;
readonly itemDigest: string;
readonly audit: Readonly<SecurityAuditRecord>;
}
export interface LocalDataDirectoryAdoptionSecretRecord {
readonly ordinal: number;
readonly kind: 'environment' | 'ssh_private_key';
readonly sourceNameDigest: string;
readonly secretName: string;
readonly secretVersion: 1;
readonly secretMutationId: string;
readonly valueFile: string;
readonly valueDigest: string;
readonly secretRef: string;
readonly itemDigest: string;
}
export interface LocalDataDirectoryAdoptionReceiptPayload {
readonly schema: 'qinglong/legacy-data-directory-adoption-receipt@v1';
readonly mutationId: string;
readonly projectId: string;
readonly profile: LocalSqliteProfile;
readonly sourceStageManifestDigest: string;
readonly transformationDigest: string;
readonly modelDigest: string;
readonly secretCount: number;
readonly environmentSecretCount: number;
readonly sshSecretCount: number;
readonly items: readonly Readonly<{
ordinal: number;
kind: LocalDataDirectoryAdoptionSecretRecord['kind'];
sourceNameDigest: string;
secretRef: string;
valueFile: string;
valueDigest: string;
itemDigest: string;
}>[];
readonly publicationDigest: string;
readonly auditEventId: string;
readonly committedAtMs: number;
}
export interface LocalDataDirectoryAdoptionReceipt
extends LocalDataDirectoryAdoptionReceiptPayload {
readonly receiptDigest: string;
}
export interface LocalDataDirectoryAdoptionRecord {
readonly mutationId: string;
readonly projectId: string;
readonly profile: LocalSqliteProfile;
readonly sourceStageManifestDigest: string;
readonly transformationDigest: string;
readonly modelDigest: string;
readonly model: Readonly<LocalDataDirectoryAppliedModel>;
readonly publicationDigest: string;
readonly auditEventId: string;
readonly committedAtMs: number;
readonly receiptDigest: string;
readonly receipt: Readonly<LocalDataDirectoryAdoptionReceipt>;
readonly secrets: readonly Readonly<LocalDataDirectoryAdoptionSecretRecord>[];
}
export interface PublishLocalDataDirectoryAdoptionCommand {
readonly mutationId: string;
readonly projectId: string;
readonly profile: LocalSqliteProfile;
readonly sourceStageManifestDigest: string;
readonly transformationDigest: string;
readonly modelDigest: string;
readonly model: Readonly<LocalDataDirectoryAppliedModel>;
readonly subject: Readonly<SecuritySubject>;
readonly fence: Readonly<SecurityPolicyFence>;
readonly audit: Readonly<SecurityAuditRecord>;
readonly secrets: readonly Readonly<LocalDataDirectoryAdoptionSecretPublication>[];
readonly receipt: Readonly<LocalDataDirectoryAdoptionReceipt>;
readonly confirmExternalAuthority: () => void | Promise<void>;
}
export interface PublishLocalDataDirectoryAdoptionResult {
readonly status: 'inserted' | 'existing';
readonly adoption: Readonly<LocalDataDirectoryAdoptionRecord>;
}
export class LocalDataDirectoryAdoptionConflictError extends Error {
readonly code = 'LOCAL_DATA_DIRECTORY_ADOPTION_CONFLICT';
constructor() {
super('Local data directory adoption conflicts with durable state');
this.name = 'LocalDataDirectoryAdoptionConflictError';
}
}
export class LocalDataDirectoryAdoptionAuthorizationFenceConflictError extends Error {
readonly code = 'LOCAL_DATA_DIRECTORY_ADOPTION_AUTHORIZATION_FENCE_CONFLICT';
constructor() {
super('Local data directory adoption authorization fence changed');
this.name = 'LocalDataDirectoryAdoptionAuthorizationFenceConflictError';
}
}
export class LocalDataDirectoryAdoptionUnavailableError extends Error {
readonly code = 'LOCAL_DATA_DIRECTORY_ADOPTION_UNAVAILABLE';
constructor(readonly cause?: unknown) {
super('Local data directory adoption storage is unavailable');
this.name = 'LocalDataDirectoryAdoptionUnavailableError';
}
}
type Row = Record<string, unknown>;
function sha256(domain: string, value: string): string {
return createHash('sha256')
.update(domain, 'utf8')
.update(value, 'utf8')
.digest('hex');
}
function exactKeys(value: object, expected: readonly string[]): boolean {
const actual = Object.keys(value).sort();
const canonical = [...expected].sort();
return (
actual.length === canonical.length &&
actual.every((key, index) => key === canonical[index])
);
}
function safeInteger(value: unknown, minimum = 0): value is number {
return Number.isSafeInteger(value) && (value as number) >= minimum;
}
function text(row: Row, key: string): string {
const value = row[key];
if (typeof value !== 'string') {
throw new LocalDataDirectoryAdoptionUnavailableError();
}
return value;
}
function integer(row: Row, key: string): number {
const value = row[key];
if (!Number.isSafeInteger(value)) {
throw new LocalDataDirectoryAdoptionUnavailableError();
}
return value as number;
}
function json(row: Row, key: string): unknown {
try {
return JSON.parse(text(row, key));
} catch (error) {
throw new LocalDataDirectoryAdoptionUnavailableError(error);
}
}
function assertModel(
value: unknown,
): asserts value is LocalDataDirectoryAppliedModel {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, [
'activation',
'config',
'keyv',
'manualReview',
'schema',
'ssh',
])
) {
throw new LocalDataDirectoryAdoptionConflictError();
}
const model = value as Record<string, unknown>;
const entries = [model.config, model.keyv, model.ssh, model.manualReview];
if (
model.schema !== 'qinglong/legacy-data-directory-applied-model@v1' ||
model.activation !== 'disabled' ||
entries.some(
(entry) => !entry || typeof entry !== 'object' || Array.isArray(entry),
) ||
(model.config as Row).schema !==
'qinglong/legacy-config-transformation@v1' ||
(model.config as Row).activation !== 'disabled' ||
(model.keyv as Row).schema !== 'qinglong/legacy-keyv-transformation@v1' ||
(model.keyv as Row).activation !== 'disabled' ||
(model.ssh as Row).schema !== 'qinglong/legacy-ssh-transformation@v1' ||
(model.ssh as Row).activation !== 'disabled' ||
(model.manualReview as Row).schema !==
'qinglong/legacy-data-directory-manual-review@v1' ||
(model.manualReview as Row).required !== false ||
(model.manualReview as Row).activation !== 'disabled' ||
Buffer.byteLength(JSON.stringify(value), 'utf8') > 1024 * 1024
) {
throw new LocalDataDirectoryAdoptionConflictError();
}
}
function itemSemantic(
item: Omit<LocalDataDirectoryAdoptionSecretRecord, 'itemDigest'>,
): string {
return JSON.stringify(item);
}
export function createLocalDataDirectorySourceNameDigest(
kind: LocalDataDirectoryAdoptionSecretRecord['kind'],
sourceName: string,
): string {
if (
(kind !== 'environment' && kind !== 'ssh_private_key') ||
typeof sourceName !== 'string' ||
sourceName.length < 1 ||
sourceName.includes('\0')
) {
throw new LocalDataDirectoryAdoptionConflictError();
}
return createHash('sha256')
.update('qinglong3.legacy-data-directory-source-name.v1\0')
.update(kind)
.update('\0')
.update(sourceName)
.digest('hex');
}
export function createLocalDataDirectoryAdoptionSecretItem(options: {
readonly projectId: string;
readonly ordinal: number;
readonly kind: LocalDataDirectoryAdoptionSecretRecord['kind'];
readonly sourceNameDigest: string;
readonly secretName: string;
readonly secretMutationId: string;
readonly valueFile: string;
readonly valueDigest: string;
}): Readonly<LocalDataDirectoryAdoptionSecretRecord> {
return createSecretRecord(options);
}
function createSecretRecord(options: {
readonly projectId: string;
readonly ordinal: number;
readonly kind: LocalDataDirectoryAdoptionSecretRecord['kind'];
readonly sourceNameDigest: string;
readonly secretName: string;
readonly secretMutationId: string;
readonly valueFile: string;
readonly valueDigest: string;
}): Readonly<LocalDataDirectoryAdoptionSecretRecord> {
const secretRef = createLocalSecretRef({
projectId: options.projectId,
name: options.secretName,
version: 1,
});
const semantic = Object.freeze({
ordinal: options.ordinal,
kind: options.kind,
sourceNameDigest: options.sourceNameDigest,
secretName: options.secretName,
secretVersion: 1 as const,
secretMutationId: options.secretMutationId,
valueFile: options.valueFile,
valueDigest: options.valueDigest,
secretRef,
});
return Object.freeze({
...semantic,
itemDigest: sha256(
'qinglong3.legacy-data-directory-adoption-secret-item.v1\0',
itemSemantic(semantic),
),
});
}
function publicationDigest(options: {
readonly mutationId: string;
readonly projectId: string;
readonly sourceStageManifestDigest: string;
readonly transformationDigest: string;
readonly modelDigest: string;
readonly secrets: readonly Readonly<LocalDataDirectoryAdoptionSecretRecord>[];
}): string {
const hash = createHash('sha256')
.update('qinglong3.legacy-data-directory-adoption-publication.v1\0')
.update(options.mutationId)
.update('\0')
.update(options.projectId)
.update('\0')
.update(options.sourceStageManifestDigest)
.update('\0')
.update(options.transformationDigest)
.update('\0')
.update(options.modelDigest);
for (const item of options.secrets) hash.update('\0').update(item.itemDigest);
return hash.digest('hex');
}
export function createLocalDataDirectoryAdoptionReceipt(options: {
readonly mutationId: string;
readonly projectId: string;
readonly profile: LocalSqliteProfile;
readonly sourceStageManifestDigest: string;
readonly transformationDigest: string;
readonly modelDigest: string;
readonly secrets: readonly Readonly<LocalDataDirectoryAdoptionSecretRecord>[];
readonly committedAtMs: number;
}): Readonly<LocalDataDirectoryAdoptionReceipt> {
const environmentSecretCount = options.secrets.filter(
({ kind }) => kind === 'environment',
).length;
const sshSecretCount = options.secrets.length - environmentSecretCount;
const publication = publicationDigest(options);
const payload: LocalDataDirectoryAdoptionReceiptPayload = {
schema: 'qinglong/legacy-data-directory-adoption-receipt@v1',
mutationId: options.mutationId,
projectId: options.projectId,
profile: options.profile,
sourceStageManifestDigest: options.sourceStageManifestDigest,
transformationDigest: options.transformationDigest,
modelDigest: options.modelDigest,
secretCount: options.secrets.length,
environmentSecretCount,
sshSecretCount,
items: Object.freeze(
options.secrets.map(
({
ordinal,
kind,
sourceNameDigest,
secretRef,
valueFile,
valueDigest,
itemDigest,
}) =>
Object.freeze({
ordinal,
kind,
sourceNameDigest,
secretRef,
valueFile,
valueDigest,
itemDigest,
}),
),
),
publicationDigest: publication,
auditEventId: options.mutationId,
committedAtMs: options.committedAtMs,
};
return Object.freeze({
...payload,
receiptDigest: sha256(
'qinglong3.legacy-data-directory-adoption-receipt.v1\0',
JSON.stringify(payload),
),
});
}
function sameJson(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
function parseRecord(
client: DatabaseSync,
row: Row,
): LocalDataDirectoryAdoptionRecord {
const profile = text(row, 'profile');
if (profile !== 'edge' && profile !== 'standalone') {
throw new LocalDataDirectoryAdoptionUnavailableError();
}
const mutationId = text(row, 'mutationId');
const projectId = text(row, 'projectId');
const model = json(row, 'modelJson');
assertModel(model);
const itemRows = client
.prepare(
`SELECT item."ordinal" AS "ordinal", item."kind" AS "kind",
item."source_name_digest" AS "sourceNameDigest",
item."secret_name" AS "secretName",
item."secret_version" AS "secretVersion",
item."secret_mutation_id" AS "secretMutationId",
item."value_file" AS "valueFile",
item."value_digest" AS "valueDigest",
item."secret_ref" AS "secretRef",
item."item_digest" AS "itemDigest"
FROM "QingLong3LegacyDataDirectoryAdoptionSecrets" AS item
JOIN "QingLong3LocalSecretEnvelopes" AS secret
ON secret."project_id" = item."project_id"
AND secret."secret_name" = item."secret_name"
AND secret."version" = item."secret_version"
AND secret."mutation_id" = item."secret_mutation_id"
JOIN "QingLong3SecurityAuditEvents" AS audit
ON audit."event_id" = item."secret_mutation_id"
AND audit."project_id" = item."project_id"
AND audit."operation_id" = 'secret.create'
AND audit."outcome" = 'allowed'
WHERE item."adoption_mutation_id" = ?
ORDER BY item."ordinal"`,
)
.all(mutationId) as Row[];
const secrets = Object.freeze(
itemRows.map((item, index) => {
const kind = text(item, 'kind');
const candidate = createSecretRecord({
projectId,
ordinal: integer(item, 'ordinal'),
kind:
kind === 'environment' || kind === 'ssh_private_key'
? kind
: (() => {
throw new LocalDataDirectoryAdoptionUnavailableError();
})(),
sourceNameDigest: text(item, 'sourceNameDigest'),
secretName: text(item, 'secretName'),
secretMutationId: text(item, 'secretMutationId'),
valueFile: text(item, 'valueFile'),
valueDigest: text(item, 'valueDigest'),
});
if (
candidate.ordinal !== index + 1 ||
candidate.secretVersion !== integer(item, 'secretVersion') ||
candidate.secretRef !== text(item, 'secretRef') ||
candidate.itemDigest !== text(item, 'itemDigest')
) {
throw new LocalDataDirectoryAdoptionUnavailableError();
}
return candidate;
}),
);
const sourceStageManifestDigest = text(row, 'sourceStageManifestDigest');
const transformationDigest = text(row, 'transformationDigest');
const modelDigest = text(row, 'modelDigest');
const committedAtMs = integer(row, 'committedAtMs');
const receipt = createLocalDataDirectoryAdoptionReceipt({
mutationId,
projectId,
profile,
sourceStageManifestDigest,
transformationDigest,
modelDigest,
secrets,
committedAtMs,
});
const storedReceipt = json(row, 'receiptJson');
if (
secrets.length !== integer(row, 'secretCount') ||
receipt.environmentSecretCount !== integer(row, 'environmentSecretCount') ||
receipt.sshSecretCount !== integer(row, 'sshSecretCount') ||
receipt.publicationDigest !== text(row, 'publicationDigest') ||
receipt.auditEventId !== text(row, 'auditEventId') ||
receipt.receiptDigest !== text(row, 'receiptDigest') ||
!sameJson(receipt, storedReceipt)
) {
throw new LocalDataDirectoryAdoptionUnavailableError();
}
return Object.freeze({
mutationId,
projectId,
profile,
sourceStageManifestDigest,
transformationDigest,
modelDigest,
model,
publicationDigest: receipt.publicationDigest,
auditEventId: receipt.auditEventId,
committedAtMs,
receiptDigest: receipt.receiptDigest,
receipt,
secrets,
});
}
const RECORD_SELECT = `
adoption."mutation_id" AS "mutationId",
adoption."project_id" AS "projectId",
adoption."profile" AS "profile",
adoption."source_stage_manifest_digest" AS "sourceStageManifestDigest",
adoption."transformation_digest" AS "transformationDigest",
adoption."model_digest" AS "modelDigest",
adoption."secret_count" AS "secretCount",
adoption."environment_secret_count" AS "environmentSecretCount",
adoption."ssh_secret_count" AS "sshSecretCount",
adoption."model_json" AS "modelJson",
adoption."publication_digest" AS "publicationDigest",
adoption."audit_event_id" AS "auditEventId",
adoption."committed_at_ms" AS "committedAtMs",
adoption."receipt_digest" AS "receiptDigest",
adoption."receipt_json" AS "receiptJson"`;
function findRecord(
client: DatabaseSync,
mutationId: string,
transformationDigest?: string,
): LocalDataDirectoryAdoptionRecord | null {
const rows = transformationDigest
? (client
.prepare(
`SELECT ${RECORD_SELECT}
FROM "QingLong3LegacyDataDirectoryAdoptions" AS adoption
JOIN "QingLong3SecurityAuditEvents" AS audit
ON audit."event_id" = adoption."audit_event_id"
AND audit."project_id" = adoption."project_id"
AND audit."operation_id" = 'legacy-data.apply'
AND audit."outcome" = 'allowed'
WHERE adoption."mutation_id" = ?
OR adoption."transformation_digest" = ?
LIMIT 2`,
)
.all(mutationId, transformationDigest) as Row[])
: (client
.prepare(
`SELECT ${RECORD_SELECT}
FROM "QingLong3LegacyDataDirectoryAdoptions" AS adoption
JOIN "QingLong3SecurityAuditEvents" AS audit
ON audit."event_id" = adoption."audit_event_id"
AND audit."project_id" = adoption."project_id"
AND audit."operation_id" = 'legacy-data.apply'
AND audit."outcome" = 'allowed'
WHERE adoption."mutation_id" = ?
LIMIT 2`,
)
.all(mutationId) as Row[]);
if (rows.length === 0) return null;
if (rows.length !== 1) throw new LocalDataDirectoryAdoptionConflictError();
return parseRecord(client, rows[0]!);
}
function insertAudit(client: DatabaseSync, audit: SecurityAuditRecord): void {
client
.prepare(
`INSERT INTO "QingLong3SecurityAuditEvents" (
"event_id", "request_id", "operation_id", "project_id",
"subject_type", "subject_id", "authentication_id", "outcome",
"reasons_json", "fence_project_version", "fence_binding_version",
"occurred_at_ms"
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
audit.eventId,
audit.requestId,
audit.operationId,
audit.projectId,
audit.subject?.type ?? null,
audit.subject?.id ?? null,
audit.authenticationId,
audit.outcome,
JSON.stringify(audit.reasons),
audit.fence?.projectVersion ?? null,
audit.fence?.bindingVersion ?? null,
audit.occurredAtMs,
);
}
function assertCommand(command: PublishLocalDataDirectoryAdoptionCommand): {
readonly subject: Readonly<SecuritySubject>;
readonly audit: Readonly<SecurityAuditRecord>;
readonly secrets: readonly Readonly<{
publication: LocalDataDirectoryAdoptionSecretPublication;
record: LocalDataDirectoryAdoptionSecretRecord;
envelope: LocalSecretEnvelope;
audit: SecurityAuditRecord;
}>[];
} {
if (
!command ||
typeof command !== 'object' ||
!UUID_V4_PATTERN.test(command.mutationId) ||
(command.profile !== 'edge' && command.profile !== 'standalone') ||
![
command.sourceStageManifestDigest,
command.transformationDigest,
command.modelDigest,
].every((value) => DIGEST_PATTERN.test(value)) ||
!Array.isArray(command.secrets) ||
command.secrets.length > (command.profile === 'edge' ? 128 : 512) ||
typeof command.confirmExternalAuthority !== 'function'
) {
throw new LocalDataDirectoryAdoptionConflictError();
}
assertModel(command.model);
const subject = normalizeProjectPolicySubject(command.subject);
const audit = normalizeSecurityAuditRecord(command.audit);
if (
!command.fence ||
!safeInteger(command.fence.projectVersion, 1) ||
!safeInteger(command.fence.bindingVersion, 1) ||
audit.eventId !== command.mutationId ||
audit.operationId !== 'legacy-data.apply' ||
audit.projectId !== command.projectId ||
audit.subject?.type !== subject.type ||
audit.subject.id !== subject.id ||
audit.outcome !== 'allowed' ||
audit.fence?.projectVersion !== command.fence.projectVersion ||
audit.fence.bindingVersion !== command.fence.bindingVersion ||
audit.occurredAtMs !== command.receipt.committedAtMs
) {
throw new LocalDataDirectoryAdoptionConflictError();
}
const names = new Set<string>();
const mutations = new Set<string>([command.mutationId]);
const secrets = command.secrets.map((publication, index) => {
const envelope = normalizeLocalSecretEnvelope(publication.envelope);
const itemAudit = normalizeSecurityAuditRecord(publication.audit);
const record = createSecretRecord({
projectId: command.projectId,
ordinal: publication.ordinal,
kind: publication.kind,
sourceNameDigest: publication.sourceNameDigest,
secretName: envelope.name,
secretMutationId: envelope.mutationId,
valueFile: publication.valueFile,
valueDigest: publication.valueDigest,
});
if (
publication.ordinal !== index + 1 ||
envelope.projectId !== command.projectId ||
envelope.version !== 1 ||
!UUID_V4_PATTERN.test(envelope.mutationId) ||
names.has(envelope.name) ||
mutations.has(envelope.mutationId) ||
!DIGEST_PATTERN.test(publication.sourceNameDigest) ||
!DIGEST_PATTERN.test(publication.valueDigest) ||
!VALUE_FILE_PATTERN.test(publication.valueFile) ||
publication.secretRef !== record.secretRef ||
publication.itemDigest !== record.itemDigest ||
itemAudit.eventId !== envelope.mutationId ||
itemAudit.operationId !== 'secret.create' ||
itemAudit.projectId !== command.projectId ||
itemAudit.subject?.type !== subject.type ||
itemAudit.subject.id !== subject.id ||
itemAudit.outcome !== 'allowed' ||
itemAudit.fence?.projectVersion !== command.fence.projectVersion ||
itemAudit.fence.bindingVersion !== command.fence.bindingVersion ||
itemAudit.occurredAtMs !== command.receipt.committedAtMs
) {
throw new LocalDataDirectoryAdoptionConflictError();
}
names.add(envelope.name);
mutations.add(envelope.mutationId);
return Object.freeze({ publication, record, envelope, audit: itemAudit });
});
const expectedReceipt = createLocalDataDirectoryAdoptionReceipt({
mutationId: command.mutationId,
projectId: command.projectId,
profile: command.profile,
sourceStageManifestDigest: command.sourceStageManifestDigest,
transformationDigest: command.transformationDigest,
modelDigest: command.modelDigest,
secrets: secrets.map(({ record }) => record),
committedAtMs: command.receipt.committedAtMs,
});
if (!sameJson(expectedReceipt, command.receipt)) {
throw new LocalDataDirectoryAdoptionConflictError();
}
return Object.freeze({ subject, audit, secrets: Object.freeze(secrets) });
}
function exactReplay(
existing: Readonly<LocalDataDirectoryAdoptionRecord>,
command: Readonly<PublishLocalDataDirectoryAdoptionCommand>,
): boolean {
return (
existing.mutationId === command.mutationId &&
existing.projectId === command.projectId &&
existing.profile === command.profile &&
existing.sourceStageManifestDigest === command.sourceStageManifestDigest &&
existing.transformationDigest === command.transformationDigest &&
existing.modelDigest === command.modelDigest &&
sameJson(existing.model, command.model) &&
sameJson(existing.receipt, command.receipt)
);
}
export class LocalSqliteDataDirectoryAdoptionPublisher {
constructor(private readonly authority: LocalSqliteOperationAuthority) {}
resolve(
mutationId: string,
): Promise<Readonly<LocalDataDirectoryAdoptionRecord> | null> {
if (!UUID_V4_PATTERN.test(mutationId)) {
return Promise.reject(new LocalDataDirectoryAdoptionConflictError());
}
return this.authority.enqueue(
async () => findRecord(this.authority.client, mutationId),
() => new LocalDataDirectoryAdoptionUnavailableError(),
);
}
publish(
command: Readonly<PublishLocalDataDirectoryAdoptionCommand>,
): Promise<PublishLocalDataDirectoryAdoptionResult> {
const normalized = assertCommand(command);
return this.authority.enqueue(
async () => {
const client = this.authority.client;
let began = false;
try {
client.exec('BEGIN IMMEDIATE');
began = true;
const replay = findRecord(
client,
command.mutationId,
command.transformationDigest,
);
if (replay) {
if (!exactReplay(replay, command)) {
throw new LocalDataDirectoryAdoptionConflictError();
}
await command.confirmExternalAuthority();
client.exec('COMMIT');
began = false;
return Object.freeze({
status: 'existing' as const,
adoption: replay,
});
}
const project = client
.prepare(
`SELECT "version", "status" FROM "QingLong3Projects"
WHERE "id" = ? LIMIT 1`,
)
.get(command.projectId) as Row | undefined;
if (
!project ||
integer(project, 'version') !== command.fence.projectVersion ||
text(project, 'status') !== 'active'
) {
throw new LocalDataDirectoryAdoptionAuthorizationFenceConflictError();
}
const binding = client
.prepare(
`SELECT "version", "state", "role"
FROM "QingLong3ProjectRoleBindings"
WHERE "project_id" = ? AND "subject_type" = ? AND "subject_id" = ?
ORDER BY "version" DESC LIMIT 1`,
)
.get(
command.projectId,
normalized.subject.type,
normalized.subject.id,
) as Row | undefined;
if (
!binding ||
integer(binding, 'version') !== command.fence.bindingVersion ||
text(binding, 'state') !== 'active' ||
!['owner', 'admin'].includes(text(binding, 'role'))
) {
throw new LocalDataDirectoryAdoptionAuthorizationFenceConflictError();
}
for (const { envelope, audit } of normalized.secrets) {
const current = client
.prepare(
`SELECT MAX("version") AS "version"
FROM "QingLong3LocalSecretEnvelopes"
WHERE "project_id" = ? AND "secret_name" = ?`,
)
.get(envelope.projectId, envelope.name) as Row;
if (current.version !== null) {
throw new LocalDataDirectoryAdoptionConflictError();
}
const nonce = Buffer.from(envelope.nonce, 'base64url');
const ciphertext = Buffer.from(envelope.ciphertext, 'base64url');
const authTag = Buffer.from(envelope.authTag, 'base64url');
try {
client
.prepare(
`INSERT INTO "QingLong3LocalSecretEnvelopes" (
"project_id", "secret_name", "version", "mutation_id",
"key_id", "algorithm", "nonce", "ciphertext", "auth_tag",
"created_at_ms"
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
envelope.projectId,
envelope.name,
envelope.version,
envelope.mutationId,
envelope.keyId,
envelope.algorithm,
nonce,
ciphertext,
authTag,
envelope.createdAtMs,
);
} finally {
nonce.fill(0);
ciphertext.fill(0);
authTag.fill(0);
}
insertAudit(client, audit);
}
insertAudit(client, normalized.audit);
client
.prepare(
`INSERT INTO "QingLong3LegacyDataDirectoryAdoptions" (
"mutation_id", "project_id", "profile",
"source_stage_manifest_digest", "transformation_digest",
"model_digest", "secret_count", "environment_secret_count",
"ssh_secret_count", "model_json", "publication_digest",
"audit_event_id", "committed_at_ms", "receipt_digest",
"receipt_json"
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
command.mutationId,
command.projectId,
command.profile,
command.sourceStageManifestDigest,
command.transformationDigest,
command.modelDigest,
command.receipt.secretCount,
command.receipt.environmentSecretCount,
command.receipt.sshSecretCount,
JSON.stringify(command.model),
command.receipt.publicationDigest,
command.mutationId,
command.receipt.committedAtMs,
command.receipt.receiptDigest,
JSON.stringify(command.receipt),
);
for (const { record } of normalized.secrets) {
client
.prepare(
`INSERT INTO "QingLong3LegacyDataDirectoryAdoptionSecrets" (
"adoption_mutation_id", "ordinal", "project_id", "kind",
"source_name_digest", "secret_name", "secret_version",
"secret_mutation_id", "value_file", "value_digest",
"secret_ref", "item_digest"
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
command.mutationId,
record.ordinal,
command.projectId,
record.kind,
record.sourceNameDigest,
record.secretName,
record.secretVersion,
record.secretMutationId,
record.valueFile,
record.valueDigest,
record.secretRef,
record.itemDigest,
);
}
const stored = findRecord(client, command.mutationId);
if (!stored || !exactReplay(stored, command)) {
throw new LocalDataDirectoryAdoptionUnavailableError();
}
await command.confirmExternalAuthority();
client.exec('COMMIT');
began = false;
return Object.freeze({
status: 'inserted' as const,
adoption: stored,
});
} catch (error) {
if (began && client.isTransaction) {
try {
client.exec('ROLLBACK');
} catch {
// Preserve the original failure.
}
}
if (
error instanceof LocalDataDirectoryAdoptionConflictError ||
error instanceof
LocalDataDirectoryAdoptionAuthorizationFenceConflictError ||
error instanceof LocalDataDirectoryAdoptionUnavailableError
) {
throw error;
}
if (
error &&
typeof error === 'object' &&
'code' in error &&
typeof error.code === 'string' &&
error.code.startsWith('SQLITE_CONSTRAINT')
) {
throw new LocalDataDirectoryAdoptionConflictError();
}
throw new LocalDataDirectoryAdoptionUnavailableError(error);
}
},
() => new LocalDataDirectoryAdoptionUnavailableError(),
);
}
}
export interface LocalSqliteDataDirectoryAdoptionDatabase {
readonly profile: LocalSqliteProfile;
readonly readiness: LocalSqliteReadinessEvidence;
readonly projectPolicy: ProjectPolicyRepository;
readonly securityAudit: SecurityAuditSink;
readonly publisher: LocalSqliteDataDirectoryAdoptionPublisher;
close(): Promise<void>;
}
/** Short-lived data-directory adoption authority; runtime hosts must not import it. */
export async function openLocalSqliteDataDirectoryAdoptionDatabase(
options: LocalSqliteDatabaseOptions,
): Promise<LocalSqliteDataDirectoryAdoptionDatabase> {
assertLocalSqliteOptions(options);
assertLocalSqlitePathBoundary(options.databasePath, false);
const client = openLocalSqliteClient(options, false);
try {
const readiness = await auditLocalSqliteReadiness(client);
const authority = new LocalSqliteOperationAuthority(client);
const securityAuthority = new LocalSqliteSecurityAuthorityStore(authority);
const projectPolicy: ProjectPolicyRepository = Object.freeze({
resolve: (
...[projectId, subject]: Parameters<ProjectPolicyRepository['resolve']>
) => securityAuthority.resolve(projectId, subject),
append: (...[command]: Parameters<ProjectPolicyRepository['append']>) =>
securityAuthority.append(command),
});
let closePromise: Promise<void> | undefined;
return Object.freeze({
profile: options.profile,
readiness,
projectPolicy,
securityAudit: securityAuthority,
publisher: new LocalSqliteDataDirectoryAdoptionPublisher(authority),
close() {
if (closePromise) return closePromise;
closePromise = authority.close();
return closePromise;
},
});
} catch (error) {
if (client.isOpen) client.close();
throw error;
}
}
@@ -0,0 +1,17 @@
export const LOCAL_DATA_DIRECTORY_ADOPTION_SECRET_GUARD_TRIGGER_SQL = `
CREATE TRIGGER "ql3_legacy_data_directory_adoption_secret_guard"
BEFORE INSERT ON "QingLong3LegacyDataDirectoryAdoptionSecrets"
BEGIN
SELECT CASE WHEN NOT EXISTS (
SELECT 1
FROM "QingLong3LegacyDataDirectoryAdoptions" AS adoption
JOIN "QingLong3LocalSecretEnvelopes" AS secret
ON secret."project_id" = NEW."project_id"
AND secret."secret_name" = NEW."secret_name"
AND secret."version" = NEW."secret_version"
WHERE adoption."mutation_id" = NEW."adoption_mutation_id"
AND adoption."project_id" = NEW."project_id"
AND secret."mutation_id" = NEW."secret_mutation_id"
) THEN RAISE(ABORT, 'legacy data directory adoption Secret authority mismatch') END;
END
`.trim();
@@ -108,6 +108,8 @@ import { local0095PluginPackageSecretBindingTargetGuardMigration } from '../migr
import { local0096CapabilityV48Migration } from '../migrations/0096-capability-v48';
import { local0097PluginPackageSecretBindingTransitionReceiptsMigration } from '../migrations/0097-plugin-package-secret-binding-transition-receipts';
import { local0098CapabilityV49Migration } from '../migrations/0098-capability-v49';
import { local0099LegacyDataDirectoryAdoptionsMigration } from '../migrations/0099-legacy-data-directory-adoptions';
import { local0100CapabilityV50Migration } from '../migrations/0100-capability-v50';
import type { LocalSqliteMigrationContext } from '../migrations/sqlMigration';
import {
LOCAL_SQLITE_MIGRATION_STREAM_ID,
@@ -228,6 +230,8 @@ export const localSqliteMigrationDefinition: MigrationStreamDefinition<LocalSqli
local0096CapabilityV48Migration,
local0097PluginPackageSecretBindingTransitionReceiptsMigration,
local0098CapabilityV49Migration,
local0099LegacyDataDirectoryAdoptionsMigration,
local0100CapabilityV50Migration,
]),
});
@@ -502,5 +502,15 @@ export const localSqliteMigrationManifest: MigrationStreamManifest =
checksum:
'133bdb78900256971bb6e13de8a129024d09f9d4c9d9290137eca3ff6e8b30eb',
}),
Object.freeze({
id: '0099-legacy-data-directory-adoptions',
checksum:
'5a9edabd6a3e13cd8d71be6e85e3f211269b1909295aea27573c567c15112fd2',
}),
Object.freeze({
id: '0100-capability-v50',
checksum:
'ea4ef39fe237d8db032da89c8f79dfda631b7201d1e5ac8c743b67e75aad5b07',
}),
]),
});
@@ -0,0 +1,179 @@
import { defineLocalSqliteMigration } from './sqlMigration';
import { LOCAL_DATA_DIRECTORY_ADOPTION_SECRET_GUARD_TRIGGER_SQL } from '../adoption/data-directory/dataDirectoryAdoptionSchemaContract';
export const local0099LegacyDataDirectoryAdoptionsMigration =
defineLocalSqliteMigration({
id: '0099-legacy-data-directory-adoptions',
statements: [
`
CREATE TABLE "QingLong3LegacyDataDirectoryAdoptions" (
"mutation_id" TEXT PRIMARY KEY NOT NULL,
"project_id" TEXT NOT NULL,
"profile" TEXT NOT NULL,
"source_stage_manifest_digest" TEXT NOT NULL,
"transformation_digest" TEXT NOT NULL,
"model_digest" TEXT NOT NULL,
"secret_count" INTEGER NOT NULL,
"environment_secret_count" INTEGER NOT NULL,
"ssh_secret_count" INTEGER NOT NULL,
"model_json" TEXT NOT NULL,
"publication_digest" TEXT NOT NULL,
"audit_event_id" TEXT NOT NULL,
"committed_at_ms" INTEGER NOT NULL,
"receipt_digest" TEXT NOT NULL,
"receipt_json" TEXT NOT NULL,
CONSTRAINT ql3_legacy_data_directory_adoption_project_fk
FOREIGN KEY ("project_id")
REFERENCES "QingLong3Projects" ("id")
ON DELETE RESTRICT ON UPDATE RESTRICT,
CONSTRAINT ql3_legacy_data_directory_adoption_audit_fk
FOREIGN KEY ("audit_event_id")
REFERENCES "QingLong3SecurityAuditEvents" ("event_id")
ON DELETE RESTRICT ON UPDATE RESTRICT,
CONSTRAINT ql3_legacy_data_directory_adoption_identity_check CHECK (
length("mutation_id") = 36 AND
substr("mutation_id", 15, 1) = '4' AND
replace("mutation_id", '-', '') NOT GLOB '*[^0-9a-f]*' AND
"audit_event_id" = "mutation_id" AND
length("project_id") BETWEEN 1 AND 128
),
CONSTRAINT ql3_legacy_data_directory_adoption_profile_check CHECK (
"profile" IN ('edge', 'standalone')
),
CONSTRAINT ql3_legacy_data_directory_adoption_digest_check CHECK (
length("source_stage_manifest_digest") = 64 AND
"source_stage_manifest_digest" NOT GLOB '*[^0-9a-f]*' AND
length("transformation_digest") = 64 AND
"transformation_digest" NOT GLOB '*[^0-9a-f]*' AND
length("model_digest") = 64 AND
"model_digest" NOT GLOB '*[^0-9a-f]*' AND
length("publication_digest") = 64 AND
"publication_digest" NOT GLOB '*[^0-9a-f]*' AND
length("receipt_digest") = 64 AND
"receipt_digest" NOT GLOB '*[^0-9a-f]*'
),
CONSTRAINT ql3_legacy_data_directory_adoption_count_check CHECK (
"secret_count" BETWEEN 0 AND CASE "profile" WHEN 'edge' THEN 128 ELSE 512 END AND
"environment_secret_count" BETWEEN 0 AND "secret_count" AND
"ssh_secret_count" BETWEEN 0 AND "secret_count" AND
"environment_secret_count" + "ssh_secret_count" = "secret_count"
),
CONSTRAINT ql3_legacy_data_directory_adoption_model_check CHECK (
length(CAST("model_json" AS BLOB)) BETWEEN 2 AND 1048576 AND
json_valid("model_json") AND json_type("model_json") = 'object' AND
json_extract("model_json", '$.schema') = 'qinglong/legacy-data-directory-applied-model@v1' AND
json_extract("model_json", '$.activation') = 'disabled' AND
json_extract("model_json", '$.config.schema') = 'qinglong/legacy-config-transformation@v1' AND
json_extract("model_json", '$.config.activation') = 'disabled' AND
json_extract("model_json", '$.keyv.schema') = 'qinglong/legacy-keyv-transformation@v1' AND
json_extract("model_json", '$.keyv.activation') = 'disabled' AND
json_extract("model_json", '$.ssh.schema') = 'qinglong/legacy-ssh-transformation@v1' AND
json_extract("model_json", '$.ssh.activation') = 'disabled' AND
json_extract("model_json", '$.manualReview.schema') = 'qinglong/legacy-data-directory-manual-review@v1' AND
json_extract("model_json", '$.manualReview.required') = 0 AND
json_extract("model_json", '$.manualReview.activation') = 'disabled'
),
CONSTRAINT ql3_legacy_data_directory_adoption_receipt_check CHECK (
length(CAST("receipt_json" AS BLOB)) BETWEEN 2 AND 1048576 AND
json_valid("receipt_json") AND json_type("receipt_json") = 'object' AND
json_extract("receipt_json", '$.schema') = 'qinglong/legacy-data-directory-adoption-receipt@v1' AND
json_extract("receipt_json", '$.mutationId') = "mutation_id" AND
json_extract("receipt_json", '$.projectId') = "project_id" AND
json_extract("receipt_json", '$.profile') = "profile" AND
json_extract("receipt_json", '$.sourceStageManifestDigest') = "source_stage_manifest_digest" AND
json_extract("receipt_json", '$.transformationDigest') = "transformation_digest" AND
json_extract("receipt_json", '$.modelDigest') = "model_digest" AND
json_extract("receipt_json", '$.secretCount') = "secret_count" AND
json_extract("receipt_json", '$.environmentSecretCount') = "environment_secret_count" AND
json_extract("receipt_json", '$.sshSecretCount') = "ssh_secret_count" AND
json_extract("receipt_json", '$.publicationDigest') = "publication_digest" AND
json_extract("receipt_json", '$.auditEventId') = "audit_event_id" AND
json_extract("receipt_json", '$.committedAtMs') = "committed_at_ms" AND
json_extract("receipt_json", '$.receiptDigest') = "receipt_digest"
),
CONSTRAINT ql3_legacy_data_directory_adoption_time_check CHECK (
"committed_at_ms" >= 0
)
)
`,
`
CREATE UNIQUE INDEX "ql3_legacy_data_directory_adoption_transformation_uidx"
ON "QingLong3LegacyDataDirectoryAdoptions" ("transformation_digest")
`,
`
CREATE UNIQUE INDEX "ql3_legacy_data_directory_adoption_receipt_uidx"
ON "QingLong3LegacyDataDirectoryAdoptions" ("receipt_digest")
`,
`
CREATE INDEX "ql3_legacy_data_directory_adoption_project_time_idx"
ON "QingLong3LegacyDataDirectoryAdoptions" (
"project_id", "committed_at_ms" DESC, "mutation_id" DESC
)
`,
`
CREATE TABLE "QingLong3LegacyDataDirectoryAdoptionSecrets" (
"adoption_mutation_id" TEXT NOT NULL,
"ordinal" INTEGER NOT NULL,
"project_id" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"source_name_digest" TEXT NOT NULL,
"secret_name" TEXT NOT NULL,
"secret_version" INTEGER NOT NULL,
"secret_mutation_id" TEXT NOT NULL,
"value_file" TEXT NOT NULL,
"value_digest" TEXT NOT NULL,
"secret_ref" TEXT NOT NULL,
"item_digest" TEXT NOT NULL,
PRIMARY KEY ("adoption_mutation_id", "ordinal"),
CONSTRAINT ql3_legacy_data_directory_adoption_secret_parent_fk
FOREIGN KEY ("adoption_mutation_id")
REFERENCES "QingLong3LegacyDataDirectoryAdoptions" ("mutation_id")
ON DELETE RESTRICT ON UPDATE RESTRICT,
CONSTRAINT ql3_legacy_data_directory_adoption_secret_envelope_fk
FOREIGN KEY ("project_id", "secret_name", "secret_version")
REFERENCES "QingLong3LocalSecretEnvelopes" ("project_id", "secret_name", "version")
ON DELETE RESTRICT ON UPDATE RESTRICT,
CONSTRAINT ql3_legacy_data_directory_adoption_secret_audit_fk
FOREIGN KEY ("secret_mutation_id")
REFERENCES "QingLong3SecurityAuditEvents" ("event_id")
ON DELETE RESTRICT ON UPDATE RESTRICT,
CONSTRAINT ql3_legacy_data_directory_adoption_secret_identity_check CHECK (
"ordinal" BETWEEN 1 AND 512 AND
length("project_id") BETWEEN 1 AND 128 AND
"kind" IN ('environment', 'ssh_private_key') AND
length("secret_name") BETWEEN 1 AND 128 AND
"secret_version" = 1 AND
length("secret_mutation_id") = 36 AND
substr("secret_mutation_id", 15, 1) = '4' AND
replace("secret_mutation_id", '-', '') NOT GLOB '*[^0-9a-f]*' AND
length("value_file") = 83 AND
"value_file" GLOB 'secret-values/[0-9a-f]*.json' AND
length("secret_ref") BETWEEN 1 AND 512
),
CONSTRAINT ql3_legacy_data_directory_adoption_secret_digest_check CHECK (
length("source_name_digest") = 64 AND
"source_name_digest" NOT GLOB '*[^0-9a-f]*' AND
length("value_digest") = 64 AND
"value_digest" NOT GLOB '*[^0-9a-f]*' AND
length("item_digest") = 64 AND
"item_digest" NOT GLOB '*[^0-9a-f]*'
)
)
`,
`
CREATE UNIQUE INDEX "ql3_legacy_data_directory_adoption_secret_name_uidx"
ON "QingLong3LegacyDataDirectoryAdoptionSecrets" (
"adoption_mutation_id", "secret_name"
)
`,
`
CREATE UNIQUE INDEX "ql3_legacy_data_directory_adoption_secret_mutation_uidx"
ON "QingLong3LegacyDataDirectoryAdoptionSecrets" ("secret_mutation_id")
`,
`
CREATE UNIQUE INDEX "ql3_legacy_data_directory_adoption_secret_item_uidx"
ON "QingLong3LegacyDataDirectoryAdoptionSecrets" ("item_digest")
`,
LOCAL_DATA_DIRECTORY_ADOPTION_SECRET_GUARD_TRIGGER_SQL,
],
});
@@ -0,0 +1,14 @@
import { CAPABILITIES_V49 } from './0098-capability-v49';
import { defineLocalSqliteMigration } from './sqlMigration';
export const CAPABILITIES_V50 = CAPABILITIES_V49.replace(
'"legacy_adoption_ledger":1,',
'"legacy_adoption_ledger":1,"legacy_data_directory_adoption":1,',
);
export const local0100CapabilityV50Migration = defineLocalSqliteMigration({
id: '0100-capability-v50',
statements: [
`UPDATE "QingLong3SchemaCapabilities" SET contract_version = 50, migration_id = '0099-legacy-data-directory-adoptions', capabilities = '${CAPABILITIES_V50}', updated_at_ms = CAST(unixepoch('subsec') * 1000 AS INTEGER) WHERE contract_name = 'local-control-core' AND contract_version = 49 AND migration_id = '0097-plugin-package-secret-binding-transition-receipts' AND capabilities = '${CAPABILITIES_V49}'`,
],
});
@@ -2,6 +2,7 @@ import { auditMigrationStreamHistory } from '@qinglong/runtime-core/migration-st
import type { DatabaseSync } from 'node:sqlite';
import { localSqliteMigrationManifest } from '../migration/migrationManifest';
import { LocalSqliteMigrationStreamStore } from '../migration/migrationStreamStore';
import { LOCAL_DATA_DIRECTORY_ADOPTION_SECRET_GUARD_TRIGGER_SQL } from '../adoption/data-directory/dataDirectoryAdoptionSchemaContract';
import { LOCAL_PLUGIN_PACKAGE_SECRET_MATERIALIZATION_TRIGGER_SQL } from '../plugin-package/pluginPackageSecretMaterializationSchemaContract';
import { LOCAL_PLUGIN_PACKAGE_SECRET_BINDING_TARGET_TRIGGER_SQL } from '../plugin-package/secret-binding/pluginPackageSecretBindingTargetSchemaContract';
import { LOCAL_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_TRIGGER_SQL } from '../plugin-package/secret-binding/transitionReceiptSchemaContract';
@@ -11,7 +12,15 @@ import {
} from '../run/stepRunSchemaContract';
export const LOCAL_SQLITE_CONTRACT_NAME = 'local-control-core';
export const LOCAL_SQLITE_CONTRACT_VERSION = 49;
export const LOCAL_SQLITE_CONTRACT_VERSION = 50;
const LEGACY_DATA_DIRECTORY_ADOPTION_TRIGGERS = Object.freeze([
Object.freeze({
name: 'ql3_legacy_data_directory_adoption_secret_guard',
tableName: 'QingLong3LegacyDataDirectoryAdoptionSecrets',
sql: LOCAL_DATA_DIRECTORY_ADOPTION_SECRET_GUARD_TRIGGER_SQL,
}),
]);
const PLUGIN_PACKAGE_SECRET_BINDING_TARGET_TRIGGERS = Object.freeze([
Object.freeze({
@@ -1389,6 +1398,51 @@ const REQUIRED_SCHEMA = Object.freeze({
'ql3_legacy_adoptions_project_time_idx',
]),
}),
QingLong3LegacyDataDirectoryAdoptions: Object.freeze({
columns: Object.freeze([
'mutation_id',
'project_id',
'profile',
'source_stage_manifest_digest',
'transformation_digest',
'model_digest',
'secret_count',
'environment_secret_count',
'ssh_secret_count',
'model_json',
'publication_digest',
'audit_event_id',
'committed_at_ms',
'receipt_digest',
'receipt_json',
]),
indexes: Object.freeze([
'ql3_legacy_data_directory_adoption_transformation_uidx',
'ql3_legacy_data_directory_adoption_receipt_uidx',
'ql3_legacy_data_directory_adoption_project_time_idx',
]),
}),
QingLong3LegacyDataDirectoryAdoptionSecrets: Object.freeze({
columns: Object.freeze([
'adoption_mutation_id',
'ordinal',
'project_id',
'kind',
'source_name_digest',
'secret_name',
'secret_version',
'secret_mutation_id',
'value_file',
'value_digest',
'secret_ref',
'item_digest',
]),
indexes: Object.freeze([
'ql3_legacy_data_directory_adoption_secret_name_uidx',
'ql3_legacy_data_directory_adoption_secret_mutation_uidx',
'ql3_legacy_data_directory_adoption_secret_item_uidx',
]),
}),
QingLong3IdentitySubjects: Object.freeze({
columns: Object.freeze([
'subject_type',
@@ -1793,6 +1847,7 @@ function assertRequiredSchema(client: DatabaseSync): number {
...PLUGIN_PACKAGE_AUTOMATION_DISPOSITION_TRIGGERS,
...PLUGIN_PACKAGE_SECRET_BINDING_TARGET_TRIGGERS,
...PLUGIN_PACKAGE_SECRET_MATERIALIZATION_TRIGGERS,
...LEGACY_DATA_DIRECTORY_ADOPTION_TRIGGERS,
].sort((left, right) => left.name.localeCompare(right.name));
if (
triggerRows.length !== expectedTriggers.length ||
@@ -2603,11 +2658,10 @@ export async function auditLocalSqliteReadiness(
!capability ||
capability.contract_name !== LOCAL_SQLITE_CONTRACT_NAME ||
capability.contract_version !== LOCAL_SQLITE_CONTRACT_VERSION ||
capability.migration_id !==
'0097-plugin-package-secret-binding-transition-receipts' ||
capability.migration_id !== '0099-legacy-data-directory-adoptions' ||
typeof capability.capabilities !== 'string' ||
capability.capabilities !==
'{"run_core":1,"run_retry_policy":1,"completion_receipt_journal":1,"local_dispatch_plan":1,"local_secret_envelope":1,"local_project_policy":1,"local_project_administration":1,"local_security_audit":1,"local_security_audit_compaction":1,"local_secret_authorized_mutation":1,"local_identity":1,"local_api_credential":1,"local_identity_provisioning":1,"local_identity_credential_administration":1,"local_owner_bootstrap":1,"local_owner_delivery_acknowledgement":1,"api_credential_pepper_binding":1,"local_owner_pepper_catalog":1,"local_owner_credential_recovery":1,"local_owner_pepper_reference_inspection":1,"local_owner_pepper_material_gc":1,"local_owner_delivery_acknowledgement_gc":1,"task_definition":1,"local_execution_revision_digest":1,"trigger_definition":1,"legacy_adoption_ledger":1,"local_scheduler_admission":1,"plugin_package_install":1,"approved_action":1,"plugin_package_admission":1,"approved_action_execution":1,"plugin_package_proposal":1,"plugin_package_materialized_revision":1,"plugin_package_secret_binding":1,"plugin_package_secret_binding_transition":1,"plugin_package_secret_binding_transition_receipt":1,"plugin_package_secret_materialization":1,"plugin_package_task_reconciliation":1,"project_tool_definition_snapshot":1,"step_run":1,"tool_execution_evidence":1,"tool_execution_start_barrier":1,"tool_invocation_artifact":1,"tool_execution_artifact_binding":1,"tool_execution_completion":1,"tool_execution_failure_completion":1,"tool_result_key_catalog":1,"tool_result_rekey":1,"plugin_package_quarantine":1,"plugin_package_lifecycle":1,"plugin_package_automation_publication":1,"plugin_package_automation_security_withdrawal":1,"plugin_package_workflow_admission":1,"plugin_package_workflow_run_list":1,"run_attempt_log_retention":1,"plugin_package_workflow_task_attempt_admission":1}' ||
'{"run_core":1,"run_retry_policy":1,"completion_receipt_journal":1,"local_dispatch_plan":1,"local_secret_envelope":1,"local_project_policy":1,"local_project_administration":1,"local_security_audit":1,"local_security_audit_compaction":1,"local_secret_authorized_mutation":1,"local_identity":1,"local_api_credential":1,"local_identity_provisioning":1,"local_identity_credential_administration":1,"local_owner_bootstrap":1,"local_owner_delivery_acknowledgement":1,"api_credential_pepper_binding":1,"local_owner_pepper_catalog":1,"local_owner_credential_recovery":1,"local_owner_pepper_reference_inspection":1,"local_owner_pepper_material_gc":1,"local_owner_delivery_acknowledgement_gc":1,"task_definition":1,"local_execution_revision_digest":1,"trigger_definition":1,"legacy_adoption_ledger":1,"legacy_data_directory_adoption":1,"local_scheduler_admission":1,"plugin_package_install":1,"approved_action":1,"plugin_package_admission":1,"approved_action_execution":1,"plugin_package_proposal":1,"plugin_package_materialized_revision":1,"plugin_package_secret_binding":1,"plugin_package_secret_binding_transition":1,"plugin_package_secret_binding_transition_receipt":1,"plugin_package_secret_materialization":1,"plugin_package_task_reconciliation":1,"project_tool_definition_snapshot":1,"step_run":1,"tool_execution_evidence":1,"tool_execution_start_barrier":1,"tool_invocation_artifact":1,"tool_execution_artifact_binding":1,"tool_execution_completion":1,"tool_execution_failure_completion":1,"tool_result_key_catalog":1,"tool_result_rekey":1,"plugin_package_quarantine":1,"plugin_package_lifecycle":1,"plugin_package_automation_publication":1,"plugin_package_automation_security_withdrawal":1,"plugin_package_workflow_admission":1,"plugin_package_workflow_run_list":1,"run_attempt_log_retention":1,"plugin_package_workflow_task_attempt_admission":1}' ||
typeof capability.updated_at_ms !== 'number' ||
!Number.isSafeInteger(capability.updated_at_ms) ||
capability.updated_at_ms < 0
@@ -3328,6 +3328,144 @@ export const legacyAdoptions = sqliteTable(
],
);
export const legacyDataDirectoryAdoptions = sqliteTable(
'QingLong3LegacyDataDirectoryAdoptions',
{
mutationId: text('mutation_id').primaryKey(),
projectId: text('project_id')
.notNull()
.references(() => localProjects.id, {
onDelete: 'restrict',
onUpdate: 'restrict',
}),
profile: text('profile').notNull(),
sourceStageManifestDigest: text('source_stage_manifest_digest').notNull(),
transformationDigest: text('transformation_digest').notNull(),
modelDigest: text('model_digest').notNull(),
secretCount: integer('secret_count').notNull(),
environmentSecretCount: integer('environment_secret_count').notNull(),
sshSecretCount: integer('ssh_secret_count').notNull(),
modelJson: text('model_json', { mode: 'json' })
.$type<Record<string, unknown>>()
.notNull(),
publicationDigest: text('publication_digest').notNull(),
auditEventId: text('audit_event_id')
.notNull()
.references(() => localSecurityAuditEvents.eventId, {
onDelete: 'restrict',
onUpdate: 'restrict',
}),
committedAtMs: integer('committed_at_ms').notNull(),
receiptDigest: text('receipt_digest').notNull(),
receiptJson: text('receipt_json', { mode: 'json' })
.$type<Record<string, unknown>>()
.notNull(),
},
(table) => [
check(
'ql3_legacy_data_directory_adoption_identity_check',
sql`length(${table.mutationId}) = 36 and substr(${table.mutationId}, 15, 1) = '4' and replace(${table.mutationId}, '-', '') not glob '*[^0-9a-f]*' and ${table.auditEventId} = ${table.mutationId} and length(${table.projectId}) between 1 and 128`,
),
check(
'ql3_legacy_data_directory_adoption_profile_check',
sql`${table.profile} in ('edge', 'standalone')`,
),
check(
'ql3_legacy_data_directory_adoption_digest_check',
sql`length(${table.sourceStageManifestDigest}) = 64 and ${table.sourceStageManifestDigest} not glob '*[^0-9a-f]*' and length(${table.transformationDigest}) = 64 and ${table.transformationDigest} not glob '*[^0-9a-f]*' and length(${table.modelDigest}) = 64 and ${table.modelDigest} not glob '*[^0-9a-f]*' and length(${table.publicationDigest}) = 64 and ${table.publicationDigest} not glob '*[^0-9a-f]*' and length(${table.receiptDigest}) = 64 and ${table.receiptDigest} not glob '*[^0-9a-f]*'`,
),
check(
'ql3_legacy_data_directory_adoption_count_check',
sql`${table.secretCount} between 0 and case ${table.profile} when 'edge' then 128 else 512 end and ${table.environmentSecretCount} between 0 and ${table.secretCount} and ${table.sshSecretCount} between 0 and ${table.secretCount} and ${table.environmentSecretCount} + ${table.sshSecretCount} = ${table.secretCount}`,
),
check(
'ql3_legacy_data_directory_adoption_model_check',
sql`length(cast(${table.modelJson} as blob)) between 2 and 1048576 and json_valid(${table.modelJson}) and json_type(${table.modelJson}) = 'object' and json_extract(${table.modelJson}, '$.schema') = 'qinglong/legacy-data-directory-applied-model@v1' and json_extract(${table.modelJson}, '$.activation') = 'disabled' and json_extract(${table.modelJson}, '$.config.schema') = 'qinglong/legacy-config-transformation@v1' and json_extract(${table.modelJson}, '$.config.activation') = 'disabled' and json_extract(${table.modelJson}, '$.keyv.schema') = 'qinglong/legacy-keyv-transformation@v1' and json_extract(${table.modelJson}, '$.keyv.activation') = 'disabled' and json_extract(${table.modelJson}, '$.ssh.schema') = 'qinglong/legacy-ssh-transformation@v1' and json_extract(${table.modelJson}, '$.ssh.activation') = 'disabled' and json_extract(${table.modelJson}, '$.manualReview.schema') = 'qinglong/legacy-data-directory-manual-review@v1' and json_extract(${table.modelJson}, '$.manualReview.required') = 0 and json_extract(${table.modelJson}, '$.manualReview.activation') = 'disabled'`,
),
check(
'ql3_legacy_data_directory_adoption_receipt_check',
sql`length(cast(${table.receiptJson} as blob)) between 2 and 1048576 and json_valid(${table.receiptJson}) and json_type(${table.receiptJson}) = 'object' and json_extract(${table.receiptJson}, '$.schema') = 'qinglong/legacy-data-directory-adoption-receipt@v1' and json_extract(${table.receiptJson}, '$.mutationId') = ${table.mutationId} and json_extract(${table.receiptJson}, '$.projectId') = ${table.projectId} and json_extract(${table.receiptJson}, '$.profile') = ${table.profile} and json_extract(${table.receiptJson}, '$.sourceStageManifestDigest') = ${table.sourceStageManifestDigest} and json_extract(${table.receiptJson}, '$.transformationDigest') = ${table.transformationDigest} and json_extract(${table.receiptJson}, '$.modelDigest') = ${table.modelDigest} and json_extract(${table.receiptJson}, '$.secretCount') = ${table.secretCount} and json_extract(${table.receiptJson}, '$.environmentSecretCount') = ${table.environmentSecretCount} and json_extract(${table.receiptJson}, '$.sshSecretCount') = ${table.sshSecretCount} and json_extract(${table.receiptJson}, '$.publicationDigest') = ${table.publicationDigest} and json_extract(${table.receiptJson}, '$.auditEventId') = ${table.auditEventId} and json_extract(${table.receiptJson}, '$.committedAtMs') = ${table.committedAtMs} and json_extract(${table.receiptJson}, '$.receiptDigest') = ${table.receiptDigest}`,
),
check(
'ql3_legacy_data_directory_adoption_time_check',
sql`${table.committedAtMs} >= 0`,
),
uniqueIndex('ql3_legacy_data_directory_adoption_transformation_uidx').on(
table.transformationDigest,
),
uniqueIndex('ql3_legacy_data_directory_adoption_receipt_uidx').on(
table.receiptDigest,
),
index('ql3_legacy_data_directory_adoption_project_time_idx').on(
table.projectId,
sql`${table.committedAtMs} desc`,
sql`${table.mutationId} desc`,
),
],
);
export const legacyDataDirectoryAdoptionSecrets = sqliteTable(
'QingLong3LegacyDataDirectoryAdoptionSecrets',
{
adoptionMutationId: text('adoption_mutation_id').notNull(),
ordinal: integer('ordinal').notNull(),
projectId: text('project_id').notNull(),
kind: text('kind').notNull(),
sourceNameDigest: text('source_name_digest').notNull(),
secretName: text('secret_name').notNull(),
secretVersion: integer('secret_version').notNull(),
secretMutationId: text('secret_mutation_id')
.notNull()
.references(() => localSecurityAuditEvents.eventId, {
onDelete: 'restrict',
onUpdate: 'restrict',
}),
valueFile: text('value_file').notNull(),
valueDigest: text('value_digest').notNull(),
secretRef: text('secret_ref').notNull(),
itemDigest: text('item_digest').notNull(),
},
(table) => [
primaryKey({ columns: [table.adoptionMutationId, table.ordinal] }),
foreignKey({
columns: [table.adoptionMutationId],
foreignColumns: [legacyDataDirectoryAdoptions.mutationId],
name: 'ql3_legacy_data_directory_adoption_secret_parent_fk',
})
.onDelete('restrict')
.onUpdate('restrict'),
foreignKey({
columns: [table.projectId, table.secretName, table.secretVersion],
foreignColumns: [
localSecretEnvelopes.projectId,
localSecretEnvelopes.name,
localSecretEnvelopes.version,
],
name: 'ql3_legacy_data_directory_adoption_secret_envelope_fk',
})
.onDelete('restrict')
.onUpdate('restrict'),
check(
'ql3_legacy_data_directory_adoption_secret_identity_check',
sql`${table.ordinal} between 1 and 512 and length(${table.projectId}) between 1 and 128 and ${table.kind} in ('environment', 'ssh_private_key') and length(${table.secretName}) between 1 and 128 and ${table.secretVersion} = 1 and length(${table.secretMutationId}) = 36 and substr(${table.secretMutationId}, 15, 1) = '4' and replace(${table.secretMutationId}, '-', '') not glob '*[^0-9a-f]*' and length(${table.valueFile}) = 83 and ${table.valueFile} glob 'secret-values/[0-9a-f]*.json' and length(${table.secretRef}) between 1 and 512`,
),
check(
'ql3_legacy_data_directory_adoption_secret_digest_check',
sql`length(${table.sourceNameDigest}) = 64 and ${table.sourceNameDigest} not glob '*[^0-9a-f]*' and length(${table.valueDigest}) = 64 and ${table.valueDigest} not glob '*[^0-9a-f]*' and length(${table.itemDigest}) = 64 and ${table.itemDigest} not glob '*[^0-9a-f]*'`,
),
uniqueIndex('ql3_legacy_data_directory_adoption_secret_name_uidx').on(
table.adoptionMutationId,
table.secretName,
),
uniqueIndex('ql3_legacy_data_directory_adoption_secret_mutation_uidx').on(
table.secretMutationId,
),
uniqueIndex('ql3_legacy_data_directory_adoption_secret_item_uidx').on(
table.itemDigest,
),
],
);
export const localIdentitySubjects = sqliteTable(
'QingLong3IdentitySubjects',
{
@@ -5042,6 +5180,8 @@ export const localSqliteSchema = Object.freeze({
toolExecutionResultRekeyHeads,
toolResultKeyRetirementReceipts,
legacyAdoptions,
legacyDataDirectoryAdoptions,
legacyDataDirectoryAdoptionSecrets,
localIdentitySubjects,
localApiCredentials,
localApiCredentialPepperBindings,
@@ -148,9 +148,11 @@ test('creates a reviewed edge database and opens runtime only after readiness',
'0096-capability-v48',
'0097-plugin-package-secret-binding-transition-receipts',
'0098-capability-v49',
'0099-legacy-data-directory-adoptions',
'0100-capability-v50',
]);
assert.equal(migrated.readiness.contractName, 'local-control-core');
assert.equal(migrated.readiness.contractVersion, 49);
assert.equal(migrated.readiness.contractVersion, 50);
assert.equal(migrated.readiness.journalMode, 'delete');
assert.equal(fs.statSync(databasePath).mode & 0o777, 0o600);
@@ -596,8 +598,8 @@ test('backfills v14 execution revisions with a verified independent digest', asy
.get(),
},
{
contract_version: 49,
migration_id: '0097-plugin-package-secret-binding-transition-receipts',
contract_version: 50,
migration_id: '0099-legacy-data-directory-adoptions',
},
);
} finally {
@@ -784,19 +786,19 @@ test('excludes reviewed optional feature tables while preserving unknown table d
const options = { databasePath, profile: 'edge' };
await migrateLocalSqlitePath(options);
const client = new DatabaseSync(databasePath);
assert.equal((await auditLocalSqlitePath(options)).tableCount, 81);
assert.equal((await auditLocalSqlitePath(options)).tableCount, 83);
client.exec(
'CREATE TABLE "ModelInvocationFeatureHead" (feature_id TEXT PRIMARY KEY)',
);
client.close();
assert.equal((await auditLocalSqlitePath(options)).tableCount, 81);
assert.equal((await auditLocalSqlitePath(options)).tableCount, 83);
const unknownClient = new DatabaseSync(databasePath);
unknownClient.exec('CREATE TABLE "UserExtensionData" (id TEXT PRIMARY KEY)');
unknownClient.close();
assert.equal((await auditLocalSqlitePath(options)).tableCount, 82);
assert.equal((await auditLocalSqlitePath(options)).tableCount, 84);
const triggerClient = new DatabaseSync(databasePath);
triggerClient.exec(`
@@ -156,7 +156,7 @@ test('atomically admits one generation-bound Workflow Run and exactly replays it
},
{ runs: 1, steps: 2, events: 3, mutations: 2, admissions: 1 },
);
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 49);
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 50);
});
test('runs an optional authorization guard inside new and replay transactions', async (t) => {
@@ -288,7 +288,7 @@ test('exactly replays immutable admission after the Workflow StepRun advances',
},
{ status: 'running', version: 5, eventSequence: 5 },
);
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 49);
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 50);
});
test('fails closed before writing when the exact installation is not active', async (t) => {
@@ -231,7 +231,7 @@ test('atomically admits the exact reconciled local Task revision and replays it'
stepAttemptCount: 0,
},
);
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 49);
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 50);
});
test('bounds candidate paging before SQL and fences cancellation', async (t) => {
@@ -40,9 +40,9 @@ test('creates and exactly replays a reviewed rollout backup', async (t) => {
await migrateLocalSqlitePath(state);
const prepared = await createLocalSqliteRolloutBackup(state);
assert.equal(prepared.status, 'prepared');
assert.equal(prepared.contractVersion, 49);
assert.equal(prepared.writeContractVersion, 49);
assert.equal(LOCAL_SQLITE_WRITE_CONTRACT_VERSION, 49);
assert.equal(prepared.contractVersion, 50);
assert.equal(prepared.writeContractVersion, 50);
assert.equal(LOCAL_SQLITE_WRITE_CONTRACT_VERSION, 50);
assert.match(prepared.sha256, /^[0-9a-f]{64}$/);
assert.equal(prepared.bytes > 0, true);
assert.equal(prepared.pageCount > 0, true);