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