@sentropic/artifacts 0.2.4 → 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 ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## 0.2.5
4
+
5
+ - D1: Read verified author manifests and file bytes for the current or a selected
6
+ release, with path selection, source authorization, bounded snapshots and
7
+ separate provenance. Reject purged releases and mismatched record digests.
8
+ - D2: Grant or revoke named read access and delete artifacts terminally with
9
+ host-approved confirmation, transactional receipts and idempotent replay.
10
+ Mask artifacts outside the caller's visibility as `not_found`.
11
+ - D4: Enrich get/list results with binding names, dates, current release IDs,
12
+ digests, stable/pinned URLs, permissions and available optional metadata.
13
+
14
+ Apply `0004_terminal_controls.sql` after migrations 0001–0003 before enabling
15
+ the management capabilities.
package/README.md CHANGED
@@ -417,3 +417,41 @@ publication, use the current package version and this bootstrap sequence
417
417
  3. After that, merges to `main` touching the artifacts package publish
418
418
  through the `publish-artifacts` job (`make publish-artifacts`, OIDC +
419
419
  provenance), gated by `validate-artifacts`.
420
+
421
+ ## Source and management capabilities
422
+
423
+ Version 0.3.0 introduces D1, D2 and D4; see [CHANGELOG.md](./CHANGELOG.md).
424
+
425
+ Hosts may explicitly select `artifacts_get_source`, `artifacts_set_access`, and
426
+ `artifacts_delete` in their compiled profile. Configure `get_source` with empty
427
+ limits/defaults, and `set_access`/`delete` with `idempotencyKeyMaxUnits: 256` and
428
+ empty defaults. Existing profile selections remain explicit.
429
+
430
+ `POST /get_source` accepts `artifactId`, optional `releaseId`, and optional unique
431
+ `paths` (1–32 entries). Bind `publication.getRelease` and `sources.getSource` on
432
+ the HTTP app. It returns the canonical manifest, verified author file bytes,
433
+ and separate nullable `sourceRef`; readers need the distinct source permission.
434
+ The snapshot is limited to 32 files and 262144 decoded bytes. Hosts must qualify
435
+ a 2 MiB serialized MCP result budget before selecting this capability.
436
+
437
+ `POST /set_access` grants or revokes a named principal's read access, requires a
438
+ caller `idempotencyKey`, and keeps the artifact private. Bind
439
+ `isApprovedAccount` to the host's principal policy. `POST /delete` also requires
440
+ `expectedCurrentReleaseId` (nullable) and a trusted `management.confirmation`
441
+ port. Its `request` method issues a challenge for the host management UI;
442
+ `consume` verifies the human-approved proof, expiration and full command context
443
+ on the supplied transaction. A caller boolean is never confirmation. Repeated
444
+ successful deletes return the stored receipt without consuming proof again.
445
+
446
+ Apply `migrations/0004_terminal_controls.sql` after `0001_artifacts.sql`,
447
+ `0002_lifecycle.sql` and `0003_coordinator.sql` before activation.
448
+ The default management writer uses the coordinator's PostgreSQL
449
+ transaction; alternative stores implement `management.createUnit` with the same
450
+ parent/ACL/current lock order and transactional effects. Terminal markers and
451
+ SQL guards prevent restoration and later publication. Deletion reports
452
+ `purgeState: "pending"`; physical purge belongs to host retention policy.
453
+
454
+ Get/list include binding, creation/modification dates, current release, digest,
455
+ host URLs and available metadata. Historical modification dates stay nullable.
456
+ The PostgreSQL projection reads the optional metadata column added by the
457
+ publication parity lane without requiring that column for older stores.
@@ -0,0 +1,4 @@
1
+ import type { FinalizeTxClient } from '../server/commit/types.js';
2
+ import type { ManagementUnit } from '../server/management.js';
3
+ /** Parent lock serializes ACL inserts, lifecycle changes and publication FKs. */
4
+ export declare function createPostgresManagementUnit(client: FinalizeTxClient): ManagementUnit;
@@ -0,0 +1,36 @@
1
+ /** Parent lock serializes ACL inserts, lifecycle changes and publication FKs. */
2
+ export function createPostgresManagementUnit(client) {
3
+ return {
4
+ async lock(id) {
5
+ await client.query('SELECT id FROM artifacts.artifacts WHERE id = $1 FOR UPDATE', [id]);
6
+ await client.query('SELECT principal_id FROM artifacts.artifact_access WHERE artifact_id = $1 ORDER BY principal_id FOR UPDATE', [id]);
7
+ await client.query('SELECT release_id FROM artifacts.artifact_current WHERE artifact_id = $1 FOR UPDATE', [id]);
8
+ },
9
+ async read(id) {
10
+ const { rows } = await client.query(`SELECT a.owner_id, a.storage_scope_id, a.lifecycle_state, a.is_public, a.terminal_deleted_at, c.release_id
11
+ FROM artifacts.artifacts a LEFT JOIN artifacts.artifact_current c ON c.artifact_id = a.id WHERE a.id = $1`, [id]);
12
+ const row = rows[0];
13
+ if (!row)
14
+ return undefined;
15
+ const acl = { ownerId: row.owner_id, storageScopeId: row.storage_scope_id,
16
+ isPublic: row.is_public, withdrawn: row.lifecycle_state === 'withdrawn', grants: [] };
17
+ return { acl, state: row.lifecycle_state, terminal: row.terminal_deleted_at !== null,
18
+ currentReleaseId: row.release_id };
19
+ },
20
+ async setAccess(id, principalId, action, updatedAt) {
21
+ const changed = action === 'grant' ? await client.query(`INSERT INTO artifacts.artifact_access (artifact_id, principal_id, permission) VALUES ($1, $2, 'read')
22
+ ON CONFLICT (artifact_id, principal_id) DO NOTHING`, [id, principalId])
23
+ : await client.query('DELETE FROM artifacts.artifact_access WHERE artifact_id = $1 AND principal_id = $2', [id, principalId]);
24
+ await client.query(`UPDATE artifacts.artifacts SET is_public = false, updated_at = $2
25
+ WHERE id = $1 AND ($3::boolean OR is_public)`, [id, updatedAt, changed.rowCount > 0]);
26
+ const result = await client.query('SELECT principal_id, permission FROM artifacts.artifact_access WHERE artifact_id = $1 ORDER BY principal_id', [id]);
27
+ const stamp = await client.query('SELECT updated_at FROM artifacts.artifacts WHERE id = $1', [id]);
28
+ return { grants: result.rows.map((row) => ({ principalId: row.principal_id, grant: row.permission })),
29
+ updatedAt: stamp.rows[0]?.updated_at?.toISOString() ?? null };
30
+ },
31
+ async delete(id, deletedAt) {
32
+ await client.query(`UPDATE artifacts.artifacts SET lifecycle_state = 'deleted', terminal_deleted_at = $2,
33
+ updated_at = $2, is_public = false WHERE id = $1`, [id, deletedAt]);
34
+ },
35
+ };
36
+ }
@@ -1,3 +1,4 @@
1
+ import { ArtifactOperationError } from '../server/failures.js';
1
2
  /** Read-only migration inventory. Never promotes a legacy success into an authorized receipt. */
