@sentropic/artifacts 0.2.5 → 0.2.7

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +20 -6
  3. package/dist/adapters/postgres.js +11 -4
  4. package/dist/http.d.ts +4 -3
  5. package/dist/http.js +225 -37
  6. package/dist/mcp-core.d.ts +3 -3
  7. package/dist/mcp-core.js +23 -10
  8. package/dist/profile/contracts.d.ts +15 -5
  9. package/dist/profile/contracts.js +9 -6
  10. package/dist/profile/index.js +33 -4
  11. package/dist/profile/radar.js +4 -1
  12. package/dist/profile/schemas.js +35 -6
  13. package/dist/profile/validation.d.ts +3 -1
  14. package/dist/profile/validation.js +32 -13
  15. package/dist/server/access.d.ts +8 -0
  16. package/dist/server/command-identity.d.ts +4 -2
  17. package/dist/server/command-identity.js +5 -5
  18. package/dist/server/commit/coordinator.js +12 -9
  19. package/dist/server/commit/types.d.ts +4 -0
  20. package/dist/server/edits.d.ts +17 -0
  21. package/dist/server/edits.js +43 -0
  22. package/dist/server/failures.d.ts +31 -2
  23. package/dist/server/failures.js +14 -3
  24. package/dist/server/lifecycle.d.ts +2 -0
  25. package/dist/server/lifecycle.js +3 -0
  26. package/dist/server/management.d.ts +1 -0
  27. package/dist/server/management.js +9 -2
  28. package/dist/server/mime.d.ts +7 -0
  29. package/dist/server/mime.js +7 -0
  30. package/dist/server/outcome-projection.d.ts +2 -2
  31. package/dist/server/outcome-projection.js +25 -9
  32. package/dist/server/publication.d.ts +18 -1
  33. package/dist/server/publication.js +54 -5
  34. package/dist/server/result-schema.d.ts +14 -0
  35. package/dist/server/result-schema.js +43 -0
  36. package/dist/server/service.d.ts +2 -0
  37. package/dist/server/service.js +2 -0
  38. package/dist/server/source-read.d.ts +23 -3
  39. package/dist/server/source-read.js +17 -2
  40. package/dist/server/summary.d.ts +4 -3
  41. package/dist/server/summary.js +4 -3
  42. package/dist/server/templates.d.ts +5 -0
  43. package/dist/server/templates.js +30 -3
  44. package/dist/server/versions.d.ts +29 -0
  45. package/dist/server/versions.js +39 -0
  46. package/docs/migration-l6.md +8 -1
  47. package/migrations/0005_metadata.sql +14 -0
  48. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.7
4
+
5
+ - Fix the `artifacts_get_source` wire payload that broke page editing in claude.ai:
6
+ files are no longer returned as a JSON integer array (`bytes: Array.from(bytes)`).
7
+ Text mime types (the canonical `TEXT_MIME_PATTERN`) come back as `text` with
8
+ `encoding: "utf8"`; every other file — including non-UTF-8 "text" — as `base64`
9
+ with `encoding: "base64"`. Size and digest verification still run on the raw
10
+ bytes before encoding. An 816-byte page now serializes to a few KiB instead of
11
+ blowing the tool-result budget.
12
+ - `get_source` (and the conflict `currentSource`) no longer embeds any authoring
13
+ guide; the result carries only a `guideRef` (`{tool: "artifacts_get_api_guide",
14
+ version}`) naming the tool and version to fetch it from.
15
+ - Per-call budgets are unchanged: at most 32 files and 262144 decoded bytes; the
16
+ 2 MiB serialized MCP result budget still holds for the full snapshot.
17
+ - Tool schema (`artifacts_get_source` description, v2 result schema source files),
18
+ the API guide text and the README source section describe the text/base64
19
+ encoding and the guide reference.
20
+
21
+ ## 0.2.6
22
+
23
+ - Integrate part 1 with the source reads, sharing, terminal deletion, masking and
24
+ summaries published in 0.2.5. Apply `0005_metadata.sql` after 0004; it adds
25
+ editorial metadata and nullable release authors/lineage without backfilling history.
26
+ - Add version history and revert as a new release on the same stable URL, preserving
27
+ current metadata and ACLs. The v2 profile uses `targetReleaseId` and paginated history.
28
+ - Add full-bundle or exactly-once text edits, metadata on ensure/publish, and
29
+ transactional release CAS, metadata, references, quota and receipts.
30
+ - Separate trusted `profileRevisionId`, `requireExpectedCurrentReleaseId` and
31
+ `resultEnvelopeVersion`. Compatible bindings keep root results; strict v2 retains
32
+ commit state, replay status and typed failures through mcp-platform 0.4.1.
33
+ - Replay historical receipts before enforcing new preconditions or preparing edits.
34
+ Missing strict preconditions use the R2 `precondition_required` message and details.
35
+
36
+ IMMO activates part 1 only after its qualified input and result switchovers.
37
+ Artifacts 0.2.6 stays inside the existing cluster-mesh `>=0.2.0 <0.3.0` range;
38
+ the API consumes the workspace package and retains its pinned APP-P1 profile.
39
+
3
40
  ## 0.2.5
