@sentropic/artifacts 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  Generic, host-independent artifact domain state for the Sentropic artifacts
4
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).
5
+ `0.1.2` adds UTF-8 text publication, exposes storage adapters and ships SQL migrations.
6
+ Version `0.1.1` fixed activation ticket release and channel binding (FL-ACT).
6
7
 
7
8
  ## What's here (L-5a)
8
9
 
@@ -48,6 +49,79 @@ make test-artifacts-integration API_PORT=9550 UI_PORT=5750 MAILDEV_UI_PORT=1650
48
49
 
49
50
  No Immo defaults, URLs or bindings in generic source.
50
51
 
52
+ ## Storage adapters and host migrations
53
+
54
+ Hosts import `createS3BlobStore` from `@sentropic/artifacts/adapters/s3`
55
+ and `createPostgresArtifactStore` from `@sentropic/artifacts/adapters/postgres`.
56
+ Install the optional peer `@aws-sdk/client-s3@^3.980.0` for S3 and
57
+ `pg@^8.12.0` for Postgres (`@types/pg` for TypeScript hosts). Pass a configured
58
+ `S3Client` and bucket to the S3 adapter and a `pg.Pool` to the Postgres adapter.
59
+ The core works without either driver; adapters do not create connections or
60
+ apply DDL at import or startup.
61
+
62
+ The installed package includes SQL resources exported under
63
+ `@sentropic/artifacts/migrations/*`. Read them through Node's resolver:
64
+
65
+ ```js
66
+ import { readFile } from 'node:fs/promises';
67
+
68
+ const migrations = ['0001_artifacts.sql', '0002_lifecycle.sql'];
69
+ for (const name of migrations) {
70
+ const sql = await readFile(new URL(import.meta.resolve(
71
+ `@sentropic/artifacts/migrations/${name}`,
72
+ )), 'utf8');
73
+ // Hand each SQL resource to the host's existing journaled migration runner.
74
+ }
75
+ ```
76
+
77
+ Apply `0001_artifacts.sql` first to create the domain schema, then
78
+ `0002_lifecycle.sql` to add lifecycle, sharing, sources and GC support, before
79
+ using the Postgres store. The host owns migration credentials, serialization,
80
+ transactions and the migration journal; execute each version through that
81
+ runner rather than running this read loop at application startup.
82
+
83
+ Before the first `/ensure` or `artifacts_ensure`, the host must provision an
84
+ `artifacts.storage_scopes` row for each storage scope it uses. Migration 0001
85
+ creates the table but does not seed it; the package neither provisions scopes
86
+ nor infers their organization. The artifact's `storage_scope_id` references
87
+ this row through a foreign key. For example, run a parameterized host query:
88
+
89
+ ```js
90
+ await pool.query(
91
+ 'INSERT INTO artifacts.storage_scopes (id, org_id) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING',
92
+ [storageScopeId, organizationId],
93
+ );
94
+ ```
95
+
96
+ Keep the scope ID stable, validate that any existing row belongs to the intended
97
+ organization, and use that ID as `storageScopeId` (or MCP's configured
98
+ `defaultStorageScopeId`). The host owns provisioning permissions and tenant
99
+ authorization; changing a binding or organization identifier does not provision
100
+ a scope automatically.
101
+
102
+ ## Inline publication: text or bytes
103
+
104
+ Each file in HTTP `POST /publish` and MCP `artifacts_publish` accepts exactly
105
+ one of `text` (a string, including the empty string) or `bytes` (integers 0–255).
106
+ The server encodes `text` as UTF-8 before calculating digests and byte sizes:
107
+
108
+ ```json
109
+ {
110
+ "artifactId": "<ensured artifact UUID>",
111
+ "files": [{ "path": "index.html", "mimeType": "text/html", "text": "<h1>Été à Montréal 🏠</h1>" }]
112
+ }
113
+ ```
114
+
115
+ `text` requires a canonical lowercase MIME type without parameters: `text/*`,
116
+ `application/javascript`, `application/ecmascript`, `application/json`,
117
+ `application/xml`, `application/*+json`, `application/*+xml`, or `image/svg+xml`.
118
+ Use `bytes` for binary formats such as PNG or PDF. Both fields together, neither
119
+ field, a non-string `text`, or a non-text MIME type with `text` are rejected.
120
+
121
+ Content responses include `Cross-Origin-Resource-Policy: same-origin`, preventing
122
+ cross-origin no-CORS resource embedding even between sibling subdomains sharing
123
+ a registrable domain. Assets within the same artifact origin remain loadable.
124
+
51
125
  ## What's here (L-5d–5e)
