@sentropic/artifacts 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -4
- package/dist/adapters/postgres-commit.d.ts +15 -0
- package/dist/adapters/postgres-commit.js +80 -0
- package/dist/adapters/postgres.d.ts +5 -2
- package/dist/adapters/postgres.js +39 -0
- package/dist/content/handler.js +79 -63
- package/dist/http.d.ts +42 -29
- package/dist/http.js +168 -491
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mcp-core.d.ts +52 -0
- package/dist/mcp-core.js +111 -0
- package/dist/mcp.d.ts +4 -60
- package/dist/mcp.js +7 -276
- package/dist/profile/contracts.d.ts +67 -0
- package/dist/profile/contracts.js +31 -0
- package/dist/profile/index.d.ts +6 -0
- package/dist/profile/index.js +117 -0
- package/dist/profile/json.d.ts +10 -0
- package/dist/profile/json.js +76 -0
- package/dist/profile/radar.d.ts +3 -0
- package/dist/profile/radar.js +21 -0
- package/dist/profile/schemas.d.ts +6 -0
- package/dist/profile/schemas.js +76 -0
- package/dist/profile/validation.d.ts +5 -0
- package/dist/profile/validation.js +104 -0
- package/dist/profile/validators.d.ts +3 -0
- package/dist/profile/validators.js +51 -0
- package/dist/server/authorized-replay.d.ts +33 -0
- package/dist/server/authorized-replay.js +31 -0
- package/dist/server/command-identity.d.ts +52 -0
- package/dist/server/command-identity.js +71 -0
- package/dist/server/commit/brands.d.ts +7 -0
- package/dist/server/commit/brands.js +3 -0
- package/dist/server/commit/coordinator.d.ts +2 -0
- package/dist/server/commit/coordinator.js +729 -0
- package/dist/server/commit/types.d.ts +379 -0
- package/dist/server/commit/types.js +94 -0
- package/dist/server/commit-contract.d.ts +4 -0
- package/dist/server/commit-contract.js +2 -0
- package/dist/server/failures.d.ts +84 -0
- package/dist/server/failures.js +133 -0
- package/dist/server/gc.d.ts +42 -1
- package/dist/server/gc.js +168 -5
- package/dist/server/idempotency.d.ts +4 -0
- package/dist/server/idempotency.js +14 -0
- package/dist/server/lifecycle.d.ts +2 -1
- package/dist/server/lifecycle.js +15 -1
- package/dist/server/object-intents.d.ts +14 -1
- package/dist/server/object-intents.js +31 -0
- package/dist/server/outcome-contract.d.ts +3 -0
- package/dist/server/outcome-contract.js +3 -0
- package/dist/server/outcome-projection.d.ts +21 -0
- package/dist/server/outcome-projection.js +29 -0
- package/dist/server/preparation-cleanup.d.ts +31 -0
- package/dist/server/preparation-cleanup.js +106 -0
- package/dist/server/publication-limits.d.ts +10 -0
- package/dist/server/publication-limits.js +38 -0
- package/dist/server/publication.d.ts +85 -1
- package/dist/server/publication.js +288 -31
- package/dist/server/service.d.ts +64 -1
- package/dist/server/service.js +187 -1
- package/dist/server/uploads.d.ts +4 -1
- package/dist/server/uploads.js +63 -35
- package/dist/testing.d.ts +50 -0
- package/dist/testing.js +86 -0
- package/docs/migration-l6.md +129 -0
- package/examples/radar-commit-setup.ts +299 -0
- package/l0-freeze/command-identity-contract.ts +303 -0
- package/l0-freeze/commit-contract.ts +421 -0
- package/l0-freeze/failure-contract.ts +174 -0
- package/l0-freeze/profile-contract.ts +219 -0
- package/migrations/0003_coordinator.sql +28 -0
- package/package.json +31 -1
- package/src/contracts.ts +125 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { blobDigestOfKey } from './publication.js';
|
|
2
|
+
function preparation(intent) {
|
|
3
|
+
const p = intent.payload;
|
|
4
|
+
if (typeof p.attemptId !== 'string' || typeof p.invocationId !== 'string' ||
|
|
5
|
+
typeof p.preparedAtMs !== 'number' || !Number.isFinite(p.preparedAtMs) ||
|
|
6
|
+
typeof p.cleanupDeadlineMs !== 'number' || !Number.isFinite(p.cleanupDeadlineMs) ||
|
|
7
|
+
p.cleanupDeadlineMs <= p.preparedAtMs || !Array.isArray(p.blobKeys) ||
|
|
8
|
+
!p.blobKeys.every((key) => typeof key === 'string'))
|
|
9
|
+
throw new Error('invalid preparation metadata');
|
|
10
|
+
return { attemptId: p.attemptId, invocationId: p.invocationId,
|
|
11
|
+
preparedAtMs: p.preparedAtMs, cleanupDeadlineMs: p.cleanupDeadlineMs, blobKeys: [...p.blobKeys],
|
|
12
|
+
state: p.cleanupState === 'failed-cleaned' ? 'failed-cleaned' : intent.state === 'failed' ? 'failed' : 'pending-cleanup' };
|
|
13
|
+
}
|
|
14
|
+
async function hasResidualBytes(host, attempt) {
|
|
15
|
+
for (const key of attempt.blobKeys) {
|
|
16
|
+
const digest = blobDigestOfKey(key);
|
|
17
|
+
if (await host.refs.count(digest) === 0 && !await host.refs.isPinned(digest) &&
|
|
18
|
+
(await host.blobs.listKeys(key)).includes(key))
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
/** Historical attempt attribution plus current protection, scoped to one artifact. */
|
|
24
|
+
export async function inspectPreparations(host, artifactId) {
|
|
25
|
+
const intents = await host.intents.listPreparationIntents({ artifactId });
|
|
26
|
+
const attempts = [];
|
|
27
|
+
const invalidIntents = [];
|
|
28
|
+
for (const intent of intents) {
|
|
29
|
+
let attempt;
|
|
30
|
+
try {
|
|
31
|
+
attempt = preparation(intent);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
invalidIntents.push({ intentId: intent.intentId, error: String(error) });
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
attempts.push(attempt.state === 'failed-cleaned' && await hasResidualBytes(host, attempt)
|
|
38
|
+
? { ...attempt, state: 'failed' } : attempt);
|
|
39
|
+
}
|
|
40
|
+
const protectedKeys = [], abandonedKeys = [];
|
|
41
|
+
for (const key of [...new Set(attempts.flatMap((attempt) => attempt.blobKeys))].sort()) {
|
|
42
|
+
const digest = blobDigestOfKey(key);
|
|
43
|
+
const protectedBlob = await host.refs.count(digest) > 0 || await host.refs.isPinned(digest);
|
|
44
|
+
(protectedBlob ? protectedKeys : abandonedKeys).push(key);
|
|
45
|
+
}
|
|
46
|
+
return { attempts, protectedKeys, abandonedKeys, invalidIntents };
|
|
47
|
+
}
|
|
48
|
+
/** Run one deterministic, bounded sweep through the supplied clock deadline. */
|
|
49
|
+
export async function scheduleBoundedPreparationCleanup(host, throughMs) {
|
|
50
|
+
const limit = host.cleanupBatchSize ?? 100;
|
|
51
|
+
if (!Number.isFinite(throughMs) || !Number.isSafeInteger(limit) || limit < 1)
|
|
52
|
+
throw new Error('invalid cleanup bound');
|
|
53
|
+
const candidates = await host.intents.listPreparationIntents({ throughMs, limit });
|
|
54
|
+
const failures = [];
|
|
55
|
+
for (const candidate of candidates) {
|
|
56
|
+
try {
|
|
57
|
+
let previous;
|
|
58
|
+
try {
|
|
59
|
+
previous = preparation(candidate);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
await host.intents.markFailed(candidate.intentId, `invalid preparation metadata: ${candidate.intentId}`);
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
if (previous.state === 'failed-cleaned' && !await hasResidualBytes(host, previous))
|
|
66
|
+
continue;
|
|
67
|
+
const intent = await host.intents.claimPreparationCleanup(candidate.intentId, throughMs);
|
|
68
|
+
if (intent === undefined)
|
|
69
|
+
continue;
|
|
70
|
+
const attempt = preparation(intent);
|
|
71
|
+
if (typeof intent.payload.releaseId !== 'string')
|
|
72
|
+
throw new Error('preparation has no pin owner');
|
|
73
|
+
for (const key of [...new Set(attempt.blobKeys)].sort()) {
|
|
74
|
+
const digest = blobDigestOfKey(key);
|
|
75
|
+
await host.claims.withDigestLock(digest, async (claims) => {
|
|
76
|
+
try {
|
|
77
|
+
await host.refs.unpin(digest, `preparation:${intent.payload.releaseId}`);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (!(error instanceof Error) || !/not pinned/.test(error.message))
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
if (await host.refs.count(digest) > 0 || await host.refs.isPinned(digest))
|
|
84
|
+
return;
|
|
85
|
+
// A different purge owns its claim and acknowledges its own external DELETE.
|
|
86
|
+
if (await claims.isClaimed(digest))
|
|
87
|
+
throw new Error('preparation cleanup blocked by deletion claim');
|
|
88
|
+
await claims.claim(digest, `cleanup:${intent.intentId}`);
|
|
89
|
+
try {
|
|
90
|
+
if ((await host.blobs.listKeys(key)).includes(key))
|
|
91
|
+
await host.blobs.deleteBlob(key);
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
await claims.releaseClaim(digest);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
await host.intents.settlePreparationCleanup(intent.intentId);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
failures.push(new Error(`preparation ${candidate.intentId}: ${String(error)}`, { cause: error }));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (failures.length)
|
|
105
|
+
throw new AggregateError(failures, 'preparation cleanup failed; debt retained for retry');
|
|
106
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { PublicationFileInput } from './publication.js';
|
|
2
|
+
export interface PublicationLimits {
|
|
3
|
+
maxFiles: number;
|
|
4
|
+
maxFileBytes: number;
|
|
5
|
+
maxTotalBytes: number;
|
|
6
|
+
}
|
|
7
|
+
export declare const DEFAULT_PUBLICATION_LIMITS: Readonly<PublicationLimits>;
|
|
8
|
+
export declare function publicationLimits(overrides?: Partial<PublicationLimits>): Readonly<PublicationLimits>;
|
|
9
|
+
/** Validate before copying bytes, hashing, idempotency lookup or recording intents. */
|
|
10
|
+
export declare function validatePublicationFiles(files: PublicationFileInput[], limits: Readonly<PublicationLimits>): void;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export const DEFAULT_PUBLICATION_LIMITS = Object.freeze({
|
|
2
|
+
maxFiles: 128, maxFileBytes: 16 * 1024 * 1024, maxTotalBytes: 64 * 1024 * 1024,
|
|
3
|
+
});
|
|
4
|
+
export function publicationLimits(overrides = {}) {
|
|
5
|
+
const limits = { ...DEFAULT_PUBLICATION_LIMITS, ...overrides };
|
|
6
|
+
if (Object.values(limits).some((value) => !Number.isSafeInteger(value) || value <= 0)) {
|
|
7
|
+
throw new Error('invalid publication limits');
|
|
8
|
+
}
|
|
9
|
+
return Object.freeze(limits);
|
|
10
|
+
}
|
|
11
|
+
/** Validate before copying bytes, hashing, idempotency lookup or recording intents. */
|
|
12
|
+
export function validatePublicationFiles(files, limits) {
|
|
13
|
+
if (!Array.isArray(files) || files.length === 0 || files.length > limits.maxFiles) {
|
|
14
|
+
throw new Error('invalid publication file count budget');
|
|
15
|
+
}
|
|
16
|
+
const paths = new Set();
|
|
17
|
+
let total = 0;
|
|
18
|
+
for (const file of files) {
|
|
19
|
+
if (typeof file.path !== 'string' || Buffer.byteLength(file.path) > 1024 ||
|
|
20
|
+
/[\\%?#:\u0000-\u001f\u007f]/.test(file.path) ||
|
|
21
|
+
file.path.split('/').some((part) => part === '' || part === '.' || part === '..')) {
|
|
22
|
+
throw new Error('invalid publication path');
|
|
23
|
+
}
|
|
24
|
+
if (paths.has(file.path))
|
|
25
|
+
throw new Error('duplicate publication path');
|
|
26
|
+
paths.add(file.path);
|
|
27
|
+
if (typeof file.mimeType !== 'string' || file.mimeType.length === 0 ||
|
|
28
|
+
file.mimeType.length > 256 || /[\u0000-\u001f\u007f]/.test(file.mimeType)) {
|
|
29
|
+
throw new Error('invalid publication MIME type');
|
|
30
|
+
}
|
|
31
|
+
if (!(file.bytes instanceof Uint8Array) || file.bytes.byteLength > limits.maxFileBytes) {
|
|
32
|
+
throw new Error('invalid publication file byte budget');
|
|
33
|
+
}
|
|
34
|
+
total += file.bytes.byteLength;
|
|
35
|
+
if (total > limits.maxTotalBytes)
|
|
36
|
+
throw new Error('invalid publication total byte budget');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { JsonValue } from '../contracts.js';
|
|
2
2
|
import type { IntentStore } from './object-intents.js';
|
|
3
3
|
import type { IdempotencyStore } from './idempotency.js';
|
|
4
|
+
import { type SourceRef, type SourceStore } from './sources.js';
|
|
5
|
+
import { type PublicationLimits } from './publication-limits.js';
|
|
6
|
+
import type { ArtifactAcl } from './access.js';
|
|
7
|
+
import type { ArtifactState } from './lifecycle.js';
|
|
8
|
+
import { type RefcountStore } from './gc.js';
|
|
9
|
+
import type { CommandIdentity } from './command-identity.js';
|
|
10
|
+
import type { CommitLockFn, ArtifactCommitCoordinator, FinalizeResult, FinalizeTxClient } from './commit/types.js';
|
|
11
|
+
export { DEFAULT_PUBLICATION_LIMITS, type PublicationLimits } from './publication-limits.js';
|
|
4
12
|
export interface ReleaseRecord {
|
|
5
13
|
releaseId: string;
|
|
6
14
|
artifactId: string;
|
|
@@ -40,7 +48,9 @@ export interface PublishInput {
|
|
|
40
48
|
artifactId: string;
|
|
41
49
|
profile: Record<string, JsonValue | undefined>;
|
|
42
50
|
files: PublicationFileInput[];
|
|
51
|
+
source?: SourceRef;
|
|
43
52
|
idempotencyKey?: string;
|
|
53
|
+
/** @deprecated Ignored; the server hashes release metadata and file digests. */
|
|
44
54
|
paramsHash?: string;
|
|
45
55
|
}
|
|
46
56
|
export interface PublishResult {
|
|
@@ -48,7 +58,9 @@ export interface PublishResult {
|
|
|
48
58
|
manifest: string;
|
|
49
59
|
}
|
|
50
60
|
export interface PublicationService {
|
|
51
|
-
|
|
61
|
+
readonly limits?: Readonly<PublicationLimits>;
|
|
62
|
+
/** The host source store participates in the idempotent commit when a source is supplied. */
|
|
63
|
+
publish(input: PublishInput, sources?: SourceStore): Promise<PublishResult>;
|
|
52
64
|
getCurrent(artifactId: string): Promise<{
|
|
53
65
|
releaseId: string;
|
|
54
66
|
manifest: string;
|
|
@@ -64,4 +76,76 @@ export declare function createPublicationService(deps: {
|
|
|
64
76
|
blobs: PublicationBlobStore;
|
|
65
77
|
intents: IntentStore;
|
|
66
78
|
idempotency?: IdempotencyStore;
|
|
79
|
+
limits?: Partial<PublicationLimits>;
|
|
67
80
|
}): PublicationService;
|
|
81
|
+
/**
|
|
82
|
+
* L4 (immo #861) — guarded publication through the L3 atomic finalization
|
|
83
|
+
* unit (spec r2 A3–A6).
|
|
84
|
+
*
|
|
85
|
+
* Blob bytes are staged and read-back-verified in preparation (no key lock
|
|
86
|
+
* or leased connection spans S3 I/O). The unit then runs caller lifecycle /
|
|
87
|
+
* ACL / pointer locks, provisional release + pointer + refcount writes, the
|
|
88
|
+
* host guard after all waits (lifecycle-active + owner ACL + participant
|
|
89
|
+
* approval), a single success insert with intent completion, and one COMMIT.
|
|
90
|
+
* Denial or conflict rolls everything back: no release, pointer, receipt or
|
|
91
|
+
* (transactional) refcount residue. Same caller key replays the winner.
|
|
92
|
+
*/
|
|
93
|
+
export type PublishDenialReason = 'denied' | 'revoked' | 'invalid_object';
|
|
94
|
+
export interface PublishAuthorizationInput {
|
|
95
|
+
readonly lifecycleState: ArtifactState;
|
|
96
|
+
readonly acl: ArtifactAcl | undefined;
|
|
97
|
+
readonly actorId: string;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Pure lifecycle/ACL predicate evaluated after the unit waits with fresh
|
|
101
|
+
* reads. Owner-only publication: lifecycle mutations are owner-only across
|
|
102
|
+
* the service, and grant-based publish rights stay a host/L5 decision.
|
|
103
|
+
*/
|
|
104
|
+
export declare function decidePublishAuthorization(input: PublishAuthorizationInput): {
|
|
105
|
+
allowed: true;
|
|
106
|
+
} | {
|
|
107
|
+
allowed: false;
|
|
108
|
+
reason: PublishDenialReason;
|
|
109
|
+
};
|
|
110
|
+
/** Transaction-scoped provisional writers for the finalized unit. */
|
|
111
|
+
export interface PublicationUnitWriter {
|
|
112
|
+
readAuthorization(artifactId: string): Promise<{
|
|
113
|
+
state: ArtifactState;
|
|
114
|
+
acl: ArtifactAcl | undefined;
|
|
115
|
+
}>;
|
|
116
|
+
putSource(artifactId: string, source: SourceRef): Promise<void>;
|
|
117
|
+
putRelease(release: ReleaseRecord): Promise<void>;
|
|
118
|
+
registerBlob(storageScopeId: string, digest: string, size: number): Promise<void>;
|
|
119
|
+
promoteCurrentLatestWins(artifactId: string, releaseId: string): Promise<void>;
|
|
120
|
+
addReleaseRef(digest: string, holder: string): Promise<void>;
|
|
121
|
+
completeIntent(intentId: string): Promise<void>;
|
|
122
|
+
}
|
|
123
|
+
/** PostgreSQL unit writer over the coordinator's leased client. */
|
|
124
|
+
export declare function createPostgresPublicationUnitWriter(client: FinalizeTxClient): PublicationUnitWriter;
|
|
125
|
+
export interface GuardedPublishInput extends PublishInput {
|
|
126
|
+
/** Frozen trusted identity supplied by the host's L1 command normalizer. */
|
|
127
|
+
readonly identity: CommandIdentity;
|
|
128
|
+
readonly actorId: string;
|
|
129
|
+
readonly policyRevision: string;
|
|
130
|
+
readonly callerKey?: string;
|
|
131
|
+
readonly attemptId?: string;
|
|
132
|
+
readonly invocationId?: string;
|
|
133
|
+
}
|
|
134
|
+
export interface GuardedPublicationDeps {
|
|
135
|
+
readonly coordinator: ArtifactCommitCoordinator;
|
|
136
|
+
readonly blobs: PublicationBlobStore;
|
|
137
|
+
readonly intents: IntentStore;
|
|
138
|
+
readonly createUnitWriter: (client: FinalizeTxClient) => PublicationUnitWriter;
|
|
139
|
+
/** Host row locks in PARTICIPANT_LOCK_ORDER classes (PG FOR UPDATE; empty for memory). */
|
|
140
|
+
readonly locks?: Partial<Record<'artifact-lifecycle' | 'acl' | 'current-pointer', CommitLockFn[]>>;
|
|
141
|
+
/** Shared retention store: preparation pins and denial compensation. */
|
|
142
|
+
readonly refs: RefcountStore;
|
|
143
|
+
readonly noteAbandoned?: (attemptId: string) => void | Promise<void>;
|
|
144
|
+
readonly cleanupWindowMs?: number;
|
|
145
|
+
readonly limits?: Partial<PublicationLimits>;
|
|
146
|
+
}
|
|
147
|
+
export interface GuardedPublicationService {
|
|
148
|
+
publishFinalized(input: GuardedPublishInput): Promise<FinalizeResult>;
|
|
149
|
+
}
|
|
150
|
+
/** Create a publication service whose commits finalize through L3 ports. */
|
|
151
|
+
export declare function createGuardedPublicationService(deps: GuardedPublicationDeps): GuardedPublicationService;
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { createHash } from 'node:crypto';
|
|
15
15
|
import { canonicalManifest, manifestDigest } from '../codecs.js';
|
|
16
|
+
import { parseSourceRef } from './sources.js';
|
|
17
|
+
import { publicationLimits, validatePublicationFiles } from './publication-limits.js';
|
|
18
|
+
import { createPostgresDeletionClaimStore } from './gc.js';
|
|
19
|
+
export { DEFAULT_PUBLICATION_LIMITS } from './publication-limits.js';
|
|
16
20
|
/** Derive the `sha256:<hex>` digest from a digest-addressed blob key. */
|
|
17
21
|
export function blobDigestOfKey(key) {
|
|
18
22
|
const hex = key.split('/').pop() ?? '';
|
|
@@ -70,28 +74,46 @@ export function createMemoryBlobStore() {
|
|
|
70
74
|
function shaHex(bytes) {
|
|
71
75
|
return createHash('sha256').update(bytes).digest('hex');
|
|
72
76
|
}
|
|
77
|
+
/** Validate-free preparation shared by the split and finalized paths. */
|
|
78
|
+
function preparePublication(input) {
|
|
79
|
+
const entries = input.files.map((file) => {
|
|
80
|
+
const hex = shaHex(file.bytes);
|
|
81
|
+
return {
|
|
82
|
+
entry: { path: file.path, digest: `sha256:${hex}`, size: file.bytes.length, mimeType: file.mimeType },
|
|
83
|
+
key: `artifacts/${input.storageScopeId}/blobs/sha256/${hex}`,
|
|
84
|
+
bytes: file.bytes,
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
const body = {
|
|
88
|
+
schemaVersion: 'artifacts/v1',
|
|
89
|
+
artifactId: input.artifactId,
|
|
90
|
+
storageScopeId: input.storageScopeId,
|
|
91
|
+
ownerId: input.ownerId,
|
|
92
|
+
profile: input.profile,
|
|
93
|
+
files: entries.map((e) => e.entry),
|
|
94
|
+
};
|
|
95
|
+
const digest = manifestDigest(body);
|
|
96
|
+
const manifest = canonicalManifest({ ...body, digest });
|
|
97
|
+
return { entries, digest, manifest };
|
|
98
|
+
}
|
|
99
|
+
/** Read-back verification shared by the split and finalized paths. */
|
|
100
|
+
async function verifyStagedBlobs(blobs, entries) {
|
|
101
|
+
for (const { key, bytes } of entries) {
|
|
102
|
+
const stored = await blobs.getBlob(key);
|
|
103
|
+
if (stored === undefined || shaHex(stored) !== shaHex(bytes)) {
|
|
104
|
+
throw new Error(`publication digest mismatch for ${key}: read-back failed`);
|
|
105
|
+
}
|
|
106
|
+
if (stored.length !== bytes.length) {
|
|
107
|
+
throw new Error(`publication size mismatch for ${key}: read-back failed`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
73
111
|
/** Create a publication service over injected stores (no driver imports). */
|
|
74
112
|
export function createPublicationService(deps) {
|
|
75
|
-
|
|
113
|
+
const limits = publicationLimits(deps.limits);
|
|
114
|
+
async function commitOnce(input, prepared, sources) {
|
|
76
115
|
const releaseId = crypto.randomUUID();
|
|
77
|
-
const entries
|
|
78
|
-
const hex = shaHex(file.bytes);
|
|
79
|
-
return {
|
|
80
|
-
entry: { path: file.path, digest: `sha256:${hex}`, size: file.bytes.length, mimeType: file.mimeType },
|
|
81
|
-
key: `artifacts/${input.storageScopeId}/blobs/sha256/${hex}`,
|
|
82
|
-
bytes: file.bytes,
|
|
83
|
-
};
|
|
84
|
-
});
|
|
85
|
-
const body = {
|
|
86
|
-
schemaVersion: 'artifacts/v1',
|
|
87
|
-
artifactId: input.artifactId,
|
|
88
|
-
storageScopeId: input.storageScopeId,
|
|
89
|
-
ownerId: input.ownerId,
|
|
90
|
-
profile: input.profile,
|
|
91
|
-
files: entries.map((e) => e.entry),
|
|
92
|
-
};
|
|
93
|
-
const digest = manifestDigest(body);
|
|
94
|
-
const manifest = canonicalManifest({ ...body, digest });
|
|
116
|
+
const { entries, digest, manifest } = prepared;
|
|
95
117
|
const intent = await deps.intents.record('commit-release', {
|
|
96
118
|
artifactId: input.artifactId,
|
|
97
119
|
releaseId,
|
|
@@ -101,15 +123,7 @@ export function createPublicationService(deps) {
|
|
|
101
123
|
for (const { key, bytes } of entries) {
|
|
102
124
|
await deps.blobs.putBlob(key, bytes);
|
|
103
125
|
}
|
|
104
|
-
|
|
105
|
-
const stored = await deps.blobs.getBlob(key);
|
|
106
|
-
if (stored === undefined || shaHex(stored) !== shaHex(bytes)) {
|
|
107
|
-
throw new Error(`publication digest mismatch for ${key}: read-back failed`);
|
|
108
|
-
}
|
|
109
|
-
if (stored.length !== bytes.length) {
|
|
110
|
-
throw new Error(`publication size mismatch for ${key}: read-back failed`);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
126
|
+
await verifyStagedBlobs(deps.blobs, entries);
|
|
113
127
|
await deps.state.putRelease({
|
|
114
128
|
releaseId,
|
|
115
129
|
artifactId: input.artifactId,
|
|
@@ -124,16 +138,26 @@ export function createPublicationService(deps) {
|
|
|
124
138
|
await deps.intents.markFailed(intent.intentId, 'commit race lost: explicit conflict');
|
|
125
139
|
throw new Error(`publication conflict for ${input.artifactId}: concurrent commit won`);
|
|
126
140
|
}
|
|
141
|
+
if (input.source !== undefined)
|
|
142
|
+
await sources.putSource(input.artifactId, input.source);
|
|
127
143
|
await deps.intents.complete(intent.intentId);
|
|
128
144
|
return { releaseId, manifest };
|
|
129
145
|
}
|
|
130
146
|
return {
|
|
131
|
-
|
|
147
|
+
limits,
|
|
148
|
+
async publish(input, sources) {
|
|
149
|
+
validatePublicationFiles(input.files, limits);
|
|
150
|
+
const source = input.source === undefined ? undefined : parseSourceRef(input.source);
|
|
151
|
+
if (source !== undefined && sources === undefined)
|
|
152
|
+
throw new Error('invalid publication: missing source store');
|
|
153
|
+
input = { ...input, source, files: input.files.map((file) => ({ ...file, bytes: new Uint8Array(file.bytes) })) };
|
|
154
|
+
const prepared = preparePublication(input);
|
|
155
|
+
const requestHash = createHash('sha256').update(JSON.stringify([prepared.digest, source ?? null])).digest('hex');
|
|
132
156
|
if (input.idempotencyKey !== undefined && deps.idempotency !== undefined) {
|
|
133
|
-
const { result } = await deps.idempotency.execute(input.
|
|
157
|
+
const { result } = await deps.idempotency.execute(JSON.stringify(['publish', input.ownerId, input.idempotencyKey]), requestHash, () => commitOnce(input, prepared, sources));
|
|
134
158
|
return result;
|
|
135
159
|
}
|
|
136
|
-
return commitOnce(input);
|
|
160
|
+
return commitOnce(input, prepared, sources);
|
|
137
161
|
},
|
|
138
162
|
async getCurrent(artifactId) {
|
|
139
163
|
const releaseId = await deps.state.getCurrentReleaseId(artifactId);
|
|
@@ -152,3 +176,236 @@ export function createPublicationService(deps) {
|
|
|
152
176
|
},
|
|
153
177
|
};
|
|
154
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* Pure lifecycle/ACL predicate evaluated after the unit waits with fresh
|
|
181
|
+
* reads. Owner-only publication: lifecycle mutations are owner-only across
|
|
182
|
+
* the service, and grant-based publish rights stay a host/L5 decision.
|
|
183
|
+
*/
|
|
184
|
+
export function decidePublishAuthorization(input) {
|
|
185
|
+
if (input.lifecycleState === 'withdrawn')
|
|
186
|
+
return { allowed: false, reason: 'revoked' };
|
|
187
|
+
if (input.lifecycleState === 'deleted' || input.lifecycleState === 'purged') {
|
|
188
|
+
return { allowed: false, reason: 'invalid_object' };
|
|
189
|
+
}
|
|
190
|
+
if (input.lifecycleState !== 'active')
|
|
191
|
+
return { allowed: false, reason: 'revoked' };
|
|
192
|
+
if (input.acl === undefined || input.acl.ownerId !== input.actorId) {
|
|
193
|
+
return { allowed: false, reason: 'denied' };
|
|
194
|
+
}
|
|
195
|
+
return { allowed: true };
|
|
196
|
+
}
|
|
197
|
+
/** PostgreSQL unit writer over the coordinator's leased client. */
|
|
198
|
+
export function createPostgresPublicationUnitWriter(client) {
|
|
199
|
+
return {
|
|
200
|
+
async readAuthorization(artifactId) {
|
|
201
|
+
const { rows } = await client.query('SELECT lifecycle_state, owner_id FROM artifacts.artifacts WHERE id = $1', [artifactId]);
|
|
202
|
+
const row = rows[0];
|
|
203
|
+
return { state: row?.lifecycle_state ?? 'purged',
|
|
204
|
+
acl: row === undefined ? undefined : { ownerId: row.owner_id, grants: [], isPublic: false,
|
|
205
|
+
withdrawn: row.lifecycle_state === 'withdrawn' } };
|
|
206
|
+
},
|
|
207
|
+
async putSource(artifactId, source) {
|
|
208
|
+
await client.query(`INSERT INTO artifacts.artifact_sources (artifact_id, kind, commit_tx, steps)
|
|
209
|
+
VALUES ($1, $2, $3, $4::jsonb) ON CONFLICT (artifact_id) DO UPDATE
|
|
210
|
+
SET kind = EXCLUDED.kind, commit_tx = EXCLUDED.commit_tx, steps = EXCLUDED.steps`, [artifactId, source.kind, source.kind === 'base-commit' ? source.commit : null,
|
|
211
|
+
source.kind === 'recipe' ? JSON.stringify(source.steps) : null]);
|
|
212
|
+
},
|
|
213
|
+
async putRelease(release) {
|
|
214
|
+
await client.query(`INSERT INTO artifacts.releases (id, artifact_id, manifest_digest, manifest, state, created_at)
|
|
215
|
+
VALUES ($1, $2, $3, $4, 'ready', $5)`, [release.releaseId, release.artifactId, release.manifestDigest, release.manifest, release.createdAt]);
|
|
216
|
+
},
|
|
217
|
+
async registerBlob(storageScopeId, digest, size) {
|
|
218
|
+
await client.query(`INSERT INTO artifacts.blobs (storage_scope_id, digest, size)
|
|
219
|
+
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, [storageScopeId, digest, size]);
|
|
220
|
+
},
|
|
221
|
+
async promoteCurrentLatestWins(artifactId, releaseId) {
|
|
222
|
+
// CR-3 latest-wins: last committer wins the pointer, no loser conflict.
|
|
223
|
+
await client.query(`INSERT INTO artifacts.artifact_current (artifact_id, release_id)
|
|
224
|
+
VALUES ($1, $2) ON CONFLICT (artifact_id) DO UPDATE SET release_id = EXCLUDED.release_id`, [artifactId, releaseId]);
|
|
225
|
+
},
|
|
226
|
+
async addReleaseRef(digest, holder) {
|
|
227
|
+
await createPostgresDeletionClaimStore(client).withDigestLock(digest, async (claims) => {
|
|
228
|
+
if (await claims.isClaimed(digest))
|
|
229
|
+
throw new Error(`blob scheduled for deletion: ${digest}`);
|
|
230
|
+
await client.query(`INSERT INTO artifacts.blob_ref_holders (digest, holder)
|
|
231
|
+
VALUES ($1, $2) ON CONFLICT DO NOTHING`, [digest, holder]);
|
|
232
|
+
});
|
|
233
|
+
},
|
|
234
|
+
async completeIntent(intentId) {
|
|
235
|
+
const { rowCount } = await client.query(`UPDATE artifacts.object_intents SET state = 'done', updated_at = now() WHERE id = $1
|
|
236
|
+
AND state = 'pending' AND (NOT (payload ? 'cleanupDeadlineMs')
|
|
237
|
+
OR (payload->>'cleanupDeadlineMs')::numeric > EXTRACT(EPOCH FROM clock_timestamp()) * 1000)`, [intentId]);
|
|
238
|
+
if (rowCount === 0)
|
|
239
|
+
throw new Error('preparation expired or abandoned; explicit retry required');
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const DEFAULT_CLEANUP_WINDOW_MS = 15 * 60 * 1000;
|
|
244
|
+
/** Create a publication service whose commits finalize through L3 ports. */
|
|
245
|
+
export function createGuardedPublicationService(deps) {
|
|
246
|
+
const limits = publicationLimits(deps.limits);
|
|
247
|
+
const cleanupWindowMs = deps.cleanupWindowMs ?? DEFAULT_CLEANUP_WINDOW_MS;
|
|
248
|
+
if (!Number.isSafeInteger(cleanupWindowMs) || cleanupWindowMs < 1)
|
|
249
|
+
throw new Error('invalid preparation lifetime');
|
|
250
|
+
const guard = (client) => async (context, transaction) => {
|
|
251
|
+
const { state, acl } = await deps.createUnitWriter(client()).readAuthorization(context.artifactId);
|
|
252
|
+
const verdict = decidePublishAuthorization({ lifecycleState: state, acl, actorId: context.actorId });
|
|
253
|
+
if (!verdict.allowed) {
|
|
254
|
+
return {
|
|
255
|
+
allowed: false,
|
|
256
|
+
reason: verdict.reason,
|
|
257
|
+
publicCode: 'access_denied',
|
|
258
|
+
status: 403,
|
|
259
|
+
retriable: false,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
const approval = await transaction.participant.checkApproval({
|
|
263
|
+
actorId: context.actorId,
|
|
264
|
+
operation: context.operation,
|
|
265
|
+
});
|
|
266
|
+
if (!approval.approved) {
|
|
267
|
+
return {
|
|
268
|
+
allowed: false,
|
|
269
|
+
reason: approval.reason ?? 'denied',
|
|
270
|
+
publicCode: 'access_denied',
|
|
271
|
+
status: 403,
|
|
272
|
+
retriable: false,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
const revision = await transaction.participant.readRevision();
|
|
276
|
+
if (revision !== context.policyRevision) {
|
|
277
|
+
return {
|
|
278
|
+
allowed: false,
|
|
279
|
+
reason: 'revoked',
|
|
280
|
+
publicCode: 'access_denied',
|
|
281
|
+
status: 403,
|
|
282
|
+
retriable: false,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
return { allowed: true, fence: transaction.participant.mintFence(revision, context.invocationId) };
|
|
286
|
+
};
|
|
287
|
+
return {
|
|
288
|
+
async publishFinalized(input) {
|
|
289
|
+
validatePublicationFiles(input.files, limits);
|
|
290
|
+
const prepared = preparePublication(input);
|
|
291
|
+
const { internalKey, commandHash, codecVersion } = input.identity;
|
|
292
|
+
const callerKey = input.callerKey ?? internalKey;
|
|
293
|
+
const releaseId = crypto.randomUUID();
|
|
294
|
+
const invocationId = input.invocationId ?? crypto.randomUUID();
|
|
295
|
+
const attemptId = input.attemptId ?? releaseId;
|
|
296
|
+
const nowMs = Date.now();
|
|
297
|
+
const intent = await deps.intents.record('commit-release', {
|
|
298
|
+
artifactId: input.artifactId,
|
|
299
|
+
releaseId,
|
|
300
|
+
manifestDigest: prepared.digest,
|
|
301
|
+
blobKeys: prepared.entries.map((e) => e.key),
|
|
302
|
+
attemptId,
|
|
303
|
+
invocationId,
|
|
304
|
+
operation: 'publish',
|
|
305
|
+
preparedAtMs: nowMs,
|
|
306
|
+
cleanupDeadlineMs: nowMs + cleanupWindowMs,
|
|
307
|
+
});
|
|
308
|
+
const pinned = [];
|
|
309
|
+
const pinReason = `preparation:${releaseId}`;
|
|
310
|
+
let preservePins = false;
|
|
311
|
+
try {
|
|
312
|
+
for (const digest of [...new Set(prepared.entries.map((entry) => entry.entry.digest))].sort()) {
|
|
313
|
+
await deps.refs.pin(digest, pinReason);
|
|
314
|
+
pinned.push(digest);
|
|
315
|
+
}
|
|
316
|
+
await deps.coordinator.testObserver?.arrive('before_blob_put', invocationId);
|
|
317
|
+
for (const { key, bytes } of prepared.entries) {
|
|
318
|
+
if ((await deps.intents.get(intent.intentId))?.state !== 'pending') {
|
|
319
|
+
throw new Error('preparation abandoned before blob PUT');
|
|
320
|
+
}
|
|
321
|
+
await deps.blobs.putBlob(key, bytes);
|
|
322
|
+
}
|
|
323
|
+
await deps.coordinator.testObserver?.arrive('after_blob_put_before_verify', invocationId);
|
|
324
|
+
await deps.coordinator.testObserver?.arrive('before_blob_readback', invocationId);
|
|
325
|
+
await verifyStagedBlobs(deps.blobs, prepared.entries);
|
|
326
|
+
const addedRefs = [];
|
|
327
|
+
const source = input.source === undefined ? undefined : parseSourceRef(input.source);
|
|
328
|
+
let finalizationClient;
|
|
329
|
+
const request = {
|
|
330
|
+
scopeId: input.storageScopeId,
|
|
331
|
+
internalKey,
|
|
332
|
+
commandHash,
|
|
333
|
+
codecVersion,
|
|
334
|
+
invocationId,
|
|
335
|
+
actorId: input.actorId,
|
|
336
|
+
operation: 'publish',
|
|
337
|
+
artifactId: input.artifactId,
|
|
338
|
+
bindingName: null,
|
|
339
|
+
policyRevision: input.policyRevision,
|
|
340
|
+
quotaBytes: prepared.entries.reduce((total, entry) => total + entry.bytes.byteLength, 0),
|
|
341
|
+
locks: { ...deps.locks, 'artifact-lifecycle': [
|
|
342
|
+
async (client) => { finalizationClient = client; },
|
|
343
|
+
...(deps.locks?.['artifact-lifecycle'] ?? []),
|
|
344
|
+
] },
|
|
345
|
+
provisional: async (client) => {
|
|
346
|
+
const unit = deps.createUnitWriter(client);
|
|
347
|
+
await unit.putRelease({
|
|
348
|
+
releaseId,
|
|
349
|
+
artifactId: input.artifactId,
|
|
350
|
+
manifestDigest: prepared.digest,
|
|
351
|
+
manifest: prepared.manifest,
|
|
352
|
+
state: 'ready',
|
|
353
|
+
createdAt: new Date(nowMs).toISOString(),
|
|
354
|
+
});
|
|
355
|
+
for (const staged of prepared.entries) {
|
|
356
|
+
await unit.registerBlob(input.storageScopeId, staged.entry.digest, staged.entry.size);
|
|
357
|
+
await unit.addReleaseRef(staged.entry.digest, releaseId);
|
|
358
|
+
addedRefs.push(staged.entry.digest);
|
|
359
|
+
}
|
|
360
|
+
await unit.promoteCurrentLatestWins(input.artifactId, releaseId);
|
|
361
|
+
if (source !== undefined)
|
|
362
|
+
await unit.putSource(input.artifactId, source);
|
|
363
|
+
},
|
|
364
|
+
guard: guard(() => {
|
|
365
|
+
if (finalizationClient === undefined)
|
|
366
|
+
throw new Error('publication guard needs the finalization client');
|
|
367
|
+
return finalizationClient;
|
|
368
|
+
}),
|
|
369
|
+
finishIntents: async (client) => {
|
|
370
|
+
await deps.createUnitWriter(client).completeIntent(intent.intentId);
|
|
371
|
+
},
|
|
372
|
+
noteAbandonedAttempt: deps.noteAbandoned === undefined
|
|
373
|
+
? undefined
|
|
374
|
+
: async (attemptId) => {
|
|
375
|
+
await deps.noteAbandoned?.(attemptId);
|
|
376
|
+
},
|
|
377
|
+
attemptId,
|
|
378
|
+
receiptData: { releaseId, manifest: prepared.manifest },
|
|
379
|
+
};
|
|
380
|
+
preservePins = true;
|
|
381
|
+
const outcome = await deps.coordinator.finalizePrepared(request);
|
|
382
|
+
preservePins = !outcome.ok && outcome.failure.commitState === 'unknown';
|
|
383
|
+
if (!outcome.ok && outcome.failure.commitState === 'not_committed') {
|
|
384
|
+
// Best-effort compensation for ref stores outside the unit transaction.
|
|
385
|
+
for (const digest of addedRefs) {
|
|
386
|
+
try {
|
|
387
|
+
await deps.refs.releaseRef(digest, releaseId);
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
/* already rolled back or never added */
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return outcome;
|
|
395
|
+
}
|
|
396
|
+
finally {
|
|
397
|
+
if (!preservePins) {
|
|
398
|
+
for (const digest of pinned) {
|
|
399
|
+
try {
|
|
400
|
+
await deps.refs.unpin(digest, pinReason);
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
if (!(error instanceof Error) || !/not pinned/.test(error.message))
|
|
404
|
+
throw error;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
}
|