4
41
 
5
42
  - D1: Read verified author manifests and file bytes for the current or a selected
package/README.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # @sentropic/artifacts — generic artifact contracts (L-5a)
2
2
 
3
+ Publication profiles can set `requireExpectedCurrentReleaseId: true` to require an
4
+ explicit UUID or `null` on publish/revert. Strict profiles reject writers without
5
+ CAS. Omission remains supported in generic profiles. Replay the same caller key
6
+ after an unknown commit outcome; changing files, edits, metadata or the precondition
7
+ requires a new key. The `artifacts-outcome/v2` MCP envelope requires
8
+ `@sentropic/mcp-platform >=0.4.1` and preserves commit state and typed error details.
9
+
3
10
  ## Shared PostgreSQL activation (UP-T, 0.2.3)
4
11
 
5
12
  W1.c/W3.a and Radar S1/A6 use the same imports:
@@ -314,7 +321,8 @@ The installed package includes SQL resources exported under
314
321
  ```js
315
322
  import { readFile } from 'node:fs/promises';
316
323
 
317
- const migrations = ['0001_artifacts.sql', '0002_lifecycle.sql'];
324
+ const migrations = ['0001_artifacts.sql', '0002_lifecycle.sql', '0003_coordinator.sql',
325
+ '0004_terminal_controls.sql', '0005_metadata.sql'];
318
326
  for (const name of migrations) {
319
327
  const sql = await readFile(new URL(import.meta.resolve(
320
328
  `@sentropic/artifacts/migrations/${name}`,
@@ -324,8 +332,10 @@ for (const name of migrations) {
324
332
  ```
325
333
 
326
334
  Apply `0001_artifacts.sql` first to create the domain schema, then
327
- `0002_lifecycle.sql` to add lifecycle, sharing, sources and GC support, before
328
- using the Postgres store. The host owns migration credentials, serialization,
335
+ `0002_lifecycle.sql` to add lifecycle, sharing, sources and GC support, then
336
+ `0003_coordinator.sql`, `0004_terminal_controls.sql`, and `0005_metadata.sql`
337
+ (artifact display metadata and nullable release lineage),
338
+ before using the Postgres store. The host owns migration credentials, serialization,
329
339
  transactions and the migration journal; execute each version through that
330
340
  runner rather than running this read loop at application startup.
331
341
 
@@ -429,9 +439,13 @@ empty defaults. Existing profile selections remain explicit.
429
439
 
430
440
  `POST /get_source` accepts `artifactId`, optional `releaseId`, and optional unique