52
126
 
53
127
  - `src/content/grants.ts` — 55s HMAC content grants (60s cutoff budget) and
@@ -31,6 +31,7 @@ export function contentHeaders(input) {
31
31
  'content-type': withCharset,
32
32
  'content-length': String(input.size),
33
33
  'x-content-type-options': 'nosniff',
34
+ 'cross-origin-resource-policy': 'same-origin',
34
35
  'cache-control': cacheable ? `private, max-age=${input.assetCacheSeconds}` : 'no-store',
35
36
  'x-frame-options': 'DENY',
36
37
  'referrer-policy': 'no-referrer',
package/dist/http.d.ts CHANGED
@@ -37,6 +37,8 @@ export interface HttpAppDeps {
37
37
  export declare function errorBody(status: number): {
38
38
  error: string;
39
39
  };
40
+ /** Canonical text MIME types; parameters are omitted (encoding is always UTF-8). */
41
+ export declare const TEXT_MIME_PATTERN = "^(?:text/[a-z0-9!#$&^_.+-]+|application/(?:javascript|ecmascript|json|xml|[a-z0-9!#$&^_.+-]+\\+(?:json|xml))|image/svg\\+xml)(?![\\s\\S])";
40
42
  /** Create the HTTP app over injected services (no server, no fetch). */
41
43
  export declare function createHttpArtifactApp(deps: HttpAppDeps): {
42
44
  handle(request: HttpRequest): Promise<HttpResponse>;
package/dist/http.js CHANGED
@@ -84,6 +84,20 @@ function needBytes(value) {
84
84
  }
85
85
  return bytes;
86
86
  }
87
+ /** Canonical text MIME types; parameters are omitted (encoding is always UTF-8). */
88
+ export const TEXT_MIME_PATTERN = '^(?:text/[a-z0-9!#$&^_.+-]+|application/(?:javascript|ecmascript|json|xml|[a-z0-9!#$&^_.+-]+\\+(?:json|xml))|image/svg\\+xml)(?![\\s\\S])';
89
+ function needFileBytes(entry, mimeType) {
90
+ const hasBytes = 'bytes' in entry;
91
+ const hasText = 'text' in entry;
92
+ if (hasBytes === hasText)
93
+ throw new Error('invalid file: supply exactly one of bytes or text');
94
+ if (hasBytes)
95
+ return needBytes(entry['bytes']);
96
+ if (typeof entry['text'] !== 'string' || !new RegExp(TEXT_MIME_PATTERN).test(mimeType)) {
97
+ throw new Error('invalid file text: expected a string and a canonical text MIME type');
98
+ }
99
+ return new TextEncoder().encode(entry['text']);
100
+ }
87
101
  /** Create the HTTP app over injected services (no server, no fetch). */
88
102
  export function createHttpArtifactApp(deps) {
89
103
  const isApproved = deps.isApprovedAccount ?? (() => false);
@@ -177,10 +191,11 @@ export function createHttpArtifactApp(deps) {
177
191
  throw new Error('invalid request: missing files');
178
192
  const files = rawFiles.map((file) => {
179
193
  const entry = needObject(file);
194
+ const mimeType = needString(entry['mimeType'], 'file.mimeType');
180
195
  return {
181
196
  path: needString(entry['path'], 'file.path'),
182
- mimeType: needString(entry['mimeType'], 'file.mimeType'),
183
- bytes: needBytes(entry['bytes']),
197
+ mimeType,
198
+ bytes: needFileBytes(entry, mimeType),
184
199
  };
185
200
  });
186
201
  const source = body['source'];
package/dist/mcp.d.ts CHANGED
@@ -14,7 +14,7 @@ import type { ProviderParameterSchema, RegistryDefinition } from '@sentropic/mcp
14
14
  import type { HttpRequest, HttpResponse } from './http.js';
15
15
  export declare const ARTIFACTS_PROVIDER_ID = "sentropic.artifacts";
16
16
  export declare const ARTIFACTS_APP_ID = "sentropic-artifacts";
17
- export declare const ARTIFACTS_PROVIDER_VERSION = "0.1.0";
17
+ export declare const ARTIFACTS_PROVIDER_VERSION = "0.1.2";
18
18
  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"];
19
19
  export type ArtifactsToolName = (typeof ARTIFACTS_TOOL_NAMES)[number];
20
20
  type Schema = Record<string, unknown>;
package/dist/mcp.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { computeManifestDigest } from '@sentropic/mcp-platform/tool-configuration';
2
+ import { TEXT_MIME_PATTERN } from './http.js';
2
3
  export const ARTIFACTS_PROVIDER_ID = 'sentropic.artifacts';
3
4
  export const ARTIFACTS_APP_ID = 'sentropic-artifacts';
4
- export const ARTIFACTS_PROVIDER_VERSION = '0.1.0';
5
+ export const ARTIFACTS_PROVIDER_VERSION = '0.1.2';
5
6
  export const ARTIFACTS_TOOL_NAMES = [
6
7
  'artifacts_get_template', 'artifacts_get_api_guide', 'artifacts_ensure',
7
8
  'artifacts_publish', 'artifacts_create_upload', 'artifacts_commit_upload',
@@ -21,7 +22,13 @@ const record = {
21
22
  additionalProperties: { type: ['string', 'number', 'boolean', 'null'] },
22
23
  };
23
24
  const fileBytes = { type: 'array', items: int(0, 255) };
24
- const fileEntry = obj({ path: str, mimeType: str, bytes: fileBytes }, ['path', 'mimeType', 'bytes']);
25
+ const fileEntry = {
26
+ ...obj({ path: str, mimeType: str, bytes: fileBytes, text: { type: 'string', description: 'UTF-8 text; mutually exclusive with bytes; text MIME types only.' } }, ['path', 'mimeType']),
27
+ oneOf: [
28
+ { required: ['bytes'], not: { required: ['text'] } },
29
+ { required: ['text'], not: { required: ['bytes'] }, properties: { mimeType: { pattern: TEXT_MIME_PATTERN } } },
30
+ ],
31
+ };
25
32
  const sourceRef = obj({
26
33
  kind: { type: 'string', enum: ['inline-original', 'base-commit', 'recipe'] },
27
34
  commit: { type: 'string' },
@@ -0,0 +1,90 @@
1
+ -- L-5b — artifacts domain schema (spec-v8 §9).
2
+ --
3
+ -- Owned by the artifact domain; executed by the Immo host through its
4
+ -- existing migration journal (at most one root-app migration; preferred
5
+ -- Immo-owned adapter). Additive only: no ALTER/DROP of existing objects.
6
+ -- 5b tables: storage scopes, artifacts (stable bindings), releases, current
7
+ -- pointer, blobs, uploads, object intents, idempotency keys. Sharing/access,
8
+ -- lifecycle/GC and hostname reservations arrive with L-5c migrations.
9
+ -- Release states are CHECK-closed to ('ready') in this lot; L-5c extends
10
+ -- the set with its own additive migration.
11
+
12
+ CREATE SCHEMA IF NOT EXISTS artifacts;
13
+
14
+ -- Opaque storage scope isolating physical keys from org reindexing.
15
+ CREATE TABLE IF NOT EXISTS artifacts.storage_scopes (
16
+ id TEXT PRIMARY KEY,
17
+ org_id TEXT NOT NULL,
18
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
19
+ );
20
+
21
+ -- Artifact with owner-scoped stable binding (one UUID per owner+binding).
22
+ CREATE TABLE IF NOT EXISTS artifacts.artifacts (
23
+ id UUID PRIMARY KEY,
24
+ storage_scope_id TEXT NOT NULL REFERENCES artifacts.storage_scopes (id),
25
+ owner_id TEXT NOT NULL,
26
+ binding_name TEXT NOT NULL,
27
+ profile JSONB NOT NULL DEFAULT '{}',
28
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
29
+ UNIQUE (storage_scope_id, owner_id, binding_name)
30
+ );
31
+
32
+ -- Immutable sealed releases (canonical manifest bytes + digest). The manifest
33
+ -- is TEXT, not JSONB: JSONB key reordering would break byte-identity of the
34
+ -- content-addressed bytes; the digest covers the exact stored bytes.
35
+ CREATE TABLE IF NOT EXISTS artifacts.releases (
36
+ id UUID PRIMARY KEY,
37
+ artifact_id UUID NOT NULL REFERENCES artifacts.artifacts (id) ON DELETE CASCADE,
38
+ manifest_digest TEXT NOT NULL,
39
+ manifest TEXT NOT NULL,
40
+ state TEXT NOT NULL DEFAULT 'ready' CHECK (state IN ('ready')),
41
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
42
+ );
43
+ CREATE INDEX IF NOT EXISTS releases_by_artifact ON artifacts.releases (artifact_id);
44
+
45
+ -- Current release pointer (CAS-promoted; the loser's release stays orphan).
46
+ CREATE TABLE IF NOT EXISTS artifacts.artifact_current (
47
+ artifact_id UUID PRIMARY KEY REFERENCES artifacts.artifacts (id) ON DELETE CASCADE,
48
+ release_id UUID NOT NULL REFERENCES artifacts.releases (id) ON DELETE RESTRICT
49
+ );
50
+
51
+ -- Addressed blobs (deduplicated by scope+digest; pins arrive in L-5c).
52
+ CREATE TABLE IF NOT EXISTS artifacts.blobs (
53
+ storage_scope_id TEXT NOT NULL,
54
+ digest TEXT NOT NULL,
55
+ size INTEGER NOT NULL CHECK (size >= 0),
56
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
57
+ PRIMARY KEY (storage_scope_id, digest)
58
+ );
59
+
60
+ -- Bounded upload sessions (ephemeral staging ledger for GC).
61
+ CREATE TABLE IF NOT EXISTS artifacts.uploads (
62
+ id UUID PRIMARY KEY,
63
+ max_size INTEGER NOT NULL CHECK (max_size > 0),
64
+ size INTEGER NOT NULL DEFAULT 0 CHECK (size >= 0),
65
+ mime_type TEXT NOT NULL,
66
+ state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'committed', 'discarded')),
67
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
68
+ );
69
+ CREATE INDEX IF NOT EXISTS uploads_by_state ON artifacts.uploads (state);
70
+
71
+ -- Object intents recorded before every external operation.
72
+ CREATE TABLE IF NOT EXISTS artifacts.object_intents (
73
+ id UUID PRIMARY KEY,
74
+ kind TEXT NOT NULL CHECK (kind IN ('commit-release', 'delete-blob', 'promote-release')),
75
+ state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'done', 'failed')),
76
+ payload JSONB NOT NULL DEFAULT '{}',
77
+ error TEXT,
78
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
79
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
80
+ );
81
+ CREATE INDEX IF NOT EXISTS intents_pending ON artifacts.object_intents (state, created_at)
82
+ WHERE state = 'pending';
83
+
84
+ -- Operation idempotency: one stored result per key.
85
+ CREATE TABLE IF NOT EXISTS artifacts.idempotency_keys (
86
+ key TEXT PRIMARY KEY,
87
+ params_hash TEXT NOT NULL,
88
+ result JSONB NOT NULL,
89
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
90
+ );
@@ -0,0 +1,59 @@
1
+ -- L-5c — lifecycle, sharing, sources and GC schema (spec-v8 §9).
2
+ --
3
+ -- Additive upgrade over 0001: no column is dropped or narrowed, existing
4
+ -- rows keep working (defaults preserve 5b semantics: artifacts start
5
+ -- active, releases start ready). `commit` is a reserved word: the source
6
+ -- commit column is named `commit_tx`. Release states widen from ('ready')
7
+ -- to ('ready','retired','purged'); artifact lifecycle, grants, sources and
8
+ -- GC holders/pins are new tables. Sharing grants, lifecycle transitions and
9
+ -- refcounts stay consistent through the Postgres adapter (no api/src
10
+ -- imports); the host applies this file through its existing journal.
11
+
12
+ -- Artifact lifecycle state + renewal marker + public flag (approved accounts only, never anonymous).
13
+ ALTER TABLE artifacts.artifacts
14
+ ADD COLUMN IF NOT EXISTS lifecycle_state TEXT NOT NULL DEFAULT 'active'
15
+ CHECK (lifecycle_state IN ('active', 'withdrawn', 'deleted', 'purged')),
16
+ ADD COLUMN IF NOT EXISTS renewed_at TIMESTAMPTZ,
17
+ ADD COLUMN IF NOT EXISTS is_public BOOLEAN NOT NULL DEFAULT false;
18
+
19
+ -- Widen release states: ready (committed/current) → retired (supplanted) → purged (cascade).
20
+ ALTER TABLE artifacts.releases DROP CONSTRAINT IF EXISTS releases_state_check;
21
+ ALTER TABLE artifacts.releases
22
+ ADD CONSTRAINT releases_state_check CHECK (state IN ('ready', 'retired', 'purged'));
23
+
24
+ -- Sharing grants: exact (artifact, principal) pairs, no wildcards.
25
+ -- (`grant` is reserved in SQL; the column is `permission`, mapped to the
26
+ -- `grant` field by the adapter.)
27
+ CREATE TABLE IF NOT EXISTS artifacts.artifact_access (
28
+ artifact_id UUID NOT NULL REFERENCES artifacts.artifacts (id) ON DELETE CASCADE,
29
+ principal_id TEXT NOT NULL,
30
+ permission TEXT NOT NULL CHECK (permission IN ('read', 'source')),
31
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
32
+ PRIMARY KEY (artifact_id, principal_id)
33
+ );
34
+
35
+ -- Explicit source provenance per artifact (recipe/base commit/inline-original marker).
36
+ CREATE TABLE IF NOT EXISTS artifacts.artifact_sources (
37
+ artifact_id UUID PRIMARY KEY REFERENCES artifacts.artifacts (id) ON DELETE CASCADE,
38
+ kind TEXT NOT NULL CHECK (kind IN ('inline-original', 'base-commit', 'recipe')),
39
+ commit_tx TEXT,
40
+ steps JSONB,
41
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
42
+ );
43
+
44
+ -- GC refcount holders: one row per (digest, holder); counts derive by COUNT.
45
+ CREATE TABLE IF NOT EXISTS artifacts.blob_ref_holders (
46
+ digest TEXT NOT NULL,
47
+ holder TEXT NOT NULL,
48
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
49
+ PRIMARY KEY (digest, holder)
50
+ );
51
+ CREATE INDEX IF NOT EXISTS ref_holders_by_digest ON artifacts.blob_ref_holders (digest);
52
+
53
+ -- GC pins: explicit retention against concurrent collection.
54
+ CREATE TABLE IF NOT EXISTS artifacts.blob_pins (
55
+ digest TEXT NOT NULL,
56
+ reason TEXT NOT NULL,
57
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
58
+ PRIMARY KEY (digest, reason)
59
+ );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentropic/artifacts",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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",
@@ -24,6 +24,15 @@
24
24
  "types": "./dist/codecs.d.ts",
25
25
  "import": "./dist/codecs.js"
26
26
  },
27
+ "./adapters/s3": {
28
+ "types": "./dist/adapters/s3.d.ts",
29
+ "import": "./dist/adapters/s3.js"
30
+ },
31
+ "./adapters/postgres": {
32
+ "types": "./dist/adapters/postgres.d.ts",
33
+ "import": "./dist/adapters/postgres.js"
34
+ },
35
+ "./migrations/*": "./migrations/*",
27
36
  "./server/ports": {
28
37
  "types": "./dist/server/ports.d.ts",
29
38
  "import": "./dist/server/ports.js"
@@ -131,6 +140,7 @@
131
140
  },
132
141
  "files": [
133
142
  "dist",
143
+ "migrations",
134
144
  "README.md",
135
145
  "LICENSE"
136
146
  ],
@@ -143,21 +153,27 @@
143
153
  "typecheck": "tsc --noEmit -p tsconfig.json",
144
154
  "test": "vitest run tests"
145
155
  },
146
- "dependencies": {
156
+ "peerDependencies": {
147
157
  "@aws-sdk/client-s3": "^3.980.0",
158
+ "@sentropic/mcp-platform": ">=0.3.0 <0.5.0",
148
159
  "pg": "^8.12.0"
149
160
  },
150
- "peerDependencies": {
151
- "@sentropic/mcp-platform": ">=0.3.0 <0.4.0"
152
- },
153
161
  "peerDependenciesMeta": {
162
+ "@aws-sdk/client-s3": {
163
+ "optional": true
164
+ },
154
165
  "@sentropic/mcp-platform": {
155
166
  "optional": true
167
+ },
168
+ "pg": {
169
+ "optional": true
156
170
  }
157
171
  },
158
172
  "devDependencies": {
173
+ "@aws-sdk/client-s3": "^3.980.0",
159
174
  "@types/node": "^22.10.10",
160
175
  "@types/pg": "^8.15.5",
176
+ "pg": "^8.12.0",
161
177
  "typescript": "5.4.5",
162
178
  "vitest": "4.1.0"
163
179
  }