@sentropic/artifacts 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md 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
@@ -76,7 +76,69 @@ Reconcile current authority independently of the restored ACL snapshot. Fresh
76
76
  authorized sessions create fresh flows; revoked readers stay denied. Rollback
77
77
  disables activation while retaining the additive schema and accepted artifacts.
78
78
 
79
- Package conformance lives in `tests/postgres-activation.test.ts` and
79
+ ### Importable host conformance (UP-T-2, 0.2.4)
80
+
81
+ The packed package exports the framework-independent host suite and its stable
82
+ vector IDs. Install the SQL asset through your test migration executor first:
83
+
84
+ ```ts
85
+ import assert from 'node:assert/strict';
86
+ import {
87
+ POSTGRES_ACTIVATION_VECTOR_IDS, runPostgresActivationConformance,
88
+ } from '@sentropic/artifacts/adapters/postgres-activation/vectors';
89
+
90
+ assert.equal(process.env.ACTIVATION_DISPOSABLE, '1');
91
+ const { rows: [{ databaseName }] } = await pool.query('SELECT current_database() AS "databaseName"');
92
+ assert.match(databaseName, /^test[_-][a-z0-9_-]+$/i); // Check before any schema setup/reset.
93
+ const passed = await runPostgresActivationConformance({
94
+ disposable: { confirmed: true, databaseName },
95
+ createStore: namespace => createPostgresActivationStore({ pool, namespace }),
96
+ inspectTicket, // Host query returning persisted issuedAt/expiresAt and current DB now, in milliseconds.
97
+ createStoreWithCommitAckLoss, // Host composition with acknowledgement loss AFTER real consume COMMIT.
98
+ wait: async (ms, client) => { await (client ?? pool).query('SELECT pg_sleep($1)', [ms / 1000]); },
99
+ onPass: id => console.log('activation vector passed:', id),
100
+ });
101
+ assert.deepEqual(passed, POSTGRES_ACTIVATION_VECTOR_IDS); // 16 vectors
102
+ ```
103
+
104
+ Supply your host's store composition in `createStore`, using the supplied namespace
105
+ and the same disposable PostgreSQL database/executor for every instance. Replica
106
+ instances must share durable state. `wait` uses database time and the supplied
107
+ leased client during authority checks, including when the pool has one connection.
108
+ `inspectTicket(namespace, ticketId)` reads `issued_ms`, `expires_ms` and
109
+ `floor(extract(epoch FROM clock_timestamp()) * 1000)` through that same executor.
110
+ `createStoreWithCommitAckLoss(namespace)` wraps the host's leased client: execute
111
+ COMMIT, then throw before returning its acknowledgement. Seed with the normal
112
+ store; inject only into the store returned by this hook. Both hooks are optional
113
+ in the interface for additive compatibility; their vectors always run and fail
114
+ if either hook is absent. A complete host fixture is in
115
+ `packages/cluster-mesh/tests/packaging/activation-vectors.spec.ts`.
116
+ Run in an isolated process: the clock-skew vector temporarily replaces `Date.now`
117
+ by +/-120 seconds and restores it even on failure. TypeScript hosts install
118
+ `@types/pg` alongside `pg` in their consumer project.
119
+ The runner throws with the failing vector ID and original cause; it returns all
120
+ 16 IDs only after every assertion passes. It uses fresh namespaces and invalidates
121
+ and cleans its records after each vector. Namespace rows remain in the disposable
122
+ database. The runner refuses without explicit disposable confirmation and an
123
+ actual database name starting `test_` or `test-`. Check `current_database()` on the
124
+ host executor before installing/resetting the schema. Never use a serving database.
125
+
126
+ The vectors cover namespace isolation, immutable records, all nine exact binding
127
+ comparisons, wrong IDs, denial/outage preservation, single use, concurrent replicas,
128
+ expiry without cleanup and after authority waits, bounded cleanup, invalidation
129
+ and fresh recovery, the 60-second TTL cap, flow/ticket association, DB-time-only
130
+ issuance/expiry under app clock skew, and `ActivationCommitUnknownError` without
131
+ a grant followed by replay denial. This adds
132
+ only a testing export in 0.2.4; adapter behavior and SQL bytes are unchanged.
133
+
134
+ `make -f packages/cluster-mesh/packaging.mk test-lazy-package-activation ENV=test-artifacts-upt2`
135
+ packs and installs the candidate in a clean consumer, verifies the SQL hash, and
136
+ runs all 16 vectors on that consumer's PostgreSQL executor (pool max 1 and 4).
137
+ It requires explicit disposable configuration and checks the JSON result for
138
+ nine passing tests and zero skipped tests; missing CI wiring fails. Follow with
139
+ `make down ENV=test-artifacts-upt2` (use the shared Docker lock for both commands).
140
+
141
+ Additional fault/process conformance lives in `tests/postgres-activation.test.ts` and
80
142
  `tests/postgres-activation.integration.test.ts`, with the independent process