2
3
  export function createPostgresLegacyArtifactRecords(pool) {
3
4
  return {
@@ -34,8 +35,10 @@ export function createPostgresArtifactStore(pool) {
34
35
  async ensureArtifact(input) {
35
36
  const where = `WHERE storage_scope_id = $1 AND owner_id = $2 AND binding_name = $3`;
36
37
  const args = [input.storageScopeId, input.ownerId, input.bindingName];
37
- const found = await pool.query(`SELECT id FROM artifacts.artifacts ${where}`, args);
38
+ const found = await pool.query(`SELECT id, (to_jsonb(a)->>'terminal_deleted_at') IS NOT NULL AS terminal FROM artifacts.artifacts a ${where}`, args);
38
39
  if (found.rowCount !== 0 && found.rows[0] !== undefined) {
40
+ if (found.rows[0].terminal)
41
+ throw new ArtifactOperationError('gone');
39
42
  return { artifactId: found.rows[0].id, created: false };
40
43
  }
41
44
  const inserted = await pool.query(`INSERT INTO artifacts.artifacts (id, storage_scope_id, owner_id, binding_name, profile)
@@ -44,10 +47,12 @@ export function createPostgresArtifactStore(pool) {
44
47
  if (inserted.rowCount !== 0 && inserted.rows[0] !== undefined) {
45
48
  return { artifactId: inserted.rows[0].id, created: true };
46
49
  }
47
- const raced = await pool.query(`SELECT id FROM artifacts.artifacts ${where}`, args);
50
+ const raced = await pool.query(`SELECT id, (to_jsonb(a)->>'terminal_deleted_at') IS NOT NULL AS terminal FROM artifacts.artifacts a ${where}`, args);
48
51
  const row = raced.rows[0];
49
52
  if (row === undefined)
50
53
  throw new Error(`artifact binding lost for ${input.bindingName}`);
54
+ if (row.terminal)
55
+ throw new ArtifactOperationError('gone');
51
56
  return { artifactId: row.id, created: false };
52
57
  },
53
58
  async record(kind, payload) {
@@ -185,14 +190,17 @@ export function createPostgresArtifactStore(pool) {
185
190
  return rows.map((row) => row.digest);
186
191
  },
187
192
  async getArtifactAcl(artifactId) {
188
- const { rows } = await pool.query(`SELECT owner_id, storage_scope_id, is_public, lifecycle_state
189
- FROM artifacts.artifacts WHERE id = $1`, [artifactId]);
193
+ const { rows } = await pool.query(`SELECT owner_id, storage_scope_id, is_public, lifecycle_state, binding_name, created_at,
194
+ to_jsonb(a)->>'updated_at' AS updated_at, to_jsonb(a)->'metadata' AS metadata
195
+ FROM artifacts.artifacts a WHERE id = $1`, [artifactId]);
190
196
  const row = rows[0];
191
197
  if (row === undefined)
192
198
  return undefined;
193
199
  const grants = await pool.query(`SELECT principal_id, permission FROM artifacts.artifact_access
194
200
  WHERE artifact_id = $1 ORDER BY principal_id`, [artifactId]);
195
201
  return {
202
+ bindingName: row.binding_name, createdAt: row.created_at.toISOString(), updatedAt: row.updated_at,
203
+ ...(row.metadata ?? {}),
196
204
  ownerId: row.owner_id,
197
205
  storageScopeId: row.storage_scope_id,
198
206
  isPublic: row.is_public,
@@ -263,6 +271,13 @@ export function createPostgresArtifactStore(pool) {
263
271
  const { rows } = await pool.query(`SELECT lifecycle_state FROM artifacts.artifacts WHERE id = $1`, [artifactId]);
264
272
  return rows[0]?.lifecycle_state ?? 'active';
265
273
  },
274
+ async isTerminal(artifactId) {
275
+ const { rows } = await pool.query("SELECT (to_jsonb(a)->>'terminal_deleted_at') IS NOT NULL AS terminal FROM artifacts.artifacts a WHERE id = $1", [artifactId]);
276
+ return rows[0]?.terminal === true;
277
+ },
278
+ async markTerminal(artifactId) {
279
+ await pool.query("UPDATE artifacts.artifacts SET terminal_deleted_at = coalesce(terminal_deleted_at, now()), lifecycle_state = 'deleted' WHERE id = $1", [artifactId]);
280
+ },
266
281
  async setArtifactState(artifactId, state) {
267
282
  const updated = await pool.query(`UPDATE artifacts.artifacts SET lifecycle_state = $2 WHERE id = $1`, [artifactId, state]);
268
283
  if ((updated.rowCount ?? 0) === 0)
package/dist/http.d.ts CHANGED
@@ -7,6 +7,9 @@ import type { AclStore } from './server/access.js';
7
7
  import type { LifecycleService } from './server/lifecycle.js';
8
8
  import { type ArtifactInvocationContext, type ArtifactCommitCoordinator } from './server/commit/types.js';
9
9
  import { type ArtifactOutcome, type ArtifactHostFailureMapper } from './server/failures.js';
10
+ import { type ManagementUnit, type ArtifactConfirmationPort } from './server/management.js';
11
+ import type { FinalizeTxClient } from './server/commit/types.js';
12
+ import type { SourceStore } from './server/sources.js';
10
13
  export { projectArtifactHttpOutcome } from './server/outcome-projection.js';
11
14
  export interface HttpRequest {
12
15
  method: string;
@@ -37,6 +40,12 @@ export interface HttpAppDeps {
37
40
  ensureHost: GuardedEnsureHost;
38
41
  publication: Omit<GuardedPublicationDeps, 'coordinator' | 'limits'> & {
39
42
  getCurrent: PublicationService['getCurrent'];
43
+ getRelease?: PublicationService['getRelease'];
44
+ };
45
+ sources?: Pick<SourceStore, 'getSource'>;
46
+ management?: {
47
+ createUnit?: (client: FinalizeTxClient) => ManagementUnit;
48
+ confirmation?: ArtifactConfirmationPort;
40
49
  };
41
50
  acls: AclStore;
42
51
  lifecycle: Pick<LifecycleService, 'stateOf' | 'canRead'>;
package/dist/http.js CHANGED
@@ -7,6 +7,10 @@ import { createGuardedPublicationService } from './server/publication.js';
7
7
  import { CommitQuarantineError } from './server/commit/types.js';
8
8
  import { ArtifactOperationError, classifyArtifactFailure } from './server/failures.js';
9
9
  import { projectArtifactHttpOutcome } from './server/outcome-projection.js';
10
+ import { readArtifactSource } from './server/source-read.js';
11
+ import { artifactSummary } from './server/summary.js';
12
+ import { manageArtifact } from './server/management.js';
13
+ import { createPostgresManagementUnit } from './adapters/postgres-management.js';
10
14
  export { projectArtifactHttpOutcome } from './server/outcome-projection.js';
11
15
  /** Stable content-route error codes; private driver messages never leave the host. */
12
16
  export function errorBody(status) {
@@ -24,7 +28,8 @@ function object(value) {
24
28
  /** Other mutation surfaces need their own qualification before host activation. */
25
29
  export function assertArtifactsTransportProfile(value) {
26
30
  assertCompiledArtifactsProfile(value);
27
- const supported = ['artifacts_ensure', 'artifacts_publish', 'artifacts_list', 'artifacts_get'];
31
+ const supported = ['artifacts_ensure', 'artifacts_publish', 'artifacts_list', 'artifacts_get',
32
+ 'artifacts_get_source', 'artifacts_set_access', 'artifacts_delete'];
28
33
  if (value.selectedCapabilities.some((name) => !supported.includes(name))) {
29
34
  throw new Error('unsupported artifacts transport capability');
30
35
  }
@@ -40,13 +45,25 @@ export function createHttpArtifactApp(deps) {
40
45
  if (typeof deps.legacyRecords?.get !== 'function')
41
46
  throw new Error('artifacts requires a legacy record inventory');
42
47
  const profile = deps.profile;
48
+ if (profile.selectedCapabilities.includes('artifacts_get_source') &&
49
+ (!deps.sources || !deps.publication.getRelease))
50
+ throw new Error('artifact source read ports required');
51
+ if (profile.selectedCapabilities.includes('artifacts_delete') && !deps.management?.confirmation) {
52
+ throw new Error('artifact delete confirmation port required');
53
+ }
43
54
  const ensure = createGuardedEnsureService({ profile, coordinator: deps.coordinator,
44
55
  derivation: deps.derivation, host: deps.ensureHost });
45
- async function visible(artifactId, invocation) {
56
+ async function visible(artifactId, invocation, allowTerminalOwner = false) {
46
57
  const acl = await deps.acls.getArtifactAcl(artifactId);
47
58
  if (!acl || acl.storageScopeId !== invocation.storageScopeId)
48
59
  throw new ArtifactOperationError('not_found');
49
60
  const state = await deps.lifecycle.stateOf(artifactId);
61
+ if (acl.ownerId === invocation.actorId && (state === 'deleted' || state === 'purged')) {
62
+ // Management must still authorize terminal owners before replaying a receipt.
63
+ if (allowTerminalOwner)
64
+ return acl;
65
+ throw new ArtifactOperationError('gone');
66
+ }
50
67
  if (!deps.lifecycle.canRead({ artifactId, state }, acl, invocation.actorId, 'read', deps.isApprovedAccount)) {
51
68
  throw new ArtifactOperationError('not_found');
52
69
  }
@@ -93,9 +110,9 @@ export function createHttpArtifactApp(deps) {
93
110
  }
94
111
  }
95
112
  const normalized = profile.validate(capability, args, request.idempotencyKey);
96
- const command = capability === 'artifacts_ensure' || capability === 'artifacts_publish'
113
+ const command = ['artifacts_ensure', 'artifacts_publish', 'artifacts_set_access', 'artifacts_delete'].includes(capability)
97
114
  ? createArtifactCommand(profile, capability, args, invocation, deps.derivation, request.idempotencyKey) : undefined;
98
- if (command) {
115
+ if (command && ['ensure', 'publish'].includes(command.operation)) {
99
116
  phase = 'migration';
100
117
  await assertArtifactReplayMigration(deps.legacyRecords, command, invocation, normalized.idempotencyKey);
101
118
  }
@@ -103,8 +120,39 @@ export function createHttpArtifactApp(deps) {
103
120
  if (capability === 'artifacts_ensure') {
104
121
  const outcome = await ensure.ensure(invocation, { ...normalized,
105
122
  envelopeKey: request.idempotencyKey });
123
+ if (outcome.ok) {
124
+ const state = await deps.lifecycle.stateOf(outcome.result.artifactId);
125
+ if (state === 'deleted' || state === 'purged')
126
+ throw new ArtifactOperationError('gone');
127
+ }
106
128
  return outcome.ok ? { ...outcome, result: { status: 201, body: outcome.result } } : outcome;
107
129
  }
130
+ if (capability === 'artifacts_get_source') {
131
+ const artifactId = normalized.artifactId;
132
+ const result = await readArtifactSource({
133
+ storageScopeId: invocation.storageScopeId,
134
+ authorize: async () => {
135
+ const acl = await visible(artifactId, invocation);
136
+ const state = await deps.lifecycle.stateOf(artifactId);
137
+ if (!deps.lifecycle.canRead({ artifactId, state }, acl, invocation.actorId, 'source', deps.isApprovedAccount)) {
138
+ throw new ArtifactOperationError('access_denied');
139
+ }
140
+ }, getCurrent: deps.publication.getCurrent, getRelease: deps.publication.getRelease,
141
+ getSource: (id) => deps.sources.getSource(id), getBlob: (key) => deps.publication.blobs.getBlob(key),
142
+ urls: deps.publicationUrls, maxFiles: 32, maxTotalBytes: 262144,
143
+ }, normalized);
144
+ return { ok: true, result: { status: 200, body: result }, invocationId: invocation.invocationId,
145
+ commitState: 'not_committed', replayed: false };
146
+ }
147
+ if (capability === 'artifacts_set_access' || capability === 'artifacts_delete') {
148
+ await visible(normalized.artifactId, invocation, true);
149
+ const outcome = await manageArtifact({ coordinator: deps.coordinator,
150
+ createUnit: deps.management?.createUnit ?? createPostgresManagementUnit,
151
+ confirmation: deps.management?.confirmation, readPolicyRevision: () => deps.ensureHost.readPolicyRevision(),
152
+ isApprovedPrincipal: deps.isApprovedAccount ?? (() => false),
153
+ }, invocation, command, normalized.confirmationRef);
154
+ return outcome.ok ? { ...outcome, result: { status: 200, body: outcome.result } } : outcome;
155
+ }
108
156
  if (capability === 'artifacts_publish') {
109
157
  const artifactId = normalized.artifactId;
110
158
  const acl = await visible(artifactId, invocation);
@@ -146,8 +194,8 @@ export function createHttpArtifactApp(deps) {
146
194
  const state = await deps.lifecycle.stateOf(artifactId);
147
195
  const current = await deps.publication.getCurrent(artifactId);
148
196
  return { ok: true, invocationId: invocation.invocationId, commitState: 'not_committed', replayed: false,
149
- result: { status: 200, body: { artifactId, ownerId: acl.ownerId, storageScopeId: acl.storageScopeId,
150
- state, isPublic: acl.isPublic, withdrawn: state === 'withdrawn', currentReleaseId: current?.releaseId,
197
+ result: { status: 200, body: { ...artifactSummary({ artifactId, acl, state, current,
198
+ actorId: invocation.actorId, urls: deps.publicationUrls, approved: deps.isApprovedAccount }),
151
199
  ...(acl.ownerId === invocation.actorId ? { grants: acl.grants } : {}) } } };
152
200
  }
153
201
  const visibleArtifacts = [];
@@ -157,7 +205,8 @@ export function createHttpArtifactApp(deps) {
157
205
  continue;
158
206
  const state = await deps.lifecycle.stateOf(artifactId);
159
207
  if (deps.lifecycle.canRead({ artifactId, state }, acl, invocation.actorId, 'read', deps.isApprovedAccount)) {
160
- visibleArtifacts.push({ artifactId, ownerId: acl.ownerId, state, isPublic: acl.isPublic });
208
+ visibleArtifacts.push(artifactSummary({ artifactId, acl, state, actorId: invocation.actorId,
209
+ current: await deps.publication.getCurrent(artifactId), urls: deps.publicationUrls, approved: deps.isApprovedAccount }));
161
210
  }
162
211
  }
163
212
  const offset = normalized.offset;
package/dist/index.d.ts CHANGED
@@ -25,3 +25,6 @@ export * from './server/reader-profile.js';
25
25
  export * from './http.js';
26
26
  export * from './server/commit-contract.js';
27
27
  export * from './server/outcome-contract.js';
28
+ export * from './server/source-read.js';
29
+ export * from './server/management.js';
30
+ export * from './adapters/postgres-management.js';
package/dist/index.js CHANGED
@@ -29,3 +29,6 @@ export * from './server/reader-profile.js';
29
29
  export * from './http.js';
30
30
  export * from './server/commit-contract.js';
31
31
  export * from './server/outcome-contract.js';
32
+ export * from './server/source-read.js';
33
+ export * from './server/management.js';
34
+ export * from './adapters/postgres-management.js';
@@ -10,11 +10,11 @@ export { createRadarKeyDerivation } from './server/command-identity.js';
10
10
  export type { ArtifactKeyDerivationBinding } from './server/command-identity.js';
11
11
  export declare const ARTIFACTS_PROVIDER_ID = "sentropic.artifacts";
12
12
  export declare const ARTIFACTS_APP_ID = "sentropic-artifacts";
13
- export declare const ARTIFACTS_PROVIDER_VERSION = "0.2.4";
13
+ export declare const ARTIFACTS_PROVIDER_VERSION = "0.2.5";
14
14
  export declare const ARTIFACTS_TOOL_NAMES: readonly ["artifacts_get_template", "artifacts_get_api_guide", "artifacts_ensure", "artifacts_publish", "artifacts_create_upload", "artifacts_commit_upload", "artifacts_list", "artifacts_get", "artifacts_get_source", "artifacts_fork", "artifacts_set_access", "artifacts_promote", "artifacts_renew", "artifacts_delete", "artifacts_restore"];
15
15
  export type ArtifactsToolName = (typeof ARTIFACTS_TOOL_NAMES)[number];
16
16
  export declare const ARTIFACTS_TOOL_SPECS: {
17
- name: "artifacts_ensure" | "artifacts_publish" | "artifacts_get_template" | "artifacts_get_api_guide" | "artifacts_create_upload" | "artifacts_commit_upload" | "artifacts_list" | "artifacts_get" | "artifacts_get_source" | "artifacts_fork" | "artifacts_set_access" | "artifacts_promote" | "artifacts_renew" | "artifacts_delete" | "artifacts_restore";
17
+ name: "artifacts_ensure" | "artifacts_publish" | "artifacts_set_access" | "artifacts_delete" | "artifacts_get_template" | "artifacts_get_api_guide" | "artifacts_create_upload" | "artifacts_commit_upload" | "artifacts_list" | "artifacts_get" | "artifacts_get_source" | "artifacts_fork" | "artifacts_promote" | "artifacts_renew" | "artifacts_restore";
18
18
  description: string;
19
19
  mutability: import("@sentropic/mcp-platform").Mutability;
20
20
  }[];
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.4';
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: false, requiresPrincipalGate: false },
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,
@@ -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 (name === 'artifacts_ensure' || name === 'artifacts_publish' || name === 'artifacts_commit_upload') {
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;
@@ -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', 'set-public'] },
72
- principalId: str, grant: { type: 'string', enum: ['read', 'source'] }, isPublic: { type: 'boolean' } }, ['artifactId', 'action']);
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 (operation === 'ensure' || operation === 'publish' || operation === 'commit_upload') {
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;
@@ -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: 'ensure' | 'publish';
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: 'ensure' | 'publish';
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 === '') || (operation === 'publish' && isArtifactId(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
- (operation !== 'ensure' && operation !== 'publish') ||
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 (name !== 'artifacts_ensure' && name !== 'artifacts_publish')
51
+ if (!['artifacts_ensure', 'artifacts_publish', 'artifacts_set_access', 'artifacts_delete'].includes(name))
52
52
  throw new ArtifactProfileInputError(true);
53
- const operation = name === 'artifacts_ensure' ? 'ensure' : 'publish';
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 === 'publish' ? normalized.artifactId : '',
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. */
@@ -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>;
@@ -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: 'ready';
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
- releaseId: string;
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 { releaseId: release.releaseId, manifest: release.manifest };
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 { releaseId: release.releaseId, manifest: release.manifest };
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
+ }
@@ -0,0 +1,39 @@
1
+ import { type ArtifactAcl } from './access.js';
2
+ export declare function artifactSummary(input: {
3
+ artifactId: string;
4
+ acl: ArtifactAcl;
5
+ actorId: string;
6
+ state: string;
7
+ current?: {
8
+ releaseId: string;
9
+ manifest: string;
10
+ };
11
+ urls?: (artifactId: string, releaseId: string) => {
12
+ stableUrl: string;
13
+ pinnedUrl: string;
14
+ };
15
+ approved?: (principalId: string) => boolean;
16
+ }): {
17
+ permissions: {
18
+ read: boolean;
19
+ source: boolean;
20
+ write: boolean;
21
+ manage: boolean;
22
+ };
23
+ icon?: string | null | undefined;
24
+ description?: string | undefined;
25
+ title?: string | undefined;
26
+ artifactId: string;
27
+ bindingName: string | null;
28
+ ownerId: string;
29
+ storageScopeId: string | undefined;
30
+ state: string;
31
+ isPublic: boolean;
32
+ withdrawn: boolean;
33
+ createdAt: string | null;
34
+ updatedAt: string | null;
35
+ currentReleaseId: string | null;
36
+ digest: string | null;
37
+ stableUrl: string | null;
38
+ pinnedUrl: string | null;
39
+ };
@@ -0,0 +1,17 @@
1
+ import { parseManifest } from '../codecs.js';
2
+ import { evaluateAccess } from './access.js';
3
+ export function artifactSummary(input) {
4
+ const { artifactId, acl, actorId, state, current } = input;
5
+ const urls = input.urls?.(artifactId, current?.releaseId ?? '');
6
+ const owner = acl.ownerId === actorId;
7
+ return { artifactId, bindingName: acl.bindingName ?? null, ownerId: acl.ownerId,
8
+ storageScopeId: acl.storageScopeId, state, isPublic: acl.isPublic, withdrawn: state === 'withdrawn',
9
+ createdAt: acl.createdAt ?? null, updatedAt: acl.updatedAt ?? null,
10
+ currentReleaseId: current?.releaseId ?? null, digest: current ? parseManifest(current.manifest).digest ?? null : null,
11
+ stableUrl: urls?.stableUrl ?? null, pinnedUrl: current ? urls?.pinnedUrl ?? null : null,
12
+ ...(acl.title === undefined ? {} : { title: acl.title }),
13
+ ...(acl.description === undefined ? {} : { description: acl.description }),
14
+ ...(acl.icon === undefined ? {} : { icon: acl.icon }),
15
+ permissions: { read: evaluateAccess(acl, actorId, 'read', input.approved),
16
+ source: evaluateAccess(acl, actorId, 'source', input.approved), write: owner, manage: owner } };
17
+ }
@@ -0,0 +1,47 @@
1
+ -- Terminal deletion retains the identity and receipts; physical purge remains host policy.
2
+ ALTER TABLE artifacts.artifacts ADD COLUMN IF NOT EXISTS terminal_deleted_at TIMESTAMPTZ;
3
+ ALTER TABLE artifacts.artifacts ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ;
4
+ -- Defaults affect new identities only; historical modification times remain unknown.
5
+ ALTER TABLE artifacts.artifacts ALTER COLUMN updated_at SET DEFAULT now();
6
+
7
+ CREATE OR REPLACE FUNCTION artifacts.reject_terminal_restore() RETURNS trigger LANGUAGE plpgsql AS $$
8
+ BEGIN
9
+ IF TG_OP = 'DELETE' THEN
10
+ IF OLD.terminal_deleted_at IS NOT NULL THEN RAISE EXCEPTION 'terminal artifact' USING ERRCODE = '23514'; END IF;
11
+ RETURN OLD;
12
+ END IF;
13
+ IF OLD.terminal_deleted_at IS NOT NULL AND
14
+ (NEW.terminal_deleted_at IS DISTINCT FROM OLD.terminal_deleted_at OR NEW.lifecycle_state NOT IN ('deleted', 'purged')
15
+ OR NEW.renewed_at IS DISTINCT FROM OLD.renewed_at) THEN
16
+ RAISE EXCEPTION 'terminal artifact' USING ERRCODE = '23514';
17
+ END IF;
18
+ RETURN NEW;
19
+ END $$;
20
+ DROP TRIGGER IF EXISTS terminal_restore_guard ON artifacts.artifacts;
21
+ CREATE TRIGGER terminal_restore_guard BEFORE UPDATE ON artifacts.artifacts
22
+ FOR EACH ROW EXECUTE FUNCTION artifacts.reject_terminal_restore();
23
+ DROP TRIGGER IF EXISTS terminal_identity_guard ON artifacts.artifacts;
24
+ CREATE TRIGGER terminal_identity_guard BEFORE DELETE ON artifacts.artifacts
25
+ FOR EACH ROW EXECUTE FUNCTION artifacts.reject_terminal_restore();
26
+
27
+ CREATE OR REPLACE FUNCTION artifacts.reject_terminal_publication() RETURNS trigger LANGUAGE plpgsql AS $$
28
+ BEGIN
29
+ PERFORM id FROM artifacts.artifacts WHERE id = NEW.artifact_id AND terminal_deleted_at IS NULL FOR KEY SHARE;
30
+ IF NOT FOUND THEN RAISE EXCEPTION 'terminal artifact' USING ERRCODE = '23514'; END IF;
31
+ RETURN NEW;
32
+ END $$;
33
+ DROP TRIGGER IF EXISTS terminal_release_guard ON artifacts.releases;
34
+ CREATE TRIGGER terminal_release_guard BEFORE INSERT ON artifacts.releases
35
+ FOR EACH ROW EXECUTE FUNCTION artifacts.reject_terminal_publication();
36
+
37
+ CREATE OR REPLACE FUNCTION artifacts.record_publication_update() RETURNS trigger LANGUAGE plpgsql AS $$
38
+ BEGIN
39
+ UPDATE artifacts.artifacts SET updated_at = NEW.created_at WHERE id = NEW.artifact_id;
40
+ RETURN NEW;
41
+ END $$;
42
+ DROP TRIGGER IF EXISTS publication_updated_at ON artifacts.releases;
43
+ CREATE TRIGGER publication_updated_at AFTER INSERT ON artifacts.releases
44
+ FOR EACH ROW EXECUTE FUNCTION artifacts.record_publication_update();
45
+ DROP TRIGGER IF EXISTS terminal_pointer_guard ON artifacts.artifact_current;
46
+ CREATE TRIGGER terminal_pointer_guard BEFORE INSERT OR UPDATE ON artifacts.artifact_current
47
+ FOR EACH ROW EXECUTE FUNCTION artifacts.reject_terminal_publication();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentropic/artifacts",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Generic configurable artifacts MCP and artifact service: StorageScope/Artifact/Binding/Release model, canonical manifests, object authorization, idempotent intents (spec-v8 §9).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -103,6 +103,18 @@
103
103
  "types": "./dist/server/sources.d.ts",
104
104
  "import": "./dist/server/sources.js"
105
105
  },
106
+ "./server/source-read": {
107
+ "types": "./dist/server/source-read.d.ts",
108
+ "import": "./dist/server/source-read.js"
109
+ },
110
+ "./server/management": {
111
+ "types": "./dist/server/management.d.ts",
112
+ "import": "./dist/server/management.js"
113
+ },
114
+ "./adapters/postgres-management": {
115
+ "types": "./dist/adapters/postgres-management.d.ts",
116
+ "import": "./dist/adapters/postgres-management.js"
117
+ },
106
118
  "./server/fork": {
107
119
  "types": "./dist/server/fork.d.ts",
108
120
  "import": "./dist/server/fork.js"
@@ -181,6 +193,7 @@
181
193
  "docs/migration-l6.md",
182
194
  "examples/radar-commit-setup.ts",
183
195
  "README.md",
196
+ "CHANGELOG.md",
184
197
  "LICENSE"
185
198
  ],
186
199
  "publishConfig": {