@sentropic/artifacts 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +95 -2
  3. package/dist/adapters/postgres.d.ts +35 -0
  4. package/dist/adapters/postgres.js +301 -0
  5. package/dist/adapters/s3.d.ts +19 -0
  6. package/dist/adapters/s3.js +70 -0
  7. package/dist/client.d.ts +23 -0
  8. package/dist/client.js +44 -0
  9. package/dist/codecs.d.ts +12 -0
  10. package/dist/codecs.js +77 -0
  11. package/dist/content/api-profile.d.ts +38 -0
  12. package/dist/content/api-profile.js +40 -0
  13. package/dist/content/grants.d.ts +57 -0
  14. package/dist/content/grants.js +143 -0
  15. package/dist/content/handler.d.ts +34 -0
  16. package/dist/content/handler.js +129 -0
  17. package/dist/content/headers.d.ts +25 -0
  18. package/dist/content/headers.js +56 -0
  19. package/dist/content/routing.d.ts +13 -0
  20. package/dist/content/routing.js +59 -0
  21. package/dist/contracts.d.ts +102 -0
  22. package/dist/contracts.js +25 -0
  23. package/dist/http.d.ts +43 -0
  24. package/dist/http.js +502 -0
  25. package/dist/index.d.ts +25 -0
  26. package/dist/index.js +29 -0
  27. package/dist/mcp.d.ts +68 -0
  28. package/dist/mcp.js +293 -0
  29. package/dist/parameters.d.ts +29 -0
  30. package/dist/parameters.js +65 -0
  31. package/dist/provider-definition.d.ts +36 -0
  32. package/dist/provider-definition.js +45 -0
  33. package/dist/server/access.d.ts +39 -0
  34. package/dist/server/access.js +53 -0
  35. package/dist/server/authorization.d.ts +17 -0
  36. package/dist/server/authorization.js +12 -0
  37. package/dist/server/bindings.d.ts +34 -0
  38. package/dist/server/bindings.js +60 -0
  39. package/dist/server/fork.d.ts +51 -0
  40. package/dist/server/fork.js +75 -0
  41. package/dist/server/gc.d.ts +24 -0
  42. package/dist/server/gc.js +90 -0
  43. package/dist/server/idempotency.d.ts +23 -0
  44. package/dist/server/idempotency.js +38 -0
  45. package/dist/server/lifecycle.d.ts +57 -0
  46. package/dist/server/lifecycle.js +184 -0
  47. package/dist/server/object-intents.d.ts +41 -0
  48. package/dist/server/object-intents.js +62 -0
  49. package/dist/server/ports.d.ts +34 -0
  50. package/dist/server/ports.js +50 -0
  51. package/dist/server/publication.d.ts +67 -0
  52. package/dist/server/publication.js +154 -0
  53. package/dist/server/reader-profile.d.ts +27 -0
  54. package/dist/server/reader-profile.js +18 -0
  55. package/dist/server/runtime.d.ts +33 -0
  56. package/dist/server/runtime.js +44 -0
  57. package/dist/server/service.d.ts +19 -0
  58. package/dist/server/service.js +32 -0
  59. package/dist/server/sources.d.ts +27 -0
  60. package/dist/server/sources.js +56 -0
  61. package/dist/server/templates.d.ts +33 -0
  62. package/dist/server/templates.js +86 -0
  63. package/dist/server/uploads.d.ts +29 -0
  64. package/dist/server/uploads.js +86 -0
  65. package/package.json +162 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Fabien Antoine
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,96 @@
1
- # Temporary Holding Version
1
+ # @sentropic/artifacts — generic artifact contracts (L-5a)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Generic, host-independent artifact domain state for the Sentropic artifacts
4
+ provider (`sentropic.artifacts`, spec-v8 §9), covering L-5a–5e. Version
5
+ `0.1.1` fixes activation ticket release and channel binding (FL-ACT).
6
+
7
+ ## What's here (L-5a)
8
+
9
+ - `src/contracts.ts` — schema versions, server-allocated UUID identities,
10
+ `CanonicalManifest`, JSON types. No server, driver or ORM imports.
11
+ - `src/codecs.ts` — canonical manifest bytes (recursive key sort, arrays as
12
+ unordered sets, no `undefined`) + SHA-256 digest; `parseManifest`
13
+ fail-closed (sealed documents verify, working documents carry no digest).
14
+ - `src/parameters.ts` — provider parameter schemas: defaults, unknown-key /
15
+ wrong-type / missing-required rejection, secret-looking key refusal.
16
+ - `src/provider-definition.ts` — exact `publicName → (providerId, capability)`
17
+ map; duplicate and cross-provider collisions rejected, never sanitized.
18
+ - `src/client.ts` — transport-free request builders / response parsers.
19
+ - `src/server/ports.ts` — injected `KvPort` / `UuidPort` / per-key mutex.
20
+ - `src/server/service.ts` — one service for HTTP and MCP: authorization
21
+ first, then idempotent binding ensure. Explicit ports, no defaults.
22
+ - `src/server/authorization.ts` — owner-only, deny by default (no sharing
23
+ grants in this lot; sharing arrives in L-5c).
24
+ - `src/server/bindings.ts` — owner-scoped stable bindings, scope fail-closed,
25
+ concurrent ensures converge.
26
+ - `src/server/idempotency.ts` — one result per key, explicit conflicts.
27
+
28
+ ## Gates
29
+
30
+ ```sh
31
+ make typecheck-artifacts API_PORT=9550 UI_PORT=5750 MAILDEV_UI_PORT=1650 ENV=test-immo-861-mcp-registry
32
+ make test-artifacts API_PORT=9550 UI_PORT=5750 MAILDEV_UI_PORT=1650 ENV=test-immo-861-mcp-registry
33
+ make test-artifacts-integration API_PORT=9550 UI_PORT=5750 MAILDEV_UI_PORT=1650 ENV=test-immo-861-mcp-registry
34
+ ```
35
+
36
+ ## What's here (L-5b–5c)
37
+
38
+ - `src/server/access.ts` — owner/grants/approved-public evaluation + ACL store.
39
+ - `src/server/sources.ts` — explicit source refs + source store.
40
+ - `src/server/fork.ts` — private independent forks, no ACL copy, refcounted blobs.
41
+ - `src/server/lifecycle.ts` — withdraw/restore/delete/purge, explicit
42
+ promote/rollback over the single publication current pointer, renew.
43
+ - `src/server/gc.ts` — refcounts, pins, reported collection.
44
+ - `src/http.ts` — transport-free HTTP app delegating to the same services
45
+ (401/403/404/409/410/422/400 mapping; reads never oracle existence).
46
+ - `migrations/0001_artifacts.sql` — domain schema (manifest TEXT for
47
+ byte-identity). Sharing/lifecycle/GC tables arrive with the 0002 migration.
48
+
49
+ No Immo defaults, URLs or bindings in generic source.
50
+
51
+ ## What's here (L-5d–5e)
52
+
53
+ - `src/content/grants.ts` — 55s HMAC content grants (60s cutoff budget) and
54
+ single-use activation tickets bound to subject, artifact, exact release
55
+ and channel (`stable` or `pinned`); secrets and clocks are injected.
56
+ - `src/content/routing.ts` — stable (`<artifactId>.<base>`) and pinned
57
+ (`<releaseId>.pinned.<base>`) single-UUID origins; safe path resolution.
58
+ - `src/content/headers.ts` — no-store/nosniff/frame-denial/CSP headers and
59
+ the host-only `__Host-artifacts_grant` cookie.
60
+ - `src/content/handler.ts` — transport-free content serving: per-version
61
+ activation, grant + live ACL recheck per request, raw bytes out.
62
+ - `src/content/api-profile.ts` — reader-delegation profile: exact
63
+ method/route/binding checks plus Origin/CSRF evidence for writes.
64
+ - `src/server/templates.ts` — generic template kit + versioned API guide
65
+ catalog port (hosts inject approved local assets).
66
+ - `src/mcp.ts` — the `sentropic.artifacts` MCP provider: 15 exact
67
+ `artifacts_*` tools with closed schemas, a host-bindable invocation
68
+ scope, an executor over the same HTTP app, a platform adapter, and the
69
+ `@sentropic/mcp-platform` kernel registry definition (the platform is
70
+ an optional peer, required only by the `./mcp` subpath).
71
+
72
+ Ticket issuance and redemption both require `subjectId`, `artifactId`,
73
+ `releaseId` and `channel`. The handler checks the release resolved from the
74
+ requested origin and its channel before minting a grant. A stable ticket
75
+ issued for r1 cannot activate r2 after republish, and a stable ticket cannot
76
+ activate a pinned origin (or vice versa). Mismatches return 401 without a
77
+ grant cookie and preserve the ticket for a matching request. Tickets expire
78
+ at `expiresAt` (120 seconds by default) and can be redeemed only once.
79
+
80
+ ## First publish (bootstrap)
81
+
82
+ The steady-state OIDC job requires an npm trusted publisher. For the first
83
+ publication, use the current package version and this bootstrap sequence
84
+ (owner-run, never automated here):
85
+
86
+ 1. Trigger the `bootstrap-publish` workflow manually with
87
+ `bootstrap_publish_target=artifacts` (runs
88
+ `make publish-artifacts-token` with the `NPM_TOKEN` secret; no OIDC
89
+ provenance on the token publish). Locally equivalent:
90
+ `make publish-artifacts-token NPM_TOKEN_FILE=/path/to/token`.
91
+ 2. On npmjs.com, open `@sentropic/artifacts` → Settings → Trusted
92
+ Publishers and attach the GitHub Actions publisher for
93
+ `rhanka/sentropic` workflow `ci.yml`.
94
+ 3. After that, merges to `main` touching the artifacts package publish
95
+ through the `publish-artifacts` job (`make publish-artifacts`, OIDC +
96
+ provenance), gated by `validate-artifacts`.
@@ -0,0 +1,35 @@
1
+ /**
2
+ * L-5b — Postgres adapter, part 1: stable bindings (spec-v8 §9).
3
+ *
4
+ * Over an injected pg Pool: no eager connections, no api/src imports, no DDL at
5
+ * runtime (the host applies `migrations/0001_artifacts.sql`). Concurrent ensures
6
+ * converge via INSERT ... ON CONFLICT DO NOTHING + reread. Parts 2-3 add
7
+ * intents, state promotion, idempotency and blob registration.
8
+ */
9
+ import type { Pool } from 'pg';
10
+ import type { JsonValue } from '../contracts.js';
11
+ import type { AclStore } from '../server/access.js';
12
+ import type { RefcountStore } from '../server/gc.js';
13
+ import type { LifecycleStore } from '../server/lifecycle.js';
14
+ import type { SourceStore } from '../server/sources.js';
15
+ import type { IntentStore } from '../server/object-intents.js';
16
+ import type { IdempotencyStore } from '../server/idempotency.js';
17
+ import type { PublicationStateStore } from '../server/publication.js';
18
+ export interface ArtifactBindingInput {
19
+ storageScopeId: string;
20
+ ownerId: string;
21
+ bindingName: string;
22
+ profile: Record<string, JsonValue | undefined>;
23
+ }
24
+ export interface ArtifactBinding {
25
+ artifactId: string;
26
+ created: boolean;
27
+ }
28
+ export interface PostgresArtifactBasics extends IntentStore {
29
+ ensureArtifact(input: ArtifactBindingInput): Promise<ArtifactBinding>;
30
+ }
31
+ export interface PostgresArtifactStore extends PostgresArtifactBasics, PublicationStateStore, IdempotencyStore, AclStore, SourceStore, LifecycleStore, RefcountStore {
32
+ listRegisteredBlobs(storageScopeId: string): Promise<string[]>;
33
+ }
34
+ /** Create the Postgres-backed store over an injected pool (no DDL). */
35
+ export declare function createPostgresArtifactStore(pool: Pool): PostgresArtifactStore;
@@ -0,0 +1,301 @@
1
+ function toIntent(row) {
2
+ return {
3
+ intentId: row.id,
4
+ kind: row.kind,
5
+ state: row.state,
6
+ payload: row.payload,
7
+ error: row.error ?? undefined,
8
+ createdAt: row.created_at.toISOString(),
9
+ updatedAt: row.updated_at.toISOString(),
10
+ };
11
+ }
12
+ function toRelease(row) {
13
+ return {
14
+ releaseId: row.id,
15
+ artifactId: row.artifact_id,
16
+ manifestDigest: row.manifest_digest,
17
+ manifest: row.manifest,
18
+ state: row.state,
19
+ createdAt: row.created_at.toISOString(),
20
+ };
21
+ }
22
+ /** Create the Postgres-backed store over an injected pool (no DDL). */
23
+ export function createPostgresArtifactStore(pool) {
24
+ return {
25
+ async ensureArtifact(input) {
26
+ const where = `WHERE storage_scope_id = $1 AND owner_id = $2 AND binding_name = $3`;
27
+ const args = [input.storageScopeId, input.ownerId, input.bindingName];
28
+ const found = await pool.query(`SELECT id FROM artifacts.artifacts ${where}`, args);
29
+ if (found.rowCount !== 0 && found.rows[0] !== undefined) {
30
+ return { artifactId: found.rows[0].id, created: false };
31
+ }
32
+ const inserted = await pool.query(`INSERT INTO artifacts.artifacts (id, storage_scope_id, owner_id, binding_name, profile)
33
+ VALUES (gen_random_uuid(), $1, $2, $3, $4)
34
+ ON CONFLICT (storage_scope_id, owner_id, binding_name) DO NOTHING RETURNING id`, [...args, input.profile]);
35
+ if (inserted.rowCount !== 0 && inserted.rows[0] !== undefined) {
36
+ return { artifactId: inserted.rows[0].id, created: true };
37
+ }
38
+ const raced = await pool.query(`SELECT id FROM artifacts.artifacts ${where}`, args);
39
+ const row = raced.rows[0];
40
+ if (row === undefined)
41
+ throw new Error(`artifact binding lost for ${input.bindingName}`);
42
+ return { artifactId: row.id, created: false };
43
+ },
44
+ async record(kind, payload) {
45
+ const { rows } = await pool.query(`INSERT INTO artifacts.object_intents (id, kind, payload)
46
+ VALUES (gen_random_uuid(), $1, $2)
47
+ RETURNING id, kind, state, payload, error, created_at, updated_at`, [kind, payload]);
48
+ const row = rows[0];
49
+ if (row === undefined)
50
+ throw new Error('intent insert failed');
51
+ return toIntent(row);
52
+ },
53
+ async complete(intentId) {
54
+ const done = await pool.query(`UPDATE artifacts.object_intents SET state = 'done', updated_at = now() WHERE id = $1`, [intentId]);
55
+ if (done.rowCount === 0)
56
+ throw new Error(`unknown intent: ${intentId}`);
57
+ },
58
+ async markFailed(intentId, error) {
59
+ const failed = await pool.query(`UPDATE artifacts.object_intents SET state = 'failed', error = $2, updated_at = now() WHERE id = $1`, [intentId, error]);
60
+ if (failed.rowCount === 0)
61
+ throw new Error(`unknown intent: ${intentId}`);
62
+ },
63
+ async get(intentId) {
64
+ const { rows } = await pool.query(`SELECT id, kind, state, payload, error, created_at, updated_at
65
+ FROM artifacts.object_intents WHERE id = $1`, [intentId]);
66
+ const row = rows[0];
67
+ return row === undefined ? undefined : toIntent(row);
68
+ },
69
+ async listPending() {
70
+ const { rows } = await pool.query(`SELECT id, kind, state, payload, error, created_at, updated_at
71
+ FROM artifacts.object_intents WHERE state = 'pending' ORDER BY created_at`);
72
+ return rows.map(toIntent);
73
+ },
74
+ async putRelease(release) {
75
+ const parsed = JSON.parse(release.manifest);
76
+ const client = await pool.connect();
77
+ try {
78
+ await client.query('BEGIN');
79
+ await client.query(`INSERT INTO artifacts.releases (id, artifact_id, manifest_digest, manifest, state, created_at)
80
+ VALUES ($1, $2, $3, $4, 'ready', $5)`, [release.releaseId, release.artifactId, release.manifestDigest, release.manifest, release.createdAt]);
81
+ const scope = await client.query(`SELECT storage_scope_id FROM artifacts.artifacts WHERE id = $1`, [release.artifactId]);
82
+ const scopeId = scope.rows[0]?.storage_scope_id;
83
+ if (scopeId === undefined)
84
+ throw new Error(`unknown artifact: ${release.artifactId}`);
85
+ for (const file of parsed.files ?? []) {
86
+ if (typeof file.digest !== 'string' || typeof file.size !== 'number')
87
+ continue;
88
+ await client.query(`INSERT INTO artifacts.blobs (storage_scope_id, digest, size)
89
+ VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, [scopeId, file.digest, file.size]);
90
+ }
91
+ await client.query('COMMIT');
92
+ }
93
+ catch (error) {
94
+ await client.query('ROLLBACK');
95
+ throw error;
96
+ }
97
+ finally {
98
+ client.release();
99
+ }
100
+ },
101
+ async getRelease(releaseId) {
102
+ const { rows } = await pool.query(`SELECT id, artifact_id, manifest_digest, manifest, state, created_at
103
+ FROM artifacts.releases WHERE id = $1`, [releaseId]);
104
+ const row = rows[0];
105
+ return row === undefined ? undefined : toRelease(row);
106
+ },
107
+ async getCurrentReleaseId(artifactId) {
108
+ const { rows } = await pool.query(`SELECT release_id FROM artifacts.artifact_current WHERE artifact_id = $1`, [artifactId]);
109
+ return rows[0]?.release_id;
110
+ },
111
+ async compareAndSwapCurrent(artifactId, expected, next) {
112
+ if (expected === null) {
113
+ const inserted = await pool.query(`INSERT INTO artifacts.artifact_current (artifact_id, release_id)
114
+ VALUES ($1, $2) ON CONFLICT (artifact_id) DO NOTHING`, [artifactId, next]);
115
+ return (inserted.rowCount ?? 0) !== 0;
116
+ }
117
+ const updated = await pool.query(`UPDATE artifacts.artifact_current SET release_id = $3
118
+ WHERE artifact_id = $1 AND release_id = $2`, [artifactId, expected, next]);
119
+ return (updated.rowCount ?? 0) !== 0;
120
+ },
121
+ async execute(key, paramsHash, work) {
122
+ const found = await pool.query(`SELECT params_hash, result FROM artifacts.idempotency_keys WHERE key = $1`, [`idempotency:${key}`]);
123
+ const stored = found.rows[0];
124
+ if (stored !== undefined) {
125
+ if (stored.params_hash !== paramsHash) {
126
+ throw new Error(`idempotency conflict: key ${key} already used with different parameters`);
127
+ }
128
+ return { result: stored.result, replayed: true };
129
+ }
130
+ const result = await work();
131
+ const inserted = await pool.query(`INSERT INTO artifacts.idempotency_keys (key, params_hash, result)
132
+ VALUES ($1, $2, $3) ON CONFLICT (key) DO NOTHING RETURNING key`, [`idempotency:${key}`, paramsHash, JSON.stringify(result)]);
133
+ if ((inserted.rowCount ?? 0) !== 0)
134
+ return { result, replayed: false };
135
+ const raced = await pool.query(`SELECT params_hash, result FROM artifacts.idempotency_keys WHERE key = $1`, [`idempotency:${key}`]);
136
+ const winner = raced.rows[0];
137
+ if (winner === undefined)
138
+ throw new Error(`idempotency record lost for ${key}`);
139
+ if (winner.params_hash !== paramsHash) {
140
+ throw new Error(`idempotency conflict: key ${key} already used with different parameters`);
141
+ }
142
+ return { result: winner.result, replayed: true };
143
+ },
144
+ async listRegisteredBlobs(storageScopeId) {
145
+ const { rows } = await pool.query(`SELECT digest FROM artifacts.blobs WHERE storage_scope_id = $1 ORDER BY digest`, [storageScopeId]);
146
+ return rows.map((row) => row.digest);
147
+ },
148
+ async getArtifactAcl(artifactId) {
149
+ const { rows } = await pool.query(`SELECT owner_id, storage_scope_id, is_public, lifecycle_state
150
+ FROM artifacts.artifacts WHERE id = $1`, [artifactId]);
151
+ const row = rows[0];
152
+ if (row === undefined)
153
+ return undefined;
154
+ const grants = await pool.query(`SELECT principal_id, permission FROM artifacts.artifact_access
155
+ WHERE artifact_id = $1 ORDER BY principal_id`, [artifactId]);
156
+ return {
157
+ ownerId: row.owner_id,
158
+ storageScopeId: row.storage_scope_id,
159
+ isPublic: row.is_public,
160
+ withdrawn: row.lifecycle_state === 'withdrawn',
161
+ grants: grants.rows.map((grant) => ({
162
+ principalId: grant.principal_id,
163
+ grant: grant.permission,
164
+ })),
165
+ };
166
+ },
167
+ async putArtifactAcl(artifactId, acl) {
168
+ await pool.query(`UPDATE artifacts.artifacts SET is_public = $2,
169
+ lifecycle_state = CASE WHEN $3 THEN 'withdrawn'
170
+ WHEN lifecycle_state = 'withdrawn' THEN 'active'
171
+ ELSE lifecycle_state END
172
+ WHERE id = $1`, [artifactId, acl.isPublic, acl.withdrawn]);
173
+ await pool.query(`DELETE FROM artifacts.artifact_access WHERE artifact_id = $1`, [
174
+ artifactId,
175
+ ]);
176
+ for (const grant of acl.grants) {
177
+ await pool.query(`INSERT INTO artifacts.artifact_access (artifact_id, principal_id, permission)
178
+ VALUES ($1, $2, $3)`, [artifactId, grant.principalId, grant.grant]);
179
+ }
180
+ },
181
+ async listArtifactIds() {
182
+ const { rows } = await pool.query(`SELECT id FROM artifacts.artifacts ORDER BY created_at ASC, id ASC`);
183
+ return rows.map((row) => row.id);
184
+ },
185
+ async getSource(artifactId) {
186
+ const { rows } = await pool.query(`SELECT kind, commit_tx, steps FROM artifacts.artifact_sources WHERE artifact_id = $1`, [artifactId]);
187
+ const row = rows[0];
188
+ if (row === undefined)
189
+ return undefined;
190
+ if (row.kind === 'inline-original')
191
+ return { kind: 'inline-original' };
192
+ if (row.kind === 'base-commit') {
193
+ return { kind: 'base-commit', commit: row.commit_tx ?? '' };
194
+ }
195
+ if (row.kind === 'recipe') {
196
+ return {
197
+ kind: 'recipe',
198
+ steps: Array.isArray(row.steps) ? row.steps.map((step) => String(step)) : [],
199
+ };
200
+ }
201
+ throw new Error(`unknown source kind: ${row.kind}`);
202
+ },
203
+ async putSource(artifactId, source) {
204
+ const commit = source.kind === 'base-commit' ? source.commit : null;
205
+ const steps = source.kind === 'recipe' ? JSON.stringify(source.steps) : null;
206
+ await pool.query(`INSERT INTO artifacts.artifact_sources (artifact_id, kind, commit_tx, steps)
207
+ VALUES ($1, $2, $3, $4::jsonb)
208
+ ON CONFLICT (artifact_id) DO UPDATE
209
+ SET kind = EXCLUDED.kind, commit_tx = EXCLUDED.commit_tx, steps = EXCLUDED.steps`, [artifactId, source.kind, commit, steps]);
210
+ },
211
+ async register(artifactId, ownerId) {
212
+ const { rows } = await pool.query(`SELECT owner_id FROM artifacts.artifacts WHERE id = $1`, [artifactId]);
213
+ const row = rows[0];
214
+ if (row === undefined)
215
+ throw new Error(`unknown artifact: ${artifactId}`);
216
+ if (row.owner_id !== ownerId)
217
+ throw new Error(`owner mismatch for ${artifactId}`);
218
+ },
219
+ async getOwner(artifactId) {
220
+ const { rows } = await pool.query(`SELECT owner_id FROM artifacts.artifacts WHERE id = $1`, [artifactId]);
221
+ return rows[0]?.owner_id;
222
+ },
223
+ async getArtifactState(artifactId) {
224
+ const { rows } = await pool.query(`SELECT lifecycle_state FROM artifacts.artifacts WHERE id = $1`, [artifactId]);
225
+ return rows[0]?.lifecycle_state ?? 'active';
226
+ },
227
+ async setArtifactState(artifactId, state) {
228
+ const updated = await pool.query(`UPDATE artifacts.artifacts SET lifecycle_state = $2 WHERE id = $1`, [artifactId, state]);
229
+ if ((updated.rowCount ?? 0) === 0)
230
+ throw new Error(`unknown artifact: ${artifactId}`);
231
+ },
232
+ async getRenewedAt(artifactId) {
233
+ const { rows } = await pool.query(`SELECT renewed_at FROM artifacts.artifacts WHERE id = $1`, [artifactId]);
234
+ return rows[0]?.renewed_at?.toISOString();
235
+ },
236
+ async setRenewedAt(artifactId, renewedAt) {
237
+ const updated = await pool.query(`UPDATE artifacts.artifacts SET renewed_at = $2 WHERE id = $1`, [artifactId, renewedAt]);
238
+ if ((updated.rowCount ?? 0) === 0)
239
+ throw new Error(`unknown artifact: ${artifactId}`);
240
+ },
241
+ async addRelease(artifactId, releaseId) {
242
+ const artifact = await pool.query(`SELECT id FROM artifacts.artifacts WHERE id = $1`, [
243
+ artifactId,
244
+ ]);
245
+ if ((artifact.rowCount ?? 0) === 0)
246
+ throw new Error(`unknown artifact: ${artifactId}`);
247
+ const release = await pool.query(`SELECT id FROM artifacts.releases WHERE id = $1`, [
248
+ releaseId,
249
+ ]);
250
+ if ((release.rowCount ?? 0) === 0)
251
+ throw new Error(`unknown release: ${releaseId}`);
252
+ },
253
+ async listArtifactReleases(artifactId) {
254
+ const { rows } = await pool.query(`SELECT id FROM artifacts.releases WHERE artifact_id = $1 ORDER BY created_at, id`, [artifactId]);
255
+ return rows.map((row) => row.id);
256
+ },
257
+ async getReleaseState(releaseId) {
258
+ const { rows } = await pool.query(`SELECT state FROM artifacts.releases WHERE id = $1`, [releaseId]);
259
+ return rows[0]?.state ?? 'ready';
260
+ },
261
+ async setReleaseState(releaseId, state) {
262
+ const updated = await pool.query(`UPDATE artifacts.releases SET state = $2 WHERE id = $1`, [
263
+ releaseId,
264
+ state,
265
+ ]);
266
+ if ((updated.rowCount ?? 0) === 0)
267
+ throw new Error(`unknown release: ${releaseId}`);
268
+ },
269
+ async addRef(digest, holder) {
270
+ await pool.query(`INSERT INTO artifacts.blob_ref_holders (digest, holder) VALUES ($1, $2)
271
+ ON CONFLICT (digest, holder) DO NOTHING`, [digest, holder]);
272
+ },
273
+ async releaseRef(digest, holder) {
274
+ const deleted = await pool.query(`DELETE FROM artifacts.blob_ref_holders WHERE digest = $1 AND holder = $2`, [digest, holder]);
275
+ if ((deleted.rowCount ?? 0) === 0) {
276
+ throw new Error(`refcount not held: ${digest} by ${holder}`);
277
+ }
278
+ },
279
+ async pin(digest, reason) {
280
+ const inserted = await pool.query(`INSERT INTO artifacts.blob_pins (digest, reason) VALUES ($1, $2)
281
+ ON CONFLICT (digest, reason) DO NOTHING RETURNING digest`, [digest, reason]);
282
+ if ((inserted.rowCount ?? 0) === 0) {
283
+ throw new Error(`duplicate pin: ${digest} (${reason})`);
284
+ }
285
+ },
286
+ async unpin(digest, reason) {
287
+ const deleted = await pool.query(`DELETE FROM artifacts.blob_pins WHERE digest = $1 AND reason = $2`, [digest, reason]);
288
+ if ((deleted.rowCount ?? 0) === 0) {
289
+ throw new Error(`not pinned: ${digest} (${reason})`);
290
+ }
291
+ },
292
+ async count(digest) {
293
+ const { rows } = await pool.query(`SELECT COUNT(*) AS total FROM artifacts.blob_ref_holders WHERE digest = $1`, [digest]);
294
+ return Number(rows[0]?.total ?? 0);
295
+ },
296
+ async isPinned(digest) {
297
+ const { rows } = await pool.query(`SELECT EXISTS (SELECT 1 FROM artifacts.blob_pins WHERE digest = $1) AS found`, [digest]);
298
+ return rows[0]?.found ?? false;
299
+ },
300
+ };
301
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * L-5b — S3 adapter: digest-addressed blob store (spec-v8 §9).
3
+ *
4
+ * Implements PublicationBlobStore over an injected S3 client and bucket.
5
+ * Only keys under `artifacts/<scope>/blobs/sha256/<64 hex>` are accepted;
6
+ * anything else (traversal, absolute paths, foreign prefixes, non-hex
7
+ * digests) is refused before any network call. Absent objects read as
8
+ * undefined; listing is prefix-scoped and sorted (orphan enumeration).
9
+ * The client (endpoint, region, credentials, path style) is built by the
10
+ * host and injected — this module never reads env or creates connections.
11
+ */
12
+ import { type S3Client } from '@aws-sdk/client-s3';
13
+ import type { PublicationBlobStore } from '../server/publication.js';
14
+ export interface S3BlobStoreOptions {
15
+ client: S3Client;
16
+ bucket: string;
17
+ }
18
+ /** Create an S3-backed blob store over an injected client (no env reads). */
19
+ export declare function createS3BlobStore(options: S3BlobStoreOptions): PublicationBlobStore;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * L-5b — S3 adapter: digest-addressed blob store (spec-v8 §9).
3
+ *
4
+ * Implements PublicationBlobStore over an injected S3 client and bucket.
5
+ * Only keys under `artifacts/<scope>/blobs/sha256/<64 hex>` are accepted;
6
+ * anything else (traversal, absolute paths, foreign prefixes, non-hex
7
+ * digests) is refused before any network call. Absent objects read as
8
+ * undefined; listing is prefix-scoped and sorted (orphan enumeration).
9
+ * The client (endpoint, region, credentials, path style) is built by the
10
+ * host and injected — this module never reads env or creates connections.
11
+ */
12
+ import { DeleteObjectCommand, GetObjectCommand, ListObjectsV2Command, PutObjectCommand, } from '@aws-sdk/client-s3';
13
+ const BLOB_KEY_PATTERN = /^artifacts\/[A-Za-z0-9_-]+\/blobs\/sha256\/[0-9a-f]{64}$/;
14
+ /** Reject anything but digest-addressed blob keys, before any I/O. */
15
+ function assertBlobKey(key) {
16
+ if (!BLOB_KEY_PATTERN.test(key)) {
17
+ throw new Error(`invalid blob key (must be artifacts/<scope>/blobs/sha256/<hex>): ${key}`);
18
+ }
19
+ }
20
+ /** Reject empty or escaping prefixes before any I/O. */
21
+ function assertPrefix(prefix) {
22
+ if (prefix.length === 0 || prefix.includes('..')) {
23
+ throw new Error(`invalid blob prefix: ${prefix}`);
24
+ }
25
+ }
26
+ /** Create an S3-backed blob store over an injected client (no env reads). */
27
+ export function createS3BlobStore(options) {
28
+ return {
29
+ async putBlob(key, bytes) {
30
+ assertBlobKey(key);
31
+ await options.client.send(new PutObjectCommand({ Bucket: options.bucket, Key: key, Body: bytes }));
32
+ },
33
+ async getBlob(key) {
34
+ assertBlobKey(key);
35
+ try {
36
+ const out = await options.client.send(new GetObjectCommand({ Bucket: options.bucket, Key: key }));
37
+ if (out.Body === undefined)
38
+ return undefined;
39
+ const collected = await out.Body.transformToByteArray();
40
+ return new Uint8Array(collected);
41
+ }
42
+ catch (error) {
43
+ if (typeof error === 'object' && error !== null && 'name' in error) {
44
+ const name = error.name;
45
+ if (name === 'NoSuchKey' || name === 'NotFound')
46
+ return undefined;
47
+ }
48
+ throw error;
49
+ }
50
+ },
51
+ async listKeys(prefix) {
52
+ assertPrefix(prefix);
53
+ const keys = [];
54
+ let token;
55
+ do {
56
+ const out = await options.client.send(new ListObjectsV2Command({ Bucket: options.bucket, Prefix: prefix, ContinuationToken: token }));
57
+ for (const entry of out.Contents ?? []) {
58
+ if (entry.Key !== undefined)
59
+ keys.push(entry.Key);
60
+ }
61
+ token = out.IsTruncated === true ? out.NextContinuationToken : undefined;
62
+ } while (token !== undefined);
63
+ return keys.sort();
64
+ },
65
+ async deleteBlob(key) {
66
+ assertBlobKey(key);
67
+ await options.client.send(new DeleteObjectCommand({ Bucket: options.bucket, Key: key }));
68
+ },
69
+ };
70
+ }
@@ -0,0 +1,23 @@
1
+ import { type ParameterSchema } from './parameters.js';
2
+ export interface EnsureRequest {
3
+ bindingName: string;
4
+ storageScopeId: string;
5
+ idempotencyKey: string;
6
+ paramsHash: string;
7
+ parameters: Record<string, string | number | boolean | null>;
8
+ }
9
+ export interface EnsureResponse {
10
+ artifactId: string;
11
+ created: boolean;
12
+ replayed: boolean;
13
+ }
14
+ /** Build a validated ensure request for a provider's parameter schema. */
15
+ export declare function buildEnsureRequest(schema: ParameterSchema, input: {
16
+ bindingName: string;
17
+ storageScopeId: string;
18
+ idempotencyKey: string;
19
+ paramsHash: string;
20
+ parameters: Record<string, string | number | boolean | null | undefined>;
21
+ }): EnsureRequest;
22
+ /** Parse and validate an ensure response (server-allocated identity). */
23
+ export declare function parseEnsureResponse(raw: unknown): EnsureResponse;
package/dist/client.js ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * L-5a — public artifact client builders (spec-v8 §9).
3
+ *
4
+ * No I/O, no server, driver or cluster-mesh imports: this module builds
5
+ * validated ensure requests and parses ensure responses for any transport
6
+ * (HTTP in L-5c, MCP in L-5e). Parameter values are validated against the
7
+ * provider's schema up front; response identities are checked to be
8
+ * server-allocated UUIDs clients cannot forge.
9
+ */
10
+ import { isArtifactId } from './contracts.js';
11
+ import { validateParameters } from './parameters.js';
12
+ /** Build a validated ensure request for a provider's parameter schema. */
13
+ export function buildEnsureRequest(schema, input) {
14
+ if (input.bindingName.length === 0)
15
+ throw new Error('invalid ensure request: empty bindingName');
16
+ if (input.storageScopeId.length === 0)
17
+ throw new Error('invalid ensure request: empty storageScopeId');
18
+ if (input.idempotencyKey.length === 0)
19
+ throw new Error('invalid ensure request: empty idempotencyKey');
20
+ const parameters = validateParameters(schema, input.parameters);
21
+ return {
22
+ bindingName: input.bindingName,
23
+ storageScopeId: input.storageScopeId,
24
+ idempotencyKey: input.idempotencyKey,
25
+ paramsHash: input.paramsHash,
26
+ parameters,
27
+ };
28
+ }
29
+ /** Parse and validate an ensure response (server-allocated identity). */
30
+ export function parseEnsureResponse(raw) {
31
+ if (typeof raw !== 'object' || raw === null)
32
+ throw new Error('invalid ensure response: not an object');
33
+ const response = raw;
34
+ if (!isArtifactId(response['artifactId']))
35
+ throw new Error('invalid ensure response: forged artifactId');
36
+ if (typeof response['created'] !== 'boolean' || typeof response['replayed'] !== 'boolean') {
37
+ throw new Error('invalid ensure response: bad flags');
38
+ }
39
+ return {
40
+ artifactId: response['artifactId'],
41
+ created: response['created'],
42
+ replayed: response['replayed'],
43
+ };
44
+ }
@@ -0,0 +1,12 @@
1
+ import { type CanonicalManifest } from './contracts.js';
2
+ /** Deterministic serialization of a manifest (recursive key sort, no `undefined`). */
3
+ export declare function canonicalManifest(manifest: CanonicalManifest): string;
4
+ /** SHA-256 hex digest of the canonical manifest bytes. */
5
+ export declare function manifestDigest(manifest: CanonicalManifest): string;
6
+ /**
7
+ * Parse and validate a manifest, fail-closed: JSON object, known schema
8
+ * version, required `artifactId`. A present `digest` must match the body
9
+ * recomputed without it; an absent digest means unsealed working document
10
+ * (construction precedes sealing) and is accepted as-is.
11
+ */
12
+ export declare function parseManifest(raw: string): CanonicalManifest;