81
143
  witness `tests/fixtures/activation-process.mjs`. Run through
82
144
  `make test-artifacts` and `make test-artifacts-integration` on an isolated test
@@ -355,3 +417,41 @@ publication, use the current package version and this bootstrap sequence
355
417
  3. After that, merges to `main` touching the artifacts package publish
356
418
  through the `publish-artifacts` job (`make publish-artifacts`, OIDC +
357
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,31 @@
1
+ import type { PoolClient } from 'pg';
2
+ import { type PostgresActivationStore } from './postgres-activation.js';
3
+ export declare const POSTGRES_ACTIVATION_VECTOR_IDS: readonly ["namespace-isolation", "immutable-records", "binding-mismatch", "wrong-ids", "authority-denial", "authority-outage", "single-use", "concurrent-consume", "expiry-without-cleanup", "expiry-after-authority", "bounded-cleanup", "invalidation-recovery", "ttl-cap", "flow-association", "db-time-skew", "commit-unknown"];
4
+ export type PostgresActivationVectorId = typeof POSTGRES_ACTIVATION_VECTOR_IDS[number];
5
+ export interface PostgresActivationConformanceHost {
6
+ /** Explicit opt-in; databaseName must be the executor's actual current_database(), starting test_ or test-. */
7
+ disposable?: {
8
+ confirmed: true;
9
+ databaseName: string;
10
+ };
11
+ /** Same disposable DB/executor for every namespace and replica; schema installed by the host. */
12
+ createStore(namespace: string): PostgresActivationStore;
13
+ /** Wait on DB time; use the supplied leased client during authority checks (supports max:1). */
14
+ wait(milliseconds: number, client?: PoolClient): Promise<void>;
15
+ /** Read persisted issuance/expiry and current DB milliseconds through the host executor. */
16
+ inspectTicket?(namespace: string, ticketId: string): Promise<{
17
+ issuedAt: number;
18
+ expiresAt: number;
19
+ now: number;
20
+ }>;
21
+ /** Same host composition, but throw AFTER the consume COMMIT succeeds, losing its acknowledgement. */
22
+ createStoreWithCommitAckLoss?(namespace: string): PostgresActivationStore;
23
+ onPass?(id: PostgresActivationVectorId): void;
24
+ }
25
+ /** Test-only, framework-independent host conformance. Throws on the first failing vector.
26
+ * Uses fresh isolated namespaces; invalidates and collects its own records after each vector.
27
+ * Process crashes and restore snapshots remain package integration tests.
28
+ * Optional hooks preserve interface compatibility; their vectors are mandatory and fail if absent.
29
+ * Run in an isolated process: db-time-skew temporarily replaces Date.now and always restores it.
30
+ */
31
+ export declare function runPostgresActivationConformance(host: PostgresActivationConformanceHost): Promise<PostgresActivationVectorId[]>;
@@ -0,0 +1,178 @@
1
+ import assert from 'node:assert/strict';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { ActivationCommitUnknownError } from './postgres-activation.js';
4
+ export const POSTGRES_ACTIVATION_VECTOR_IDS = Object.freeze([
5
+ 'namespace-isolation', 'immutable-records', 'binding-mismatch', 'wrong-ids',
6
+ 'authority-denial', 'authority-outage', 'single-use', 'concurrent-consume',
7
+ 'expiry-without-cleanup', 'expiry-after-authority', 'bounded-cleanup',
8
+ 'invalidation-recovery', 'ttl-cap', 'flow-association',
9
+ 'db-time-skew', 'commit-unknown',
10
+ ]);
11
+ /** Test-only, framework-independent host conformance. Throws on the first failing vector.
12
+ * Uses fresh isolated namespaces; invalidates and collects its own records after each vector.
13
+ * Process crashes and restore snapshots remain package integration tests.
14
+ * Optional hooks preserve interface compatibility; their vectors are mandatory and fail if absent.
15
+ * Run in an isolated process: db-time-skew temporarily replaces Date.now and always restores it.
16
+ */
17
+ export async function runPostgresActivationConformance(host) {
18
+ assert.ok(host.disposable?.confirmed === true, 'Explicit disposable database confirmation is required');
19
+ assert.match(host.disposable.databaseName, /^test[_-][a-z0-9_-]+$/i, 'A test database name is required');
20
+ const passed = [];
21
+ for (const id of POSTGRES_ACTIVATION_VECTOR_IDS) {
22
+ const namespace = `upt2:${randomUUID()}`;
23
+ const store = host.createStore(namespace);
24
+ const foreign = host.createStore(`${namespace}:other`);
25
+ const expected = { subjectId: 'alice', artifactId: 'a', releaseId: 'r', channel: 'stable',
26
+ nonceDigest: 'ab'.repeat(32), sessionId: 'session', profileDigest: 'cd'.repeat(32),
27
+ policyRevision: 'p1', targetOrigin: 'https://activation.example.test' };
28
+ async function seed(ttl = 50_000, target = store) {
29
+ assert.ok(target.tickets.issue, 'DB-time ticket issuance is required');
30
+ const ticket = await target.tickets.issue({ ...expected, ticketId: randomUUID() }, ttl);
31
+ const flow = { ...expected, ticketId: ticket.ticketId, flowId: randomUUID(), expiresAt: ticket.expiresAt };
32
+ await target.flows.put(flow);
33
+ return { ticket, flow, input: { ticketId: ticket.ticketId, flowId: flow.flowId, expected } };
34
+ }
35
+ try {
36
+ const { ticket, flow, input } = await seed();
37
+ const redeem = () => store.consume(input, async () => true);
38
+ const preserved = async () => {
39
+ assert.deepEqual(await store.tickets.peek(ticket.ticketId), ticket);
40
+ assert.deepEqual(await store.flows.peek(flow.flowId), flow);
41
+ };
42
+ if (id === 'namespace-isolation') {
43
+ assert.equal(await foreign.tickets.peek(ticket.ticketId), undefined);
44
+ assert.equal(await foreign.flows.peek(flow.flowId), undefined);
45
+ assert.equal(await foreign.consume(input, async () => true), undefined);
46
+ await foreign.tickets.remove(ticket.ticketId);
47
+ await preserved();
48
+ }
49
+ else if (id === 'immutable-records') {
50
+ await assert.rejects(store.tickets.put({ ...ticket, subjectId: 'mallory' }));
51
+ await assert.rejects(store.flows.put({ ...flow, sessionId: 'other' }));
52
+ await preserved();
53
+ }
54
+ else if (id === 'binding-mismatch') {
55
+ for (const key of Object.keys(expected)) {
56
+ const mismatch = { ...expected, [key]: key === 'channel' ? 'pinned' : 'different' };
57
+ assert.equal(await store.consume({ ...input, expected: mismatch }, async () => true), undefined, key);
58
+ }
59
+ await preserved();
60
+ assert.deepEqual(await redeem(), ticket);
61
+ }
62
+ else if (id === 'wrong-ids') {
63
+ for (const key of ['ticketId', 'flowId']) {
64
+ assert.equal(await store.consume({ ...input, [key]: randomUUID() }, async () => true), undefined);
65
+ }
66
+ await preserved();
67
+ }
68
+ else if (id === 'authority-denial') {
69
+ assert.equal(await store.consume(input, async () => false), undefined);
70
+ await preserved();
71
+ }
72
+ else if (id === 'authority-outage') {
73
+ const outage = new Error('conformance authority unavailable');
74
+ await assert.rejects(store.consume(input, async () => { throw outage; }), error => error === outage);
75
+ await preserved();
76
+ }
77
+ else if (id === 'single-use') {
78
+ assert.deepEqual(await redeem(), ticket);
79
+ assert.equal(await redeem(), undefined);
80
+ assert.equal(await store.flows.peek(flow.flowId), undefined);
81
+ }
82
+ else if (id === 'concurrent-consume') {
83
+ const replica = host.createStore(namespace);
84
+ const results = await Promise.all([redeem(), replica.consume(input, async () => true)]);
85
+ assert.deepEqual(results.filter(Boolean), [ticket]);
86
+ }
87
+ else if (id === 'expiry-without-cleanup' || id === 'expiry-after-authority') {
88
+ const expired = await seed(1000);
89
+ if (id === 'expiry-without-cleanup')
90
+ await host.wait(1200);
91
+ assert.equal(await store.consume(expired.input, async ({ client }) => {
92
+ await host.wait(1200, client);
93
+ return true;
94
+ }), undefined);
95
+ assert.equal(await store.tickets.peek(expired.ticket.ticketId), undefined);
96
+ assert.equal(await store.flows.peek(expired.flow.flowId), undefined);
97
+ }
98
+ else if (id === 'bounded-cleanup') {
99
+ for (let i = 0; i < 3; i++)
100
+ await seed(1000);
101
+ const other = await seed(50_000, foreign);
102
+ await host.wait(1200);
103
+ let total = 0;
104
+ for (let i = 0; i < 4; i++) {
105
+ const removed = await store.cleanup(2);
106
+ const count = removed.flows + removed.tickets;
107
+ assert.ok(count >= 0 && count <= 2);
108
+ total += count;
109
+ }
110
+ assert.equal(total, 6);
111
+ await preserved();
112
+ assert.deepEqual(await foreign.tickets.peek(other.ticket.ticketId), other.ticket);
113
+ }
114
+ else if (id === 'invalidation-recovery') {
115
+ await store.invalidate();
116
+ const replica = host.createStore(namespace);
117
+ assert.equal(await replica.consume(input, async () => true), undefined);
118
+ assert.equal(await replica.tickets.peek(ticket.ticketId), undefined);
119
+ assert.equal(await replica.flows.peek(flow.flowId), undefined);
120
+ const fresh = await seed();
121
+ assert.deepEqual(await replica.consume(fresh.input, async () => true), fresh.ticket);
122
+ }
123
+ else if (id === 'ttl-cap') {
124
+ for (const ttl of [0, 60_001]) {
125
+ await assert.rejects(store.tickets.issue({ ...expected, ticketId: randomUUID() }, ttl));
126
+ }
127
+ await seed(60_000);
128
+ }
129
+ else if (id === 'flow-association') {
130
+ await assert.rejects(store.flows.put({ ...flow, flowId: randomUUID(), subjectId: 'mallory' }));
131
+ await assert.rejects(store.flows.put({ ...flow, flowId: randomUUID(), expiresAt: ticket.expiresAt + 1 }));
132
+ }
133
+ else if (id === 'db-time-skew') {
134
+ assert.ok(host.inspectTicket, 'inspectTicket hook is required for db-time-skew');
135
+ const originalNow = Date.now;
136
+ try {
137
+ for (const skew of [-120_000, 120_000]) {
138
+ Date.now = () => originalNow() + skew;
139
+ const issued = await seed(60_000);
140
+ const db = await host.inspectTicket(namespace, issued.ticket.ticketId);
141
+ assert.equal(db.expiresAt - db.issuedAt, 60_000);
142
+ assert.equal(issued.ticket.expiresAt, db.expiresAt);
143
+ assert.ok(db.expiresAt - db.now > 59_000 && db.expiresAt - db.now <= 60_000);
144
+ assert.deepEqual(await store.consume(issued.input, async () => true), issued.ticket);
145
+ const expired = await seed(50);
146
+ await host.wait(80);
147
+ assert.equal(await store.consume(expired.input, async () => true), undefined);
148
+ assert.equal(await store.tickets.peek(expired.ticket.ticketId), undefined);
149
+ }
150
+ }
151
+ finally {
152
+ Date.now = originalNow;
153
+ }
154
+ }
155
+ else if (id === 'commit-unknown') {
156
+ assert.ok(host.createStoreWithCommitAckLoss, 'createStoreWithCommitAckLoss hook is required for commit-unknown');
157
+ const uncertain = host.createStoreWithCommitAckLoss(namespace);
158
+ const grants = [];
159
+ await assert.rejects(uncertain.consume(input, async () => true).then(result => grants.push(result)), error => error instanceof ActivationCommitUnknownError);
160
+ assert.deepEqual(grants, []);
161
+ assert.equal(await host.createStore(namespace).consume(input, async () => true), undefined);
162
+ assert.equal(await store.flows.peek(flow.flowId), undefined);
163
+ }
164
+ }
165
+ catch (cause) {
166
+ throw new Error(`UP-T conformance failed: ${id}`, { cause });
167
+ }
168
+ finally {
169
+ for (const target of [store, foreign]) {
170
+ await target.invalidate();
171
+ await target.cleanup(1000);
172
+ }
173
+ }
174
+ passed.push(id);
175
+ host.onPass?.(id);
176
+ }
177
+ return passed;
178
+ }
@@ -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.3";
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
  }[];