431
441
  `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
442
+ the HTTP app. It returns the canonical manifest, verified author files, and a
443
+ separate nullable `sourceRef`; readers need the distinct source permission.
444
+ Size and digest are verified on the raw bytes, then each file is encoded for the
445
+ wire: text mime types as `text` with `encoding: "utf8"`, all other files as
446
+ `base64` with `encoding: "base64"` — never an integer array. The result carries
447
+ a `guideRef` (`{tool, version}`) instead of the guide body. The snapshot is
448
+ limited to 32 files and 262144 decoded bytes. Hosts must qualify
435
449
  a 2 MiB serialized MCP result budget before selecting this capability.
436
450
 
437
451
  `POST /set_access` grants or revokes a named principal's read access, requires a
@@ -21,6 +21,9 @@ function toIntent(row) {
21
21
  }
22
22
  function toRelease(row) {
23
23
  return {
24
+ authorPrincipalId: row.author_principal_id ?? null,
25
+ parentReleaseId: row.parent_release_id ?? null,
26
+ revertedFromReleaseId: row.reverted_from_release_id ?? null,
24
27
  releaseId: row.id,
25
28
  artifactId: row.artifact_id,
26
29
  manifestDigest: row.manifest_digest,
@@ -143,11 +146,14 @@ export function createPostgresArtifactStore(pool) {
143
146
  }
144
147
  },
145
148
  async getRelease(releaseId) {
146
- const { rows } = await pool.query(`SELECT id, artifact_id, manifest_digest, manifest, state, created_at
147
- FROM artifacts.releases WHERE id = $1`, [releaseId]);
149
+ const { rows } = await pool.query(`SELECT * FROM artifacts.releases WHERE id = $1`, [releaseId]);
148
150
  const row = rows[0];
149
151
  return row === undefined ? undefined : toRelease(row);
150
152
  },
153
+ async listReleases(artifactId) {
154
+ const { rows } = await pool.query(`SELECT * FROM artifacts.releases WHERE artifact_id = $1 ORDER BY created_at, id`, [artifactId]);
155
+ return rows.map(toRelease);
156
+ },
151
157
  async getCurrentReleaseId(artifactId) {
152
158
  const { rows } = await pool.query(`SELECT release_id FROM artifacts.artifact_current WHERE artifact_id = $1`, [artifactId]);
153
159
  return rows[0]?.release_id;
@@ -209,14 +215,15 @@ export function createPostgresArtifactStore(pool) {
209
215
  principalId: grant.principal_id,
210
216
  grant: grant.permission,
211
217
  })),
218
+ ...(row.metadata === null || Object.keys(row.metadata).length === 0 ? {} : { metadata: row.metadata }),
212
219
  };
213
220
  },
214
221
  async putArtifactAcl(artifactId, acl) {
215
- await pool.query(`UPDATE artifacts.artifacts SET is_public = $2,
222
+ await pool.query(`UPDATE artifacts.artifacts SET is_public = $2, metadata = $4,
216
223
  lifecycle_state = CASE WHEN $3 THEN 'withdrawn'
217
224
  WHEN lifecycle_state = 'withdrawn' THEN 'active'
218
225
  ELSE lifecycle_state END
219
- WHERE id = $1`, [artifactId, acl.isPublic, acl.withdrawn]);
226
+ WHERE id = $1`, [artifactId, acl.isPublic, acl.withdrawn, JSON.stringify(acl.metadata ?? {})]);
220
227
  await pool.query(`DELETE FROM artifacts.artifact_access WHERE artifact_id = $1`, [
221
228
  artifactId,
222
229
  ]);
package/dist/http.d.ts CHANGED
@@ -40,7 +40,8 @@ export interface HttpAppDeps {
40
40
  ensureHost: GuardedEnsureHost;
41
41
  publication: Omit<GuardedPublicationDeps, 'coordinator' | 'limits'> & {
42
42
  getCurrent: PublicationService['getCurrent'];
43
- getRelease?: PublicationService['getRelease'];
43
+ getRelease: PublicationService['getRelease'];
44
+ listReleases: PublicationService['listReleases'];
44
45
  };
45
46
  sources?: Pick<SourceStore, 'getSource'>;
46
47
  management?: {
@@ -48,7 +49,7 @@ export interface HttpAppDeps {
48
49
  confirmation?: ArtifactConfirmationPort;
49
50
  };
50
51
  acls: AclStore;
51
- lifecycle: Pick<LifecycleService, 'stateOf' | 'canRead'>;
52
+ lifecycle: Pick<LifecycleService, 'stateOf' | 'canRead' | 'getReleaseState'>;
52
53
  isApprovedAccount?: (principalId: string) => boolean;
53
54
  /** One trusted URL policy shared by HTTP and MCP receipts. */
54
55
  publicationUrls?: (artifactId: string, releaseId: string) => {
@@ -63,7 +64,7 @@ export interface ArtifactApp {
63
64
  handle(request: HttpRequest, invocation: ArtifactInvocationContext): Promise<HttpResponse>;
64
65
  }
65
66
  /** Retained for consumers describing canonical textual publication inputs. */
66
- export declare const TEXT_MIME_PATTERN = "^(?:text/[a-z0-9!#$&^_.+-]+|application/(?:javascript|ecmascript|json|xml|[a-z0-9!#$&^_.+-]+\\+(?:json|xml))|image/svg\\+xml)(?![\\s\\S])";
67
+ export { TEXT_MIME_PATTERN } from './server/mime.js';
67
68
  /** Other mutation surfaces need their own qualification before host activation. */
68
69
  export declare function assertArtifactsTransportProfile(value: unknown): asserts value is CompiledArtifactsProfile;
69
70
  export declare function createHttpArtifactApp(deps: HttpAppDeps): ArtifactApp;
package/dist/http.js CHANGED
@@ -1,16 +1,22 @@
1
+ /** Selected HTTP control surface over the authorized atomic services. */
2
+ import { createHash } from 'node:crypto';
1
3
  import { parseManifest } from './codecs.js';
2
4
  import { assertCompiledArtifactsProfile, ArtifactProfileInputError } from './profile/index.js';
3
5
  import { createArtifactCommand } from './server/command-identity.js';
4
6
  import { createGuardedEnsureService } from './server/service.js';
5
7
  import { assertArtifactReplayMigration } from './server/idempotency.js';
6
8
  import { createGuardedPublicationService } from './server/publication.js';
9
+ import { applyEdits } from './server/edits.js';
7
10
  import { CommitQuarantineError } from './server/commit/types.js';
8
11
  import { ArtifactOperationError, classifyArtifactFailure } from './server/failures.js';
9
12
  import { projectArtifactHttpOutcome } from './server/outcome-projection.js';
10
13
  import { readArtifactSource } from './server/source-read.js';
11
14
  import { artifactSummary } from './server/summary.js';
15
+ import { artifactVersions } from './server/versions.js';
12
16
  import { manageArtifact } from './server/management.js';
13
17
  import { createPostgresManagementUnit } from './adapters/postgres-management.js';
18
+ import { TEXT_MIME_RE } from './server/mime.js';
19
+ import { API_GUIDE_REF } from './server/templates.js';
14
20
  export { projectArtifactHttpOutcome } from './server/outcome-projection.js';
15
21
  /** Stable content-route error codes; private driver messages never leave the host. */
16
22
  export function errorBody(status) {
@@ -19,7 +25,7 @@ export function errorBody(status) {
19
25
  500: 'internal_error', 503: 'capability_unavailable' }[status] ?? 'internal_error' };
20
26
  }
21
27
  /** Retained for consumers describing canonical textual publication inputs. */
22
- export const TEXT_MIME_PATTERN = '^(?:text/[a-z0-9!#$&^_.+-]+|application/(?:javascript|ecmascript|json|xml|[a-z0-9!#$&^_.+-]+\\+(?:json|xml))|image/svg\\+xml)(?![\\s\\S])';
28
+ export { TEXT_MIME_PATTERN } from './server/mime.js';
23
29
  function object(value) {
24
30
  if (typeof value !== 'object' || value === null || Array.isArray(value))
25
31
  throw new ArtifactProfileInputError();
@@ -29,7 +35,7 @@ function object(value) {
29
35
  export function assertArtifactsTransportProfile(value) {
30
36
  assertCompiledArtifactsProfile(value);
31
37
  const supported = ['artifacts_ensure', 'artifacts_publish', 'artifacts_list', 'artifacts_get',
32
- 'artifacts_get_source', 'artifacts_set_access', 'artifacts_delete'];
38
+ 'artifacts_list_versions', 'artifacts_get_source', 'artifacts_revert', 'artifacts_set_access', 'artifacts_delete'];
33
39
  if (value.selectedCapabilities.some((name) => !supported.includes(name))) {
34
40
  throw new Error('unsupported artifacts transport capability');
35
41
  }
@@ -52,23 +58,140 @@ export function createHttpArtifactApp(deps) {
52
58
  throw new Error('artifact delete confirmation port required');
53
59
  }
54
60
  const ensure = createGuardedEnsureService({ profile, coordinator: deps.coordinator,
55
- derivation: deps.derivation, host: deps.ensureHost });
56
- async function visible(artifactId, invocation, allowTerminalOwner = false) {
61
+ derivation: deps.derivation, host: {
62
+ lockBinding: (client, binding) => deps.ensureHost.lockBinding(client, binding),
63
+ abandonAttempt: id => deps.ensureHost.abandonAttempt(id),
64
+ readPolicyRevision: () => deps.ensureHost.readPolicyRevision(),
65
+ async resolveBinding(client, binding) {
66
+ const receipt = await deps.ensureHost.resolveBinding(client, binding);
67
+ if (receipt.created && binding.metadata) {
68
+ await deps.publication.createUnitWriter(client).updateMetadata(receipt.artifactId, binding.metadata);
69
+ }
70
+ return receipt;
71
+ },
72
+ } });
73
+ async function visible(artifactId, invocation, action = 'read') {
57
74
  const acl = await deps.acls.getArtifactAcl(artifactId);
58
75
  if (!acl || acl.storageScopeId !== invocation.storageScopeId)
59
76
  throw new ArtifactOperationError('not_found');
60
77
  const state = await deps.lifecycle.stateOf(artifactId);
61
78
  if (acl.ownerId === invocation.actorId && (state === 'deleted' || state === 'purged')) {
62
79
  // Management must still authorize terminal owners before replaying a receipt.
63
- if (allowTerminalOwner)
80
+ if (action === true)
64
81
  return acl;
65
82
  throw new ArtifactOperationError('gone');
66
83
  }
67
- if (!deps.lifecycle.canRead({ artifactId, state }, acl, invocation.actorId, 'read', deps.isApprovedAccount)) {
84
+ if (!deps.lifecycle.canRead({ artifactId, state }, acl, invocation.actorId, action === true ? 'read' : action, deps.isApprovedAccount)) {
68
85
  throw new ArtifactOperationError('not_found');
69
86
  }
70
87
  return acl;
71
88
  }
89
+ /** Display metadata fields present on validated args (ensure/publish). */
90
+ function metadataOf(normalized) {
91
+ const meta = {};
92
+ if (typeof normalized.title === 'string')
93
+ meta.title = normalized.title;
94
+ if (typeof normalized.description === 'string')
95
+ meta.description = normalized.description;
96
+ if (typeof normalized.icon === 'string' || normalized.icon === null)
97
+ meta.icon = normalized.icon;
98
+ return Object.keys(meta).length > 0 ? meta : undefined;
99
+ }
100
+ function blobKey(storageScopeId, digest) {
101
+ // Manifest digests are `sha256:<hex>`; physical keys are `.../blobs/sha256/<hex>`.
102
+ return `artifacts/${storageScopeId}/blobs/${digest.replace(':', '/')}`;
103
+ }
104
+ async function readManifestFiles(manifest, storageScopeId) {
105
+ const parsed = parseManifest(manifest);
106
+ const entries = parsed.files ?? [];
107
+ const files = [];
108
+ for (const entry of entries) {
109
+ const bytes = await deps.publication.blobs.getBlob(blobKey(storageScopeId, entry.digest));
110
+ if (bytes === undefined)
111
+ throw new ArtifactOperationError('release_unavailable', { release: { reason: 'not_retained' } });
112
+ if (bytes.byteLength !== entry.size || `sha256:${createHash('sha256').update(bytes).digest('hex')}` !== entry.digest) {
113
+ throw new ArtifactOperationError('verification_failed');
114
+ }
115
+ let text;
116
+ if (TEXT_MIME_RE.test(entry.mimeType)) {
117
+ try {
118
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
119
+ }
120
+ catch { /* Non-UTF-8 is not editable. */ }
121
+ }
122
+ files.push({ path: entry.path, mimeType: entry.mimeType, bytes: new Uint8Array(bytes),
123
+ ...(text === undefined ? {} : { text }) });
124
+ }
125
+ return files;
126
+ }
127
+ /** Apply publish edits to the current release's text files; typed edit_mismatch changes nothing. */
128
+ async function editedFiles(artifactId, invocation, edits, expected) {
129
+ const current = expected === undefined ? await deps.publication.getCurrent(artifactId)
130
+ : expected === null ? undefined : await deps.publication.getRelease(expected);
131
+ if (current === undefined) {
132
+ if (expected === null || expected === undefined)
133
+ throw new ArtifactOperationError('release_unavailable', { release: { reason: 'unpublished' } });
134
+ throw new ArtifactOperationError('not_found');
135
+ }
136
+ if (parseManifest(current.manifest).artifactId !== artifactId)
137
+ throw new ArtifactOperationError('not_found');
138
+ if (await deps.lifecycle.getReleaseState(current.releaseId) === 'purged') {
139
+ throw new ArtifactOperationError('release_unavailable', { release: { reason: 'purged' } });
140
+ }
141
+ const files = await readManifestFiles(current.manifest, invocation.storageScopeId);
142
+ return applyEdits(files, edits).map((file) => ({
143
+ path: file.path, mimeType: file.mimeType,
144
+ bytes: file.text === undefined ? file.bytes : new Uint8Array(Buffer.from(file.text, 'utf8')),
145
+ }));
146
+ }
147
+ async function conflictSource(outcome, normalized, invocation) {
148
+ if (outcome.ok || outcome.failure.kind !== 'operational' || outcome.failure.variant !== 'pointer_conflict')
149
+ return outcome;
150
+ const artifactId = normalized.artifactId;
151
+ await visible(artifactId, invocation, 'source');
152
+ const conflict = outcome.failure.conflict;
153
+ if (!conflict?.currentReleaseId || normalized.edits === undefined)
154
+ return outcome;
155
+ const release = await deps.publication.getRelease(conflict.currentReleaseId);
156
+ if (!release || await deps.lifecycle.getReleaseState(release.releaseId) === 'purged')
157
+ return outcome;
158
+ const manifest = parseManifest(release.manifest);
159
+ const paths = new Set(normalized.edits.map(edit => edit.path));
160
+ const entries = manifest.files;
161
+ const files = (await readManifestFiles(release.manifest, invocation.storageScopeId)).filter(file => paths.has(file.path))
162
+ .map(file => ({ path: file.path, mimeType: file.mimeType, size: file.bytes.byteLength,
163
+ digest: entries.find(entry => entry.path === file.path).digest,
164
+ ...(file.text === undefined
165
+ ? { base64: Buffer.from(file.bytes).toString('base64'), encoding: 'base64' }
166
+ : { text: file.text, encoding: 'utf8' }) }));
167
+ await visible(artifactId, invocation, 'source');
168
+ const failure = { ...outcome.failure, conflict: { ...conflict, currentSource: {
169
+ artifactId, releaseId: release.releaseId, digest: conflict.currentDigest, manifest: release.manifest,
170
+ files, sourceRef: null, guideRef: API_GUIDE_REF, ...deps.publicationUrls?.(artifactId, release.releaseId),
171
+ } } };
172
+ Object.defineProperty(failure, 'cause', { value: outcome.failure.cause, enumerable: false });
173
+ return { ok: false, failure: Object.freeze(failure) };
174
+ }
175
+ /** Shared guarded-commit response for publish and revert (same commit path, same receipt shape). */
176
+ function commitOutcome(outcome, artifactId, meta, setCommitState) {
177
+ if (!outcome.ok) {
178
+ const failure = { ...outcome.failure, kind: 'operational' };
179
+ Object.defineProperty(failure, 'cause', { value: outcome.failure.cause, enumerable: false });
180
+ return { ok: false, failure: Object.freeze(failure) };
181
+ }
182
+ setCommitState(outcome.commitState);
183
+ const receipt = object(outcome.result.receipt);
184
+ if (typeof receipt.releaseId !== 'string' || typeof receipt.manifest !== 'string') {
185
+ throw new Error('invalid publication receipt');
186
+ }
187
+ return { ...outcome, result: { status: 201, body: {
188
+ artifactId, releaseId: receipt.releaseId, manifest: receipt.manifest,
189
+ previousReleaseId: receipt.previousReleaseId ?? null, updatedAt: receipt.updatedAt ?? null,
190
+ ...(typeof receipt.revertedFromReleaseId === 'string' ? { revertedFromReleaseId: receipt.revertedFromReleaseId } : {}),
191
+ digest: parseManifest(receipt.manifest).digest,
192
+ ...deps.publicationUrls?.(artifactId, receipt.releaseId), ...(receipt.metadata ?? meta ?? {}),
193
+ } } };
194
+ }
72
195
  const app = {
73
196
  selectedCapabilities: profile.selectedCapabilities,
74
197
  profileDigest: profile.profileDigest,
@@ -96,10 +219,13 @@ export function createHttpArtifactApp(deps) {
96
219
  throw new ArtifactProfileInputError();
97
220
  }
98
221
  let args;
99
- if (capability === 'artifacts_get') {
222
+ const getPrefix = request.method !== 'GET' ? undefined : capability === 'artifacts_get' ? '/meta/'
223
+ : capability === 'artifacts_list_versions' ? '/versions/'
224
+ : capability === 'artifacts_get_source' ? '/source/' : undefined;
225
+ if (getPrefix !== undefined) {
100
226
  if (request.body !== undefined && Object.keys(object(request.body)).length)
101
227
  throw new ArtifactProfileInputError();
102
- args = { artifactId: request.path.slice('/meta/'.length) };
228
+ args = { artifactId: request.path.slice(getPrefix.length) };
103
229
  }
104
230
  else {
105
231
  args = { ...object(request.body ?? {}) };
@@ -109,9 +235,9 @@ export function createHttpArtifactApp(deps) {
109
235
  delete args.storageScopeId;
110
236
  }
111
237
  }
112
- const normalized = profile.validate(capability, args, request.idempotencyKey);
113
- const command = ['artifacts_ensure', 'artifacts_publish', 'artifacts_set_access', 'artifacts_delete'].includes(capability)
114
- ? createArtifactCommand(profile, capability, args, invocation, deps.derivation, request.idempotencyKey) : undefined;
238
+ const normalized = profile.validate(capability, args, request.idempotencyKey, { deferPrecondition: true });
239
+ const command = ['artifacts_ensure', 'artifacts_publish', 'artifacts_set_access', 'artifacts_delete', 'artifacts_revert'].includes(capability)
240
+ ? createArtifactCommand(profile, capability, args, invocation, deps.derivation, request.idempotencyKey, { deferPrecondition: true }) : undefined;
115
241
  if (command && ['ensure', 'publish'].includes(command.operation)) {
116
242
  phase = 'migration';
117
243
  await assertArtifactReplayMigration(deps.legacyRecords, command, invocation, normalized.idempotencyKey);
@@ -121,11 +247,20 @@ export function createHttpArtifactApp(deps) {
121
247
  const outcome = await ensure.ensure(invocation, { ...normalized,
122
248
  envelopeKey: request.idempotencyKey });
123
249
  if (outcome.ok) {
250
+ commitState = outcome.commitState;
124
251
  const state = await deps.lifecycle.stateOf(outcome.result.artifactId);
125
252
  if (state === 'deleted' || state === 'purged')
126
253
  throw new ArtifactOperationError('gone');
127
254
  }
128
- return outcome.ok ? { ...outcome, result: { status: 201, body: outcome.result } } : outcome;
255
+ if (!outcome.ok)
256
+ return outcome;
257
+ const artifactId = outcome.result.artifactId;
258
+ const acl = await visible(artifactId, invocation);
259
+ const summary = artifactSummary({ artifactId, acl, actorId: invocation.actorId,
260
+ state: await deps.lifecycle.stateOf(artifactId), current: await deps.publication.getCurrent(artifactId),
261
+ urls: deps.publicationUrls, approved: deps.isApprovedAccount });
262
+ return { ...outcome, result: { status: 201, body: profile.resultEnvelopeVersion === 2
263
+ ? { ...summary, ...outcome.result } : { ...outcome.result, ...acl.metadata } } };
129
264
  }
130
265
  if (capability === 'artifacts_get_source') {
131
266
  const artifactId = normalized.artifactId;
@@ -145,15 +280,18 @@ export function createHttpArtifactApp(deps) {
145
280
  commitState: 'not_committed', replayed: false };
146
281
  }
147
282
  if (capability === 'artifacts_set_access' || capability === 'artifacts_delete') {
148
- await visible(normalized.artifactId, invocation, true);
283
+ const acl = await visible(normalized.artifactId, invocation, true);
284
+ if (acl.ownerId !== invocation.actorId)
285
+ throw new ArtifactOperationError('access_denied');
149
286
  const outcome = await manageArtifact({ coordinator: deps.coordinator,
150
287
  createUnit: deps.management?.createUnit ?? createPostgresManagementUnit,
151
288
  confirmation: deps.management?.confirmation, readPolicyRevision: () => deps.ensureHost.readPolicyRevision(),
152
289
  isApprovedPrincipal: deps.isApprovedAccount ?? (() => false),
290
+ validateNewCommand: () => { profile.validate(capability, args, request.idempotencyKey); },
153
291
  }, invocation, command, normalized.confirmationRef);
154
292
  return outcome.ok ? { ...outcome, result: { status: 200, body: outcome.result } } : outcome;
155
293
  }
156
- if (capability === 'artifacts_publish') {
294
+ if (capability === 'artifacts_publish' || capability === 'artifacts_revert') {
157
295
  const artifactId = normalized.artifactId;
158
296
  const acl = await visible(artifactId, invocation);
159
297
  if (acl.ownerId !== invocation.actorId)
@@ -162,31 +300,53 @@ export function createHttpArtifactApp(deps) {
162
300
  const publication = createGuardedPublicationService({ ...deps.publication, coordinator: deps.coordinator,
163
301
  limits: { maxFiles: limits.publishFilesMax, maxFileBytes: limits.maxDecodedFileBytes,
164
302
  maxTotalBytes: limits.maxDecodedTotalBytes } });
165
- const outcome = await publication.publishFinalized({
166
- ownerId: acl.ownerId, storageScopeId: invocation.storageScopeId, artifactId,
303
+ const meta = metadataOf(normalized);
304
+ const input = { ownerId: acl.ownerId, storageScopeId: invocation.storageScopeId, artifactId,
167
305
  identity: command.identity, actorId: invocation.actorId, invocationId: invocation.invocationId,
168
306
  attemptId: invocation.invocationId, policyRevision: await deps.ensureHost.readPolicyRevision(),
169
- authorizeCommit: invocation.authorizeCommit,
170
- source: { kind: 'inline-original' }, profile: normalized.profile,
171
- files: normalized.files.map((file) => ({
172
- path: file.path, mimeType: file.mimeType, bytes: Uint8Array.from(file.bytes),
173
- })),
174
- });
175
- if (!outcome.ok) {
176
- const failure = { ...outcome.failure, kind: 'operational' };
177
- Object.defineProperty(failure, 'cause', { value: outcome.failure.cause, enumerable: false });
178
- return { ok: false, failure: Object.freeze(failure) };
307
+ authorizeCommit: invocation.authorizeCommit, metadata: meta,
308
+ operation: capability === 'artifacts_revert' ? 'revert' : 'publish',
309
+ expectedCurrentReleaseId: normalized.expectedCurrentReleaseId };
310
+ const replay = await publication.replayFinalized(input);
311
+ if (replay !== undefined)
312
+ return commitOutcome(replay, artifactId, meta, state => { commitState = state; });
313
+ profile.validate(capability, args, request.idempotencyKey);
314
+ let files;
315
+ let source;
316
+ let releaseProfile;
317
+ if (capability === 'artifacts_publish') {
318
+ files = normalized.edits !== undefined
319
+ ? await editedFiles(artifactId, invocation, normalized.edits, input.expectedCurrentReleaseId)
320
+ : normalized.files.map((file) => ({
321
+ path: file.path, mimeType: file.mimeType, bytes: Uint8Array.from(file.bytes),
322
+ }));
323
+ if (normalized.edits !== undefined) {
324
+ const { edits: _edits, ...snapshot } = normalized;
325
+ profile.validate(capability, { ...snapshot, files: files.map(file => ({ ...file, bytes: Array.from(file.bytes) })) });
326
+ }
327
+ source = { kind: 'inline-original' };
328
+ releaseProfile = normalized.profile;
179
329
  }
180
- commitState = outcome.commitState;
181
- const receipt = object(outcome.result.receipt);
182
- if (typeof receipt.releaseId !== 'string' || typeof receipt.manifest !== 'string') {
183
- throw new Error('invalid publication receipt');
330
+ else {
331
+ const releaseId = normalized[profile.resultEnvelopeVersion === 2 ? 'targetReleaseId' : 'releaseId'];
332
+ const target = await deps.publication.getRelease(releaseId);
333
+ const targetManifest = target === undefined ? undefined : parseManifest(target.manifest);
334
+ if (target === undefined || targetManifest?.artifactId !== artifactId) {
335
+ throw new ArtifactOperationError('not_found');
336
+ }
337
+ if (await deps.lifecycle.getReleaseState(releaseId) === 'purged') {
338
+ throw new ArtifactOperationError('release_unavailable', { release: { reason: 'purged' } });
339
+ }
340
+ files = (await readManifestFiles(target.manifest, invocation.storageScopeId))
341
+ .map((file) => ({ path: file.path, mimeType: file.mimeType, bytes: file.bytes }));
342
+ source = { kind: 'base-commit', commit: releaseId };
343
+ releaseProfile = (targetManifest.profile ?? {});
184
344
  }
185
- return { ...outcome, result: { status: 201, body: {
186
- artifactId, releaseId: receipt.releaseId, manifest: receipt.manifest,
187
- digest: parseManifest(receipt.manifest).digest,
188
- ...deps.publicationUrls?.(artifactId, receipt.releaseId),
189
- } } };
345
+ const outcome = await publication.publishFinalized({
346
+ ...input, source, profile: releaseProfile, files,
347
+ });
348
+ const committed = commitOutcome(outcome, artifactId, meta, (state) => { commitState = state; });
349
+ return await conflictSource(committed, normalized, invocation);
190
350
  }
191
351
  if (capability === 'artifacts_get') {
192
352
  const artifactId = normalized.artifactId;
@@ -196,8 +356,34 @@ export function createHttpArtifactApp(deps) {
196
356
  return { ok: true, invocationId: invocation.invocationId, commitState: 'not_committed', replayed: false,
197
357
  result: { status: 200, body: { ...artifactSummary({ artifactId, acl, state, current,
198
358
  actorId: invocation.actorId, urls: deps.publicationUrls, approved: deps.isApprovedAccount }),
359
+ ...(acl.metadata !== undefined ? { metadata: acl.metadata } : {}),
199
360
  ...(acl.ownerId === invocation.actorId ? { grants: acl.grants } : {}) } } };
200
361
  }
362
+ if (capability === 'artifacts_list_versions') {
363
+ const artifactId = normalized.artifactId;
364
+ await visible(artifactId, invocation);
365
+ const current = await deps.publication.getCurrent(artifactId);
366
+ if (profile.resultEnvelopeVersion === 2) {
367
+ const body = await artifactVersions({ artifactId, storageScopeId: invocation.storageScopeId,
368
+ currentReleaseId: current?.releaseId ?? null, releases: await deps.publication.listReleases(artifactId),
369
+ limit: normalized.limit, cursor: normalized.cursor,
370
+ state: id => deps.lifecycle.getReleaseState(id), urls: deps.publicationUrls });
371
+ await visible(artifactId, invocation);
372
+ return { ok: true, invocationId: invocation.invocationId, commitState: 'not_committed', replayed: false,
373
+ result: { status: 200, body } };
374
+ }
375
+ const releases = [];
376
+ for (const release of await deps.publication.listReleases(artifactId)) {
377
+ const manifest = parseManifest(release.manifest);
378
+ releases.push({ releaseId: release.releaseId, createdAt: release.createdAt,
379
+ author: typeof manifest.ownerId === 'string' ? manifest.ownerId : null,
380
+ state: await deps.lifecycle.getReleaseState(release.releaseId),
381
+ isCurrent: current?.releaseId === release.releaseId,
382
+ digest: release.manifestDigest });
383
+ }
384
+ return { ok: true, invocationId: invocation.invocationId, commitState: 'not_committed', replayed: false,
385
+ result: { status: 200, body: { artifactId, total: releases.length, releases } } };
386
+ }
201
387
  const visibleArtifacts = [];
202
388
  for (const artifactId of await deps.acls.listArtifactIds()) {
203
389
  const acl = await deps.acls.getArtifactAcl(artifactId);
@@ -217,7 +403,7 @@ export function createHttpArtifactApp(deps) {
217
403
  catch (cause) {
218
404
  const failure = classifyArtifactFailure(cause, { invocationId: invocation?.invocationId ?? '',
219
405
  operation, phase, commitState }, (error) => error instanceof ArtifactProfileInputError
220
- ? new ArtifactOperationError(error.category) : deps.mapHostError?.(error));
406
+ ? new ArtifactOperationError(error.category, { publicDetails: error.details }) : deps.mapHostError?.(error));
221
407
  if (cause instanceof CommitQuarantineError) {
222
408
  const migration = { ...failure, kind: 'operational', category: 'provider_unavailable',
223
409
  variant: 'legacy_quarantine', httpStatus: 503, publicCode: 'capability_unavailable', retriable: false };
@@ -227,7 +413,9 @@ export function createHttpArtifactApp(deps) {
227
413
  return { ok: false, failure };
228
414
  }
229
415
  },
230
- async handle(request, invocation) { return projectArtifactHttpOutcome(await app.execute(request, invocation)); },
416
+ async handle(request, invocation) {
417
+ return projectArtifactHttpOutcome(await app.execute(request, invocation), profile.resultEnvelopeVersion === 2);
418
+ },
231
419
  };
232
420
  return Object.freeze(app);
233
421
  }
@@ -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.5";
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"];
13
+ export declare const ARTIFACTS_PROVIDER_VERSION = "0.2.7";
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", "artifacts_list_versions", "artifacts_revert"];
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_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";
17
+ name: "artifacts_ensure" | "artifacts_publish" | "artifacts_set_access" | "artifacts_delete" | "artifacts_revert" | "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" | "artifacts_list_versions";
18
18
  description: string;
19
19
  mutability: import("@sentropic/mcp-platform").Mutability;
20
20
  }[];