@sentropic/artifacts 0.2.3 → 0.2.5
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/CHANGELOG.md +15 -0
- package/README.md +101 -1
- package/dist/adapters/postgres-activation-vectors.d.ts +31 -0
- package/dist/adapters/postgres-activation-vectors.js +178 -0
- package/dist/adapters/postgres-management.d.ts +4 -0
- package/dist/adapters/postgres-management.js +36 -0
- package/dist/adapters/postgres.js +19 -4
- package/dist/http.d.ts +9 -0
- package/dist/http.js +56 -7
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/mcp-core.d.ts +2 -2
- package/dist/mcp-core.js +11 -2
- package/dist/profile/index.js +7 -1
- package/dist/profile/schemas.js +11 -4
- package/dist/profile/validation.js +5 -1
- package/dist/server/access.d.ts +6 -0
- package/dist/server/command-identity.d.ts +3 -2
- package/dist/server/command-identity.js +6 -6
- package/dist/server/commit/coordinator.js +1 -1
- package/dist/server/failures.d.ts +5 -2
- package/dist/server/failures.js +4 -1
- package/dist/server/lifecycle.d.ts +2 -0
- package/dist/server/lifecycle.js +12 -0
- package/dist/server/management.d.ts +45 -0
- package/dist/server/management.js +79 -0
- package/dist/server/outcome-projection.js +4 -2
- package/dist/server/publication.d.ts +4 -10
- package/dist/server/publication.js +2 -2
- package/dist/server/source-read.d.ts +38 -0
- package/dist/server/source-read.js +68 -0
- package/dist/server/summary.d.ts +39 -0
- package/dist/server/summary.js +17 -0
- package/migrations/0004_terminal_controls.sql +47 -0
- package/package.json +18 -1
package/dist/mcp-core.js
CHANGED
|
@@ -6,7 +6,7 @@ export * from './profile/index.js';
|
|
|
6
6
|
export { createRadarKeyDerivation } from './server/command-identity.js';
|
|
7
7
|
export const ARTIFACTS_PROVIDER_ID = 'sentropic.artifacts';
|
|
8
8
|
export const ARTIFACTS_APP_ID = 'sentropic-artifacts';
|
|
9
|
-
export const ARTIFACTS_PROVIDER_VERSION = '0.2.
|
|
9
|
+
export const ARTIFACTS_PROVIDER_VERSION = '0.2.5';
|
|
10
10
|
export const ARTIFACTS_TOOL_NAMES = ARTIFACT_CAPABILITY_NAMES;
|
|
11
11
|
export const ARTIFACTS_TOOL_SPECS = ARTIFACTS_TOOL_NAMES.map((name) => ({ name,
|
|
12
12
|
description: `Artifacts ${name.slice('artifacts_'.length).replaceAll('_', ' ')}.`,
|
|
@@ -29,7 +29,7 @@ export function createArtifactsManifest(options) {
|
|
|
29
29
|
requiredScopes: [...scopes], requiredClaims: [], redactionClass: 'low',
|
|
30
30
|
mutability: spec.mutability, mutatesExternalSystem: spec.mutability !== 'read-only',
|
|
31
31
|
idempotency: profile.idempotency[spec.name] ?? { required: false },
|
|
32
|
-
gates: { requiresElicitation: false, requiresHumanConfirmation:
|
|
32
|
+
gates: { requiresElicitation: false, requiresHumanConfirmation: spec.name === 'artifacts_delete', requiresPrincipalGate: false },
|
|
33
33
|
})),
|
|
34
34
|
authz: { requiredClaims: [], scopes, tenantResolution: 'custom-resolver' },
|
|
35
35
|
// The existing manifest codec includes audit metadata, including for an empty tool set.
|
|
@@ -64,6 +64,15 @@ export function createArtifactsToolExecutor(deps) {
|
|
|
64
64
|
case 'artifacts_get':
|
|
65
65
|
request = { method: 'GET', path: `/meta/${args.artifactId}`, body: undefined };
|
|
66
66
|
break;
|
|
67
|
+
case 'artifacts_get_source':
|
|
68
|
+
request = { method: 'POST', path: '/get_source', body: args };
|
|
69
|
+
break;
|
|
70
|
+
case 'artifacts_set_access':
|
|
71
|
+
request = { method: 'POST', path: '/set_access', body: args };
|
|
72
|
+
break;
|
|
73
|
+
case 'artifacts_delete':
|
|
74
|
+
request = { method: 'POST', path: '/delete', body: args };
|
|
75
|
+
break;
|
|
67
76
|
default: throw new ArtifactProfileInputError(true);
|
|
68
77
|
}
|
|
69
78
|
return await deps.app.execute({ ...request, actorId: input.invocation.actorId,
|
package/dist/profile/index.js
CHANGED
|
@@ -79,7 +79,7 @@ export function compileArtifactsProfile(input) {
|
|
|
79
79
|
if (!Object.hasOwn(props, key) || !matchesSchema(value, props[key]))
|
|
80
80
|
throw new Error('profile/schema default mismatch');
|
|
81
81
|
}
|
|
82
|
-
if (
|
|
82
|
+
if (['artifacts_ensure', 'artifacts_publish', 'artifacts_commit_upload', 'artifacts_set_access', 'artifacts_delete'].includes(name)) {
|
|
83
83
|
idempotency[name] = { required: true, scope: 'principal' };
|
|
84
84
|
}
|
|
85
85
|
}
|
|
@@ -106,6 +106,12 @@ export function compileArtifactsProfile(input) {
|
|
|
106
106
|
name = 'artifacts_publish';
|
|
107
107
|
if (method === 'POST' && path === '/list')
|
|
108
108
|
name = 'artifacts_list';
|
|
109
|
+
if (method === 'POST' && path === '/get_source')
|
|
110
|
+
name = 'artifacts_get_source';
|
|
111
|
+
if (method === 'POST' && path === '/set_access')
|
|
112
|
+
name = 'artifacts_set_access';
|
|
113
|
+
if (method === 'POST' && path === '/delete')
|
|
114
|
+
name = 'artifacts_delete';
|
|
109
115
|
if (method === 'GET' && path.startsWith('/meta/') && isArtifactId(path.slice(6)))
|
|
110
116
|
name = 'artifacts_get';
|
|
111
117
|
return name && selectedCapabilities.includes(name) ? name : undefined;
|
package/dist/profile/schemas.js
CHANGED
|
@@ -14,6 +14,8 @@ export function assertCompleteOperation(config) {
|
|
|
14
14
|
'maxDecodedFileBytes', 'maxDecodedTotalBytes'], defaults: ['profile'] },
|
|
15
15
|
list: { limits: ['listLimitMin', 'listLimitMax'], defaults: ['limit', 'offset'] },
|
|
16
16
|
commit_upload: { limits: ['idempotencyKeyMaxUnits'], defaults: ['profile'] },
|
|
17
|
+
set_access: { limits: ['idempotencyKeyMaxUnits'], defaults: [] },
|
|
18
|
+
delete: { limits: ['idempotencyKeyMaxUnits'], defaults: [] },
|
|
17
19
|
};
|
|
18
20
|
const fields = required[config.operation];
|
|
19
21
|
if (fields && (fields.limits.some((key) => !Object.hasOwn(config.limits, key)) ||
|
|
@@ -58,18 +60,23 @@ export function capabilitySchema(name, profile) {
|
|
|
58
60
|
case 'list': return obj({ limit: { ...integer(limits.listLimitMin, limits.listLimitMax), default: config.defaults.limit },
|
|
59
61
|
offset: { ...integer(0), default: config.defaults.offset } });
|
|
60
62
|
case 'get':
|
|
61
|
-
case 'get_source':
|
|
62
63
|
case 'fork':
|
|
63
64
|
case 'renew':
|
|
64
|
-
case 'delete':
|
|
65
65
|
case 'restore': return idOnly;
|
|
66
|
+
case 'get_source': return obj({ artifactId: id, releaseId: id,
|
|
67
|
+
paths: { type: 'array', minItems: 1, maxItems: 32, items: { ...str, maxLength: profile.rules.pathMaxBytes } } }, ['artifactId']);
|
|
68
|
+
case 'delete': return obj({ artifactId: id, expectedCurrentReleaseId: { ...id, type: ['string', 'null'] },
|
|
69
|
+
idempotencyKey: key, confirmationRef: { ...str, maxLength: 4096 } }, ['artifactId', 'expectedCurrentReleaseId', 'idempotencyKey']);
|
|
66
70
|
case 'get_template': return obj({ templateId: str }, ['templateId']);
|
|
67
71
|
case 'get_api_guide': return obj({ version: str });
|
|
68
72
|
case 'create_upload': return obj({ artifactId: id, mimeType: str, maxSize: integer(1) }, ['artifactId', 'mimeType']);
|
|
69
73
|
case 'commit_upload': return obj({ artifactId: id, uploadId: str, path: str, mimeType: str,
|
|
70
74
|
digest: str, size: integer(0), profile: primitiveRecord, idempotencyKey: key }, ['artifactId', 'uploadId', 'path', 'mimeType', 'digest', 'size', 'idempotencyKey']);
|
|
71
|
-
case 'set_access': return obj({ artifactId: id, action: { type: 'string', enum: ['grant', 'revoke'
|
|
72
|
-
|
|
75
|
+
case 'set_access': return { ...obj({ artifactId: id, action: { type: 'string', enum: ['grant', 'revoke'] },
|
|
76
|
+
principalId: { ...str, maxLength: 256 }, grant: { type: 'string', enum: ['read'] }, idempotencyKey: key }, ['artifactId', 'action', 'principalId', 'idempotencyKey']), oneOf: [
|
|
77
|
+
{ properties: { action: { enum: ['grant'] } }, required: ['grant'] },
|
|
78
|
+
{ properties: { action: { enum: ['revoke'] } }, not: { required: ['grant'] } },
|
|
79
|
+
] };
|
|
73
80
|
case 'promote': return obj({ artifactId: id, releaseId: id, expectedCurrent: id }, ['artifactId', 'releaseId', 'expectedCurrent']);
|
|
74
81
|
default: throw new Error(`unsupported operation profile: ${operation}`);
|
|
75
82
|
}
|
|
@@ -78,7 +78,7 @@ export function normalizeArguments(profile, name, input, schema, envelopeKey) {
|
|
|
78
78
|
const operation = name.slice('artifacts_'.length);
|
|
79
79
|
const { defaults, limits } = operationProfile(profile, operation);
|
|
80
80
|
const normalized = { ...copyJson(defaults), ...args };
|
|
81
|
-
if (
|
|
81
|
+
if (['ensure', 'publish', 'commit_upload', 'set_access', 'delete'].includes(operation)) {
|
|
82
82
|
const key = args.idempotencyKey;
|
|
83
83
|
requireInput(typeof key === 'string' && key.trim().length > 0 && key.length <= limits.idempotencyKeyMaxUnits);
|
|
84
84
|
requireInput(envelopeKey === undefined || envelopeKey === key);
|
|
@@ -87,6 +87,10 @@ export function normalizeArguments(profile, name, input, schema, envelopeKey) {
|
|
|
87
87
|
requireInput(envelopeKey === undefined);
|
|
88
88
|
if (Object.hasOwn(args, 'artifactId'))
|
|
89
89
|
requireInput(isArtifactId(args.artifactId));
|
|
90
|
+
if (operation === 'get_source' && args.paths !== undefined) {
|
|
91
|
+
const paths = args.paths;
|
|
92
|
+
requireInput(new Set(paths).size === paths.length && paths.every((path) => Buffer.byteLength(path, 'utf8') <= profile.rules.pathMaxBytes));
|
|
93
|
+
}
|
|
90
94
|
if (operation === 'publish') {
|
|
91
95
|
const paths = new Set();
|
|
92
96
|
let total = 0;
|
package/dist/server/access.d.ts
CHANGED
|
@@ -16,6 +16,12 @@ export interface GrantRecord {
|
|
|
16
16
|
grant: AccessGrant;
|
|
17
17
|
}
|
|
18
18
|
export interface ArtifactAcl {
|
|
19
|
+
bindingName?: string;
|
|
20
|
+
createdAt?: string;
|
|
21
|
+
updatedAt?: string | null;
|
|
22
|
+
title?: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
icon?: string | null;
|
|
19
25
|
ownerId: string;
|
|
20
26
|
/** Physical scope of the artifact (set at creation; read by publish). */
|
|
21
27
|
storageScopeId?: string;
|
|
@@ -8,13 +8,14 @@ export type CallerIdempotencyKey = string & {
|
|
|
8
8
|
export type InternalIdempotencyKey = string & {
|
|
9
9
|
readonly __brand: 'internal-key';
|
|
10
10
|
};
|
|
11
|
+
export type ArtifactCommandOperation = 'ensure' | 'publish' | 'set_access' | 'delete';
|
|
11
12
|
export interface CommandIdentity {
|
|
12
13
|
readonly internalKey: InternalIdempotencyKey;
|
|
13
14
|
readonly commandHash: string;
|
|
14
15
|
readonly codecVersion: typeof COMMAND_CODEC_VERSION;
|
|
15
16
|
}
|
|
16
17
|
export interface EffectiveArtifactCommand {
|
|
17
|
-
readonly operation:
|
|
18
|
+
readonly operation: ArtifactCommandOperation;
|
|
18
19
|
readonly scopeBinding: string;
|
|
19
20
|
readonly payload: Readonly<Record<string, JsonValue>>;
|
|
20
21
|
readonly provenance: {
|
|
@@ -29,7 +30,7 @@ export interface ArtifactCommandAuthority {
|
|
|
29
30
|
export interface ArtifactKeyDerivationBinding {
|
|
30
31
|
readonly algorithm: string;
|
|
31
32
|
derive(input: ArtifactCommandAuthority & {
|
|
32
|
-
readonly operation:
|
|
33
|
+
readonly operation: ArtifactCommandOperation;
|
|
33
34
|
readonly objectId: string;
|
|
34
35
|
readonly callerKey: CallerIdempotencyKey;
|
|
35
36
|
}): InternalIdempotencyKey;
|
|
@@ -20,7 +20,7 @@ export function createRadarKeyDerivation(config) {
|
|
|
20
20
|
if (other === actor || !other.startsWith(`${actor}|`))
|
|
21
21
|
continue;
|
|
22
22
|
const [operation, objectId] = other.slice(actor.length + 1).split('|');
|
|
23
|
-
if ((operation === 'ensure' && objectId === '') || (
|
|
23
|
+
if ((operation === 'ensure' && objectId === '') || (['publish', 'set_access', 'delete'].includes(operation) && isArtifactId(objectId))) {
|
|
24
24
|
throw new Error('ambiguous Radar actor domain');
|
|
25
25
|
}
|
|
26
26
|
}
|
|
@@ -30,7 +30,7 @@ export function createRadarKeyDerivation(config) {
|
|
|
30
30
|
derive(input) {
|
|
31
31
|
const { storageScopeId, actorId, operation, objectId, callerKey } = input;
|
|
32
32
|
if (storageScopeId !== scope || !actors.has(actorId) ||
|
|
33
|
-
|
|
33
|
+
!['ensure', 'publish', 'set_access', 'delete'].includes(operation) ||
|
|
34
34
|
(operation === 'ensure' ? objectId !== '' : !isArtifactId(objectId)))
|
|
35
35
|
throw new ArtifactProfileInputError();
|
|
36
36
|
assertWellFormed(callerKey);
|
|
@@ -48,10 +48,10 @@ export function createArtifactCommand(profile, name, args, authority, derivation
|
|
|
48
48
|
if (derivation.algorithm !== profile.profile.rules.keyDerivationAlgorithm)
|
|
49
49
|
throw new Error('profile/derivation mismatch');
|
|
50
50
|
const normalized = profile.validate(name, args, envelopeKey);
|
|
51
|
-
if (
|
|
51
|
+
if (!['artifacts_ensure', 'artifacts_publish', 'artifacts_set_access', 'artifacts_delete'].includes(name))
|
|
52
52
|
throw new ArtifactProfileInputError(true);
|
|
53
|
-
const operation = name
|
|
54
|
-
const { idempotencyKey, ...payload } = normalized;
|
|
53
|
+
const operation = name.slice('artifacts_'.length);
|
|
54
|
+
const { idempotencyKey, confirmationRef: _proof, ...payload } = normalized;
|
|
55
55
|
const scopeBinding = authority.storageScopeId;
|
|
56
56
|
if (typeof scopeBinding !== 'string' || typeof authority.actorId !== 'string')
|
|
57
57
|
throw new ArtifactProfileInputError();
|
|
@@ -62,7 +62,7 @@ export function createArtifactCommand(profile, name, args, authority, derivation
|
|
|
62
62
|
const provenance = { kind: 'inline-original' };
|
|
63
63
|
const effective = copyJson({ operation, scopeBinding, payload: { ...payload, provenance } });
|
|
64
64
|
const internalKey = derivation.derive({ storageScopeId: scopeBinding, actorId: authority.actorId, operation,
|
|
65
|
-
objectId: operation === '
|
|
65
|
+
objectId: operation === 'ensure' ? '' : normalized.artifactId,
|
|
66
66
|
callerKey: idempotencyKey });
|
|
67
67
|
if (!/^[0-9a-f]{64}$/.test(internalKey) || !derivation.algorithm)
|
|
68
68
|
throw new Error('invalid key derivation binding');
|
|
@@ -285,7 +285,7 @@ export function createArtifactCommitCoordinator(options) {
|
|
|
285
285
|
(input.storageScopeId !== request.scopeId || input.bytes !== bytes)) ||
|
|
286
286
|
('actorId' in input && input.actorId !== request.actorId))
|
|
287
287
|
return Promise.resolve({ reserved: false });
|
|
288
|
-
if (purpose === 'replay')
|
|
288
|
+
if (purpose === 'replay' || ['set_access', 'delete'].includes(request.operation))
|
|
289
289
|
return Promise.resolve({ reserved: true });
|
|
290
290
|
return reservation ??= raw.reserveQuota({ actorId: request.actorId, operation: request.operation,
|
|
291
291
|
storageScopeId: request.scopeId, bytes });
|
|
@@ -4,9 +4,10 @@ export type ArtifactCommitState = 'not_committed' | 'committed' | 'unknown';
|
|
|
4
4
|
export type ArtifactInternalKey = string & {
|
|
5
5
|
readonly __brand: 'internal-key';
|
|
6
6
|
};
|
|
7
|
-
export type ArtifactErrorKind = 'provider_unavailable' | 'command_conflict' | 'pointer_conflict' | 'unauthorized_at_commit' | 'capability_unselected' | 'admission_unavailable' | 'invalid_request' | 'anonymous' | 'access_denied' | 'not_found' | 'gone' | 'unprocessable' | 'verification_failed';
|
|
7
|
+
export type ArtifactErrorKind = 'provider_unavailable' | 'command_conflict' | 'pointer_conflict' | 'unauthorized_at_commit' | 'capability_unselected' | 'admission_unavailable' | 'invalid_request' | 'anonymous' | 'access_denied' | 'not_found' | 'gone' | 'release_unavailable' | 'limit_exceeded' | 'unprocessable' | 'verification_failed';
|
|
8
8
|
export type ArtifactFailureCategory = ArtifactFailure['category'];
|
|
9
9
|
interface ArtifactFailureFields {
|
|
10
|
+
readonly details?: Readonly<Record<string, unknown>>;
|
|
10
11
|
readonly variant?: string;
|
|
11
12
|
readonly httpStatus: number;
|
|
12
13
|
readonly publicCode: string;
|
|
@@ -24,7 +25,7 @@ export interface ArtifactOperationalFailure extends ArtifactFailureFields {
|
|
|
24
25
|
}
|
|
25
26
|
export interface ArtifactOrdinaryFailure extends Omit<ArtifactFailureFields, 'variant' | 'retriable'> {
|
|
26
27
|
readonly kind: 'ordinary';
|
|
27
|
-
readonly category: 'invalid_request' | 'not_found' | 'gone' | 'unprocessable' | 'verification_failed';
|
|
28
|
+
readonly category: 'invalid_request' | 'not_found' | 'gone' | 'release_unavailable' | 'limit_exceeded' | 'unprocessable' | 'verification_failed';
|
|
28
29
|
readonly retriable: false;
|
|
29
30
|
}
|
|
30
31
|
/** Entry authority outcomes are separate from ordinary and operational failures. */
|
|
@@ -65,10 +66,12 @@ export declare class ArtifactOperationError extends Error {
|
|
|
65
66
|
readonly details: {
|
|
66
67
|
readonly cause?: unknown;
|
|
67
68
|
readonly refusal?: ArtifactPublicRefusal;
|
|
69
|
+
readonly publicDetails?: Readonly<Record<string, unknown>>;
|
|
68
70
|
};
|
|
69
71
|
constructor(kind: ArtifactErrorKind, details?: {
|
|
70
72
|
readonly cause?: unknown;
|
|
71
73
|
readonly refusal?: ArtifactPublicRefusal;
|
|
74
|
+
readonly publicDetails?: Readonly<Record<string, unknown>>;
|
|
72
75
|
});
|
|
73
76
|
}
|
|
74
77
|
/** Mint only at verified per-key row contention, using the full scoped operation key. */
|
package/dist/server/failures.js
CHANGED
|
@@ -28,7 +28,7 @@ const UNAVAILABLE_CODES = new Set([
|
|
|
28
28
|
const TIMEOUT_NAMES = new Set(['TimeoutError', 'RequestTimeout', 'RequestTimeoutError']);
|
|
29
29
|
const DOMAIN_STATUS = {
|
|
30
30
|
invalid_request: 400, anonymous: 401, access_denied: 403,
|
|
31
|
-
not_found: 404, gone: 410, unprocessable: 422, verification_failed: 422,
|
|
31
|
+
not_found: 404, gone: 410, release_unavailable: 410, limit_exceeded: 413, unprocessable: 422, verification_failed: 422,
|
|
32
32
|
};
|
|
33
33
|
function field(value, name) {
|
|
34
34
|
if (typeof value !== 'object' || value === null)
|
|
@@ -126,6 +126,9 @@ export function classifyArtifactFailure(cause, context, mapHostError) {
|
|
|
126
126
|
projection = unavailable();
|
|
127
127
|
const failure = {
|
|
128
128
|
...projection, invocationId: context.invocationId, operation: context.operation,
|
|
129
|
+
...(cause instanceof ArtifactOperationError && cause.details.publicDetails &&
|
|
130
|
+
['confirmation_required', 'confirmation_invalid', 'confirmation_expired', 'confirmation_stale', 'release_unavailable'].includes(projection.publicCode)
|
|
131
|
+
? { details: cause.details.publicDetails } : {}),
|
|
129
132
|
phase: context.phase, commitState: context.commitState, cause,
|
|
130
133
|
};
|
|
131
134
|
Object.defineProperty(failure, 'cause', { value: cause, enumerable: false });
|
|
@@ -12,6 +12,8 @@ export type ArtifactState = 'active' | 'withdrawn' | 'deleted' | 'purged';
|
|
|
12
12
|
/** Release states use `ready` (not `active`) to match the releases table. */
|
|
13
13
|
export type ReleaseState = 'ready' | 'retired' | 'purged';
|
|
14
14
|
export interface LifecycleStore {
|
|
15
|
+
isTerminal(artifactId: string): Promise<boolean>;
|
|
16
|
+
markTerminal(artifactId: string): Promise<void>;
|
|
15
17
|
register(artifactId: string, ownerId: string): Promise<void>;
|
|
16
18
|
getOwner(artifactId: string): Promise<string | undefined>;
|
|
17
19
|
getArtifactState(artifactId: string): Promise<ArtifactState>;
|
package/dist/server/lifecycle.js
CHANGED
|
@@ -16,6 +16,14 @@ export function createMemoryLifecycleStore() {
|
|
|
16
16
|
const artifacts = new Map();
|
|
17
17
|
const releaseStates = new Map();
|
|
18
18
|
const store = {
|
|
19
|
+
async isTerminal(id) { return artifacts.get(id)?.terminal === true; },
|
|
20
|
+
async markTerminal(id) {
|
|
21
|
+
const entry = artifacts.get(id);
|
|
22
|
+
if (!entry)
|
|
23
|
+
throw new Error('unknown artifact');
|
|
24
|
+
entry.terminal = true;
|
|
25
|
+
entry.state = 'deleted';
|
|
26
|
+
},
|
|
19
27
|
async register(artifactId, ownerId) {
|
|
20
28
|
const entry = artifacts.get(artifactId);
|
|
21
29
|
if (entry !== undefined) {
|
|
@@ -35,6 +43,8 @@ export function createMemoryLifecycleStore() {
|
|
|
35
43
|
const entry = artifacts.get(artifactId);
|
|
36
44
|
if (entry === undefined)
|
|
37
45
|
throw new Error(`unknown artifact: ${artifactId}`);
|
|
46
|
+
if (entry.terminal && state !== 'deleted' && state !== 'purged')
|
|
47
|
+
throw new Error('terminal artifact');
|
|
38
48
|
entry.state = state;
|
|
39
49
|
},
|
|
40
50
|
async getRenewedAt(artifactId) {
|
|
@@ -108,6 +118,8 @@ export function createLifecycleService(store, clock = () => new Date().toISOStri
|
|
|
108
118
|
},
|
|
109
119
|
async restore(artifactId, actorId) {
|
|
110
120
|
await assertOwner(artifactId, actorId);
|
|
121
|
+
if (await store.isTerminal(artifactId))
|
|
122
|
+
throw new Error('cannot restore terminal artifact');
|
|
111
123
|
const state = await store.getArtifactState(artifactId);
|
|
112
124
|
if (state === 'purged')
|
|
113
125
|
throw new Error(`cannot restore purged artifact: terminal ${artifactId}`);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ArtifactAcl } from './access.js';
|
|
2
|
+
import type { ArtifactInvocationContext, ArtifactCommitCoordinator, FinalizeTxClient } from './commit/types.js';
|
|
3
|
+
import type { EffectiveArtifactCommand } from './command-identity.js';
|
|
4
|
+
import { ArtifactOperationError, type ArtifactOutcome } from './failures.js';
|
|
5
|
+
export interface ManagementTarget {
|
|
6
|
+
acl: ArtifactAcl;
|
|
7
|
+
state: string;
|
|
8
|
+
terminal: boolean;
|
|
9
|
+
currentReleaseId: string | null;
|
|
10
|
+
}
|
|
11
|
+
export interface ManagementUnit {
|
|
12
|
+
lock(artifactId: string): Promise<void>;
|
|
13
|
+
read(artifactId: string): Promise<ManagementTarget | undefined>;
|
|
14
|
+
setAccess(artifactId: string, principalId: string, action: 'grant' | 'revoke', updatedAt: string): Promise<{
|
|
15
|
+
grants: unknown;
|
|
16
|
+
updatedAt: string | null;
|
|
17
|
+
}>;
|
|
18
|
+
delete(artifactId: string, deletedAt: string): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
export interface DeleteConfirmationContext {
|
|
21
|
+
actorId: string;
|
|
22
|
+
storageScopeId: string;
|
|
23
|
+
artifactId: string;
|
|
24
|
+
expectedCurrentReleaseId: string | null;
|
|
25
|
+
commandHash: string;
|
|
26
|
+
internalKey: string;
|
|
27
|
+
}
|
|
28
|
+
/** Host challenges require trusted human approval; consume on the domain transaction. */
|
|
29
|
+
export interface ArtifactConfirmationPort {
|
|
30
|
+
request(context: DeleteConfirmationContext): Promise<{
|
|
31
|
+
confirmationRef: string;
|
|
32
|
+
managementUrl: string;
|
|
33
|
+
expiresAt: string;
|
|
34
|
+
}>;
|
|
35
|
+
consume(client: FinalizeTxClient, context: DeleteConfirmationContext, confirmationRef: string): Promise<'valid' | 'invalid' | 'expired' | 'stale'>;
|
|
36
|
+
}
|
|
37
|
+
export declare function confirmationFailure(code: string, details?: Record<string, unknown>): ArtifactOperationError;
|
|
38
|
+
/** Targeted writes and proof consumption share the existing coordinator receipt transaction. */
|
|
39
|
+
export declare function manageArtifact(deps: {
|
|
40
|
+
coordinator: ArtifactCommitCoordinator;
|
|
41
|
+
createUnit(client: FinalizeTxClient): ManagementUnit;
|
|
42
|
+
readPolicyRevision(): Promise<string>;
|
|
43
|
+
isApprovedPrincipal(principalId: string): boolean | Promise<boolean>;
|
|
44
|
+
confirmation?: ArtifactConfirmationPort;
|
|
45
|
+
}, invocation: ArtifactInvocationContext, command: EffectiveArtifactCommand, proof?: string): Promise<ArtifactOutcome<unknown>>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { ArtifactOperationError } from './failures.js';
|
|
2
|
+
export function confirmationFailure(code, details) {
|
|
3
|
+
return new ArtifactOperationError('unauthorized_at_commit', { publicDetails: details,
|
|
4
|
+
refusal: { allowed: false, reason: 'denied', publicCode: code,
|
|
5
|
+
status: code === 'confirmation_invalid' ? 403 : 409, retriable: false } });
|
|
6
|
+
}
|
|
7
|
+
function refused(code) {
|
|
8
|
+
return new ArtifactOperationError('unauthorized_at_commit', { refusal: { allowed: false,
|
|
9
|
+
reason: 'denied', status: code === 'gone' ? 410 : code === 'not_found' ? 404 : 403,
|
|
10
|
+
publicCode: code, retriable: false } });
|
|
11
|
+
}
|
|
12
|
+
/** Targeted writes and proof consumption share the existing coordinator receipt transaction. */
|
|
13
|
+
export async function manageArtifact(deps, invocation, command, proof) {
|
|
14
|
+
const artifactId = command.payload.artifactId;
|
|
15
|
+
const deleting = command.operation === 'delete';
|
|
16
|
+
let unit;
|
|
17
|
+
const receipt = {};
|
|
18
|
+
const context = { actorId: invocation.actorId, storageScopeId: invocation.storageScopeId,
|
|
19
|
+
artifactId, expectedCurrentReleaseId: command.payload.expectedCurrentReleaseId,
|
|
20
|
+
commandHash: command.identity.commandHash, internalKey: command.identity.internalKey };
|
|
21
|
+
const owned = async () => {
|
|
22
|
+
const target = await unit.read(artifactId);
|
|
23
|
+
if (!target || target.acl.storageScopeId !== invocation.storageScopeId)
|
|
24
|
+
throw refused('not_found');
|
|
25
|
+
if (target.acl.ownerId !== invocation.actorId)
|
|
26
|
+
throw refused('access_denied');
|
|
27
|
+
return target;
|
|
28
|
+
};
|
|
29
|
+
const result = await deps.coordinator.finalizePrepared({
|
|
30
|
+
...command.identity, scopeId: invocation.storageScopeId, actorId: invocation.actorId,
|
|
31
|
+
invocationId: invocation.invocationId, operation: command.operation, artifactId,
|
|
32
|
+
policyRevision: await deps.readPolicyRevision(), receiptData: receipt,
|
|
33
|
+
locks: { 'artifact-lifecycle': [async (client) => {
|
|
34
|
+
unit = deps.createUnit(client);
|
|
35
|
+
await unit.lock(artifactId);
|
|
36
|
+
await owned();
|
|
37
|
+
}] },
|
|
38
|
+
provisional: async (client) => {
|
|
39
|
+
const target = await owned();
|
|
40
|
+
if (target.terminal || target.state === 'deleted' || target.state === 'purged')
|
|
41
|
+
throw refused('gone');
|
|
42
|
+
const now = new Date().toISOString();
|
|
43
|
+
if (deleting) {
|
|
44
|
+
if (target.currentReleaseId !== context.expectedCurrentReleaseId) {
|
|
45
|
+
if (proof)
|
|
46
|
+
throw confirmationFailure('confirmation_stale');
|
|
47
|
+
throw new ArtifactOperationError('pointer_conflict');
|
|
48
|
+
}
|
|
49
|
+
if (!deps.confirmation)
|
|
50
|
+
throw new ArtifactOperationError('provider_unavailable');
|
|
51
|
+
if (!proof)
|
|
52
|
+
throw confirmationFailure('confirmation_required', await deps.confirmation.request(context));
|
|
53
|
+
const verdict = await deps.confirmation.consume(client, context, proof);
|
|
54
|
+
if (verdict !== 'valid')
|
|
55
|
+
throw confirmationFailure(`confirmation_${verdict}`);
|
|
56
|
+
await unit.delete(artifactId, now);
|
|
57
|
+
Object.assign(receipt, { artifactId, state: 'deleted', terminal: true, deletedAt: now, purgeState: 'pending' });
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
const principalId = command.payload.principalId;
|
|
61
|
+
if (command.payload.action === 'grant' && !await deps.isApprovedPrincipal(principalId))
|
|
62
|
+
throw refused('access_denied');
|
|
63
|
+
const output = await unit.setAccess(artifactId, principalId, command.payload.action, now);
|
|
64
|
+
Object.assign(receipt, { artifactId, isPublic: false, ...output });
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
guard: async (ctx, tx) => {
|
|
68
|
+
const target = await owned();
|
|
69
|
+
if (!deleting && (target.terminal || target.state === 'deleted' || target.state === 'purged'))
|
|
70
|
+
throw refused('gone');
|
|
71
|
+
if (!deleting && command.payload.action === 'grant' && !await deps.isApprovedPrincipal(command.payload.principalId))
|
|
72
|
+
throw refused('access_denied');
|
|
73
|
+
return invocation.authorizeCommit(ctx, tx);
|
|
74
|
+
},
|
|
75
|
+
validateReplayReceipt: (stored) => stored.receipt?.artifactId === artifactId,
|
|
76
|
+
});
|
|
77
|
+
return result.ok ? { ...result, result: result.result.receipt }
|
|
78
|
+
: { ok: false, failure: { ...result.failure, kind: 'operational' } };
|
|
79
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export function projectArtifactHttpOutcome(outcome) {
|
|
2
2
|
if (outcome.ok)
|
|
3
3
|
return outcome.result;
|
|
4
|
-
return { status: outcome.failure.httpStatus, body: { error: outcome.failure.publicCode
|
|
4
|
+
return { status: outcome.failure.httpStatus, body: { error: outcome.failure.publicCode,
|
|
5
|
+
...(outcome.failure.details ? { details: outcome.failure.details } : {}) } };
|
|
5
6
|
}
|
|
6
7
|
/** Feed this application result to mcp-platform's existing result codec. No second wire protocol. */
|
|
7
8
|
export function projectArtifactMcpOutcome(outcome, auditId) {
|
|
@@ -10,7 +11,8 @@ export function projectArtifactMcpOutcome(outcome, auditId) {
|
|
|
10
11
|
const { publicCode, retriable } = outcome.failure;
|
|
11
12
|
return {
|
|
12
13
|
ok: false, auditId, redactionClass: 'low',
|
|
13
|
-
error: { code: publicCode, message: publicCode, retriable
|
|
14
|
+
error: { code: publicCode, message: publicCode, retriable,
|
|
15
|
+
...(outcome.failure.details ? { details: outcome.failure.details } : {}) },
|
|
14
16
|
};
|
|
15
17
|
}
|
|
16
18
|
/**
|
|
@@ -4,7 +4,7 @@ import type { IdempotencyStore } from './idempotency.js';
|
|
|
4
4
|
import { type SourceRef, type SourceStore } from './sources.js';
|
|
5
5
|
import { type PublicationLimits } from './publication-limits.js';
|
|
6
6
|
import type { ArtifactAcl } from './access.js';
|
|
7
|
-
import type { ArtifactState } from './lifecycle.js';
|
|
7
|
+
import type { ArtifactState, ReleaseState } from './lifecycle.js';
|
|
8
8
|
import { type RefcountStore } from './gc.js';
|
|
9
9
|
import type { CommandIdentity } from './command-identity.js';
|
|
10
10
|
import type { AuthorizeCommit, CommitLockFn, ArtifactCommitCoordinator, FinalizeResult, FinalizeTxClient } from './commit/types.js';
|
|
@@ -14,7 +14,7 @@ export interface ReleaseRecord {
|
|
|
14
14
|
artifactId: string;
|
|
15
15
|
manifestDigest: string;
|
|
16
16
|
manifest: string;
|
|
17
|
-
state:
|
|
17
|
+
state: ReleaseState;
|
|
18
18
|
createdAt: string;
|
|
19
19
|
}
|
|
20
20
|
export interface PublicationStateStore {
|
|
@@ -61,14 +61,8 @@ export interface PublicationService {
|
|
|
61
61
|
readonly limits?: Readonly<PublicationLimits>;
|
|
62
62
|
/** The host source store participates in the idempotent commit when a source is supplied. */
|
|
63
63
|
publish(input: PublishInput, sources?: SourceStore): Promise<PublishResult>;
|
|
64
|
-
getCurrent(artifactId: string): Promise<
|
|
65
|
-
|
|
66
|
-
manifest: string;
|
|
67
|
-
} | undefined>;
|
|
68
|
-
getRelease(releaseId: string): Promise<{
|
|
69
|
-
releaseId: string;
|
|
70
|
-
manifest: string;
|
|
71
|
-
} | undefined>;
|
|
64
|
+
getCurrent(artifactId: string): Promise<ReleaseRecord | undefined>;
|
|
65
|
+
getRelease(releaseId: string): Promise<ReleaseRecord | undefined>;
|
|
72
66
|
}
|
|
73
67
|
/** Create a publication service over injected stores (no driver imports). */
|
|
74
68
|
export declare function createPublicationService(deps: {
|
|
@@ -168,13 +168,13 @@ export function createPublicationService(deps) {
|
|
|
168
168
|
const release = await deps.state.getRelease(releaseId);
|
|
169
169
|
if (release === undefined)
|
|
170
170
|
return undefined;
|
|
171
|
-
return
|
|
171
|
+
return release;
|
|
172
172
|
},
|
|
173
173
|
async getRelease(releaseId) {
|
|
174
174
|
const release = await deps.state.getRelease(releaseId);
|
|
175
175
|
if (release === undefined)
|
|
176
176
|
return undefined;
|
|
177
|
-
return
|
|
177
|
+
return release;
|
|
178
178
|
},
|
|
179
179
|
};
|
|
180
180
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { SourceRef } from './sources.js';
|
|
2
|
+
import type { PublicationService } from './publication.js';
|
|
3
|
+
export interface SourceReadPorts {
|
|
4
|
+
storageScopeId?: string;
|
|
5
|
+
authorize(): Promise<void>;
|
|
6
|
+
getCurrent: PublicationService['getCurrent'];
|
|
7
|
+
getRelease: PublicationService['getRelease'];
|
|
8
|
+
getSource(artifactId: string): Promise<SourceRef | undefined>;
|
|
9
|
+
getBlob(key: string): Promise<Uint8Array | undefined>;
|
|
10
|
+
urls?: (artifactId: string, releaseId: string) => {
|
|
11
|
+
stableUrl: string;
|
|
12
|
+
pinnedUrl: string;
|
|
13
|
+
};
|
|
14
|
+
maxFiles: number;
|
|
15
|
+
maxTotalBytes: number;
|
|
16
|
+
}
|
|
17
|
+
export interface SourceReadInput {
|
|
18
|
+
artifactId: string;
|
|
19
|
+
releaseId?: string;
|
|
20
|
+
paths?: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
/** Read author bytes only. Provenance never substitutes for a stored snapshot. */
|
|
23
|
+
export declare function readArtifactSource(ports: SourceReadPorts, input: SourceReadInput): Promise<{
|
|
24
|
+
stableUrl?: string | undefined;
|
|
25
|
+
pinnedUrl?: string | undefined;
|
|
26
|
+
artifactId: string;
|
|
27
|
+
releaseId: string;
|
|
28
|
+
digest: string;
|
|
29
|
+
manifest: string;
|
|
30
|
+
files: {
|
|
31
|
+
bytes: number[];
|
|
32
|
+
path: string;
|
|
33
|
+
mimeType: string;
|
|
34
|
+
digest: string;
|
|
35
|
+
size: number;
|
|
36
|
+
}[];
|
|
37
|
+
sourceRef: SourceRef | null;
|
|
38
|
+
}>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { parseManifest } from '../codecs.js';
|
|
3
|
+
import { ArtifactOperationError } from './failures.js';
|
|
4
|
+
/** Read author bytes only. Provenance never substitutes for a stored snapshot. */
|
|
5
|
+
export async function readArtifactSource(ports, input) {
|
|
6
|
+
await ports.authorize();
|
|
7
|
+
const release = input.releaseId === undefined ? await ports.getCurrent(input.artifactId)
|
|
8
|
+
: await ports.getRelease(input.releaseId);
|
|
9
|
+
if (!release)
|
|
10
|
+
throw input.releaseId ? new ArtifactOperationError('not_found')
|
|
11
|
+
: new ArtifactOperationError('release_unavailable', { publicDetails: { reason: 'unpublished' } });
|
|
12
|
+
if ((input.releaseId !== undefined && release.releaseId !== input.releaseId) ||
|
|
13
|
+
release.artifactId !== input.artifactId)
|
|
14
|
+
throw new ArtifactOperationError('not_found');
|
|
15
|
+
let manifest;
|
|
16
|
+
try {
|
|
17
|
+
manifest = parseManifest(release.manifest);
|
|
18
|
+
}
|
|
19
|
+
catch (cause) {
|
|
20
|
+
throw new ArtifactOperationError('verification_failed', { cause });
|
|
21
|
+
}
|
|
22
|
+
if (manifest.artifactId !== input.artifactId)
|
|
23
|
+
throw new ArtifactOperationError('not_found');
|
|
24
|
+
if (ports.storageScopeId !== undefined && manifest.storageScopeId !== ports.storageScopeId)
|
|
25
|
+
throw new ArtifactOperationError('verification_failed');
|
|
26
|
+
if (release.manifestDigest !== manifest.digest)
|
|
27
|
+
throw new ArtifactOperationError('verification_failed');
|
|
28
|
+
if (release.state === 'purged')
|
|
29
|
+
throw new ArtifactOperationError('release_unavailable', { publicDetails: { reason: 'purged' } });
|
|
30
|
+
if (!manifest.digest || !Array.isArray(manifest.files) || manifest.files.length > ports.maxFiles) {
|
|
31
|
+
throw new ArtifactOperationError('verification_failed');
|
|
32
|
+
}
|
|
33
|
+
const entries = [];
|
|
34
|
+
const paths = new Set();
|
|
35
|
+
let total = 0;
|
|
36
|
+
for (const value of manifest.files) {
|
|
37
|
+
const file = value;
|
|
38
|
+
if (!file || typeof file.path !== 'string' || typeof file.mimeType !== 'string' ||
|
|
39
|
+
!/^sha256:[0-9a-f]{64}$/.test(file.digest) || !Number.isSafeInteger(file.size) || file.size < 0 ||
|
|
40
|
+
paths.has(file.path))
|
|
41
|
+
throw new ArtifactOperationError('verification_failed');
|
|
42
|
+
total += file.size;
|
|
43
|
+
if (total > ports.maxTotalBytes)
|
|
44
|
+
throw new ArtifactOperationError('limit_exceeded');
|
|
45
|
+
paths.add(file.path);
|
|
46
|
+
entries.push(file);
|
|
47
|
+
}
|
|
48
|
+
if (input.paths !== undefined && (!input.paths.length || input.paths.length > ports.maxFiles ||
|
|
49
|
+
new Set(input.paths).size !== input.paths.length || input.paths.some((path) => !paths.has(path)))) {
|
|
50
|
+
throw new ArtifactOperationError('invalid_request');
|
|
51
|
+
}
|
|
52
|
+
const selected = input.paths === undefined ? entries : entries.filter((file) => input.paths.includes(file.path));
|
|
53
|
+
const files = [];
|
|
54
|
+
for (const file of selected) {
|
|
55
|
+
const bytes = await ports.getBlob(`artifacts/${manifest.storageScopeId}/blobs/sha256/${file.digest.slice(7)}`);
|
|
56
|
+
if (!bytes)
|
|
57
|
+
throw new ArtifactOperationError('release_unavailable', { publicDetails: { reason: 'not_retained' } });
|
|
58
|
+
if (bytes.length !== file.size ||
|
|
59
|
+
createHash('sha256').update(bytes).digest('hex') !== file.digest.slice(7)) {
|
|
60
|
+
throw new ArtifactOperationError('verification_failed');
|
|
61
|
+
}
|
|
62
|
+
files.push({ ...file, bytes: Array.from(bytes) });
|
|
63
|
+
}
|
|
64
|
+
const sourceRef = await ports.getSource(input.artifactId) ?? null;
|
|
65
|
+
await ports.authorize();
|
|
66
|
+
return { artifactId: input.artifactId, releaseId: release.releaseId, digest: manifest.digest,
|
|
67
|
+
manifest: release.manifest, files, sourceRef, ...ports.urls?.(input.artifactId, release.releaseId) };
|
|
68
|
+
}
|