@sentropic/artifacts 0.2.2 → 0.2.4

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
@@ -1,5 +1,154 @@
1
1
  # @sentropic/artifacts — generic artifact contracts (L-5a)
2
2
 
3
+ ## Shared PostgreSQL activation (UP-T, 0.2.3)
4
+
5
+ W1.c/W3.a and Radar S1/A6 use the same imports:
6
+
7
+ ```ts
8
+ import {
9
+ createPostgresActivationStore, ACTIVATION_MAX_TTL_SECONDS, ACTIVATION_SQL_ASSET,
10
+ ACTIVATION_SQL_ASSET_SHA256,
11
+ } from '@sentropic/artifacts/adapters/postgres-activation';
12
+ import { createTicketService } from '@sentropic/artifacts/content/grants';
13
+ import { readFile } from 'node:fs/promises';
14
+
15
+ const sql = await readFile(new URL(import.meta.resolve(ACTIVATION_SQL_ASSET)), 'utf8');
16
+ // Apply these exact bytes once through the host's journaled migration executor.
17
+ const activation = createPostgresActivationStore({ pool, namespace: trustedHostNamespace });
18
+ const tickets = createTicketService({ store: activation.tickets, ttlSeconds: ACTIVATION_MAX_TTL_SECONDS });
19
+ ```
20
+
21
+ The SQL asset is `@sentropic/artifacts/sql/0001_activation.sql`; it creates the
22
+ independent `artifact_host` schema. The adapter never applies DDL or opens a
23
+ connection on construction. Journal the asset hash and verify it against
24
+ `ACTIVATION_SQL_ASSET_SHA256` before serving. A unit test pins the exact file bytes;
25
+ future schema changes require a new numbered SQL asset.
26
+ This store is independent of the publication coordinator.
27
+
28
+ Persist a flow through `activation.flows.put`: `flowId`, `ticketId`, `subjectId`,
29
+ `artifactId`, `releaseId`, `channel`, `nonceDigest`, `sessionId`, `profileDigest`,
30
+ `policyRevision`, `targetOrigin`, `expiresAt`. Nonce/profile digests are lowercase
31
+ SHA-256 hex; the origin is an exact HTTPS origin. One flow is associated with one
32
+ ticket. Both IDs must be fresh cryptographically random values, never reused on
33
+ retry or after cleanup. Duplicate IDs reject; records cannot be updated. The
34
+ trusted namespace comes from host configuration, never browser input.
35
+
36
+ `createTicketService` uses the adapter's optional `tickets.issue(record, ttlMs)`
37
+ port: PostgreSQL samples its clock after lock waits and adds the requested TTL
38
+ in SQL. The returned deadline is authoritative; issuance and redemption tolerate
39
+ application clock skew. Use that returned deadline for the browser flow, whose
40
+ expiry must be live and no later than its ticket. Low-level `tickets.put` imports
41
+ an absolute PostgreSQL-time deadline and enforces the same 60-second cap. The historical
42
+ 120-second default of `createTicketService` is unchanged; hosts explicitly select
43
+ `ttlSeconds <= ACTIVATION_MAX_TTL_SECONDS` for this adapter.
44
+
45
+ For browser activation call `activation.consume({flowId,ticketId,expected},
46
+ authorize)`. `expected` contains every flow binding above except IDs and expiry.
47
+ Obtain these values from verified session/browser evidence and compiled policy.
48
+ The adapter locks namespace, flow, then ticket on one leased READ COMMITTED client,
49
+ checks exact bindings and database-time expiry after lock waits, invokes current
50
+ authority, and checks expiry again after authority waits at the atomic deletion.
51
+ Missing/expired/wrong bindings and explicit denial return `undefined`; authority
52
+ outage and transaction failures reject. Neither path permits a grant.
53
+
54
+ The host's `authorize({client,namespace,ticket,flow})` must recheck current session,
55
+ ACL, policy/profile, exact origin and stable-release fence on that client, holding
56
+ the relevant policy locks until COMMIT. Return `true` only with this protection.
57
+ Do not sign, set cookies, or perform externally visible effects in this callback.
58
+ Sign and emit a host-only cookie only after `consume` resolves with a record.
59
+ `ActivationCommitUnknownError` means issue no grant; obtain a fresh ticket/flow
60
+ after a new authority check. A crash after commit similarly requires a fresh
61
+ flow. Do not automatically replay a consumed ticket. Standalone `tickets.take`
62
+ implements the existing port; browser activation uses the combined operation.
63
+
64
+ Schedule `cleanup(limit)` at least once per minute and collect counts/errors
65
+ without token values. It deletes at most `limit` total rows (default 100, maximum
66
+ 1000), skips locked candidates and respects namespace boundaries. Expiry denial
67
+ works even when cleanup stops. Ordinary application-process restart preserves
68
+ live state and single use.
69
+
70
+ During restore keep both replicas/issuers closed, restore the activation tables
71
+ and migration journal alongside business/ACL state, call `invalidate()` for each
72
+ trusted namespace, and rotate the host signing credential before admitting
73
+ either replica. Invalidation advances a durable namespace generation in O(1);
74
+ all restored tickets/flows deny immediately and bounded cleanup removes them.
75
+ Reconcile current authority independently of the restored ACL snapshot. Fresh
76
+ authorized sessions create fresh flows; revoked readers stay denied. Rollback
77
+ disables activation while retaining the additive schema and accepted artifacts.
78
+
79
+ ### Importable host conformance (UP-T-2, 0.2.4)
80
+
81
+ The packed package exports the framework-independent host suite and its stable
82
+ vector IDs. Install the SQL asset through your test migration executor first:
83
+
84
+ ```ts
85
+ import assert from 'node:assert/strict';
86
+ import {
87
+ POSTGRES_ACTIVATION_VECTOR_IDS, runPostgresActivationConformance,
88
+ } from '@sentropic/artifacts/adapters/postgres-activation/vectors';
89
+
90
+ assert.equal(process.env.ACTIVATION_DISPOSABLE, '1');
91
+ const { rows: [{ databaseName }] } = await pool.query('SELECT current_database() AS "databaseName"');
92
+ assert.match(databaseName, /^test[_-][a-z0-9_-]+$/i); // Check before any schema setup/reset.
93
+ const passed = await runPostgresActivationConformance({
94
+ disposable: { confirmed: true, databaseName },
95
+ createStore: namespace => createPostgresActivationStore({ pool, namespace }),
96
+ inspectTicket, // Host query returning persisted issuedAt/expiresAt and current DB now, in milliseconds.
97
+ createStoreWithCommitAckLoss, // Host composition with acknowledgement loss AFTER real consume COMMIT.
98
+ wait: async (ms, client) => { await (client ?? pool).query('SELECT pg_sleep($1)', [ms / 1000]); },
99
+ onPass: id => console.log('activation vector passed:', id),
100
+ });
101
+ assert.deepEqual(passed, POSTGRES_ACTIVATION_VECTOR_IDS); // 16 vectors
102
+ ```
103
+
104
+ Supply your host's store composition in `createStore`, using the supplied namespace
105
+ and the same disposable PostgreSQL database/executor for every instance. Replica
106
+ instances must share durable state. `wait` uses database time and the supplied
107
+ leased client during authority checks, including when the pool has one connection.
108
+ `inspectTicket(namespace, ticketId)` reads `issued_ms`, `expires_ms` and
109
+ `floor(extract(epoch FROM clock_timestamp()) * 1000)` through that same executor.
110
+ `createStoreWithCommitAckLoss(namespace)` wraps the host's leased client: execute
111
+ COMMIT, then throw before returning its acknowledgement. Seed with the normal
112
+ store; inject only into the store returned by this hook. Both hooks are optional
113
+ in the interface for additive compatibility; their vectors always run and fail
114
+ if either hook is absent. A complete host fixture is in
115
+ `packages/cluster-mesh/tests/packaging/activation-vectors.spec.ts`.
116
+ Run in an isolated process: the clock-skew vector temporarily replaces `Date.now`
117
+ by +/-120 seconds and restores it even on failure. TypeScript hosts install
118
+ `@types/pg` alongside `pg` in their consumer project.
119
+ The runner throws with the failing vector ID and original cause; it returns all
120
+ 16 IDs only after every assertion passes. It uses fresh namespaces and invalidates
121
+ and cleans its records after each vector. Namespace rows remain in the disposable
122
+ database. The runner refuses without explicit disposable confirmation and an
123
+ actual database name starting `test_` or `test-`. Check `current_database()` on the
124
+ host executor before installing/resetting the schema. Never use a serving database.
125
+
126
+ The vectors cover namespace isolation, immutable records, all nine exact binding
127
+ comparisons, wrong IDs, denial/outage preservation, single use, concurrent replicas,
128
+ expiry without cleanup and after authority waits, bounded cleanup, invalidation
129
+ and fresh recovery, the 60-second TTL cap, flow/ticket association, DB-time-only
130
+ issuance/expiry under app clock skew, and `ActivationCommitUnknownError` without
131
+ a grant followed by replay denial. This adds
132
+ only a testing export in 0.2.4; adapter behavior and SQL bytes are unchanged.
133
+
134
+ `make -f packages/cluster-mesh/packaging.mk test-lazy-package-activation ENV=test-artifacts-upt2`
135
+ packs and installs the candidate in a clean consumer, verifies the SQL hash, and
136
+ runs all 16 vectors on that consumer's PostgreSQL executor (pool max 1 and 4).
137
+ It requires explicit disposable configuration and checks the JSON result for
138
+ nine passing tests and zero skipped tests; missing CI wiring fails. Follow with
139
+ `make down ENV=test-artifacts-upt2` (use the shared Docker lock for both commands).
140
+
141
+ Additional fault/process conformance lives in `tests/postgres-activation.test.ts` and
142
+ `tests/postgres-activation.integration.test.ts`, with the independent process
143
+ witness `tests/fixtures/activation-process.mjs`. Run through
144
+ `make test-artifacts` and `make test-artifacts-integration` on an isolated test
145
+ environment. The vectors cover namespace/binding isolation, immutable IDs,
146
+ one connection, no pre-commit result, uncertain commit, DB expiry after lock and
147
+ authority waits, bounded cleanup, two OS processes, restart, race, crashes before
148
+ and after commit, restored snapshot invalidation and fresh authority recovery.
149
+ Host migration/journal, actual session/ACL fences, signing-key rotation and real
150
+ browser/deployment receipts remain W1.c/W3.a/A6 obligations.
151
+
3
152
  Version 0.2.1 fixes host commit authorization being masked by the built-in
4
153
  publication guard and wires the optional trusted `mapHostError` dependency.
5
154
  Publication retains its lifecycle, ACL and policy revision checks; the host
@@ -0,0 +1,31 @@
1
+ import type { PoolClient } from 'pg';
2
+ import { type PostgresActivationStore } from './postgres-activation.js';
3
+ export declare const POSTGRES_ACTIVATION_VECTOR_IDS: readonly ["namespace-isolation", "immutable-records", "binding-mismatch", "wrong-ids", "authority-denial", "authority-outage", "single-use", "concurrent-consume", "expiry-without-cleanup", "expiry-after-authority", "bounded-cleanup", "invalidation-recovery", "ttl-cap", "flow-association", "db-time-skew", "commit-unknown"];
4
+ export type PostgresActivationVectorId = typeof POSTGRES_ACTIVATION_VECTOR_IDS[number];
5
+ export interface PostgresActivationConformanceHost {
6
+ /** Explicit opt-in; databaseName must be the executor's actual current_database(), starting test_ or test-. */
7
+ disposable?: {
8
+ confirmed: true;
9
+ databaseName: string;
10
+ };
11
+ /** Same disposable DB/executor for every namespace and replica; schema installed by the host. */
12
+ createStore(namespace: string): PostgresActivationStore;
13
+ /** Wait on DB time; use the supplied leased client during authority checks (supports max:1). */
14
+ wait(milliseconds: number, client?: PoolClient): Promise<void>;
15
+ /** Read persisted issuance/expiry and current DB milliseconds through the host executor. */
16
+ inspectTicket?(namespace: string, ticketId: string): Promise<{
17
+ issuedAt: number;
18
+ expiresAt: number;
19
+ now: number;
20
+ }>;
21
+ /** Same host composition, but throw AFTER the consume COMMIT succeeds, losing its acknowledgement. */
22
+ createStoreWithCommitAckLoss?(namespace: string): PostgresActivationStore;
23
+ onPass?(id: PostgresActivationVectorId): void;
24
+ }
25
+ /** Test-only, framework-independent host conformance. Throws on the first failing vector.
26
+ * Uses fresh isolated namespaces; invalidates and collects its own records after each vector.
27
+ * Process crashes and restore snapshots remain package integration tests.
28
+ * Optional hooks preserve interface compatibility; their vectors are mandatory and fail if absent.
29
+ * Run in an isolated process: db-time-skew temporarily replaces Date.now and always restores it.
30
+ */
31
+ export declare function runPostgresActivationConformance(host: PostgresActivationConformanceHost): Promise<PostgresActivationVectorId[]>;
@@ -0,0 +1,178 @@
1
+ import assert from 'node:assert/strict';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { ActivationCommitUnknownError } from './postgres-activation.js';
4
+ export const POSTGRES_ACTIVATION_VECTOR_IDS = Object.freeze([
5
+ 'namespace-isolation', 'immutable-records', 'binding-mismatch', 'wrong-ids',
6
+ 'authority-denial', 'authority-outage', 'single-use', 'concurrent-consume',
7
+ 'expiry-without-cleanup', 'expiry-after-authority', 'bounded-cleanup',
8
+ 'invalidation-recovery', 'ttl-cap', 'flow-association',
9
+ 'db-time-skew', 'commit-unknown',
10
+ ]);
11
+ /** Test-only, framework-independent host conformance. Throws on the first failing vector.
12
+ * Uses fresh isolated namespaces; invalidates and collects its own records after each vector.
13
+ * Process crashes and restore snapshots remain package integration tests.
14
+ * Optional hooks preserve interface compatibility; their vectors are mandatory and fail if absent.
15
+ * Run in an isolated process: db-time-skew temporarily replaces Date.now and always restores it.
16
+ */
17
+ export async function runPostgresActivationConformance(host) {
18
+ assert.ok(host.disposable?.confirmed === true, 'Explicit disposable database confirmation is required');
19
+ assert.match(host.disposable.databaseName, /^test[_-][a-z0-9_-]+$/i, 'A test database name is required');
20
+ const passed = [];
21
+ for (const id of POSTGRES_ACTIVATION_VECTOR_IDS) {
22
+ const namespace = `upt2:${randomUUID()}`;
23
+ const store = host.createStore(namespace);
24
+ const foreign = host.createStore(`${namespace}:other`);
25
+ const expected = { subjectId: 'alice', artifactId: 'a', releaseId: 'r', channel: 'stable',
26
+ nonceDigest: 'ab'.repeat(32), sessionId: 'session', profileDigest: 'cd'.repeat(32),
27
+ policyRevision: 'p1', targetOrigin: 'https://activation.example.test' };
28
+ async function seed(ttl = 50_000, target = store) {
29
+ assert.ok(target.tickets.issue, 'DB-time ticket issuance is required');
30
+ const ticket = await target.tickets.issue({ ...expected, ticketId: randomUUID() }, ttl);
31
+ const flow = { ...expected, ticketId: ticket.ticketId, flowId: randomUUID(), expiresAt: ticket.expiresAt };
32
+ await target.flows.put(flow);
33
+ return { ticket, flow, input: { ticketId: ticket.ticketId, flowId: flow.flowId, expected } };
34
+ }
35
+ try {
36
+ const { ticket, flow, input } = await seed();
37
+ const redeem = () => store.consume(input, async () => true);
38
+ const preserved = async () => {
39
+ assert.deepEqual(await store.tickets.peek(ticket.ticketId), ticket);
40
+ assert.deepEqual(await store.flows.peek(flow.flowId), flow);
41
+ };
42
+ if (id === 'namespace-isolation') {
43
+ assert.equal(await foreign.tickets.peek(ticket.ticketId), undefined);
44
+ assert.equal(await foreign.flows.peek(flow.flowId), undefined);
45
+ assert.equal(await foreign.consume(input, async () => true), undefined);
46
+ await foreign.tickets.remove(ticket.ticketId);
47
+ await preserved();
48
+ }
49
+ else if (id === 'immutable-records') {
50
+ await assert.rejects(store.tickets.put({ ...ticket, subjectId: 'mallory' }));
51
+ await assert.rejects(store.flows.put({ ...flow, sessionId: 'other' }));
52
+ await preserved();
53
+ }
54
+ else if (id === 'binding-mismatch') {
55
+ for (const key of Object.keys(expected)) {
56
+ const mismatch = { ...expected, [key]: key === 'channel' ? 'pinned' : 'different' };
57
+ assert.equal(await store.consume({ ...input, expected: mismatch }, async () => true), undefined, key);
58
+ }
59
+ await preserved();
60
+ assert.deepEqual(await redeem(), ticket);
61
+ }
62
+ else if (id === 'wrong-ids') {
63
+ for (const key of ['ticketId', 'flowId']) {
64
+ assert.equal(await store.consume({ ...input, [key]: randomUUID() }, async () => true), undefined);
65
+ }
66
+ await preserved();
67
+ }
68
+ else if (id === 'authority-denial') {
69
+ assert.equal(await store.consume(input, async () => false), undefined);
70
+ await preserved();
71
+ }
72
+ else if (id === 'authority-outage') {
73
+ const outage = new Error('conformance authority unavailable');
74
+ await assert.rejects(store.consume(input, async () => { throw outage; }), error => error === outage);
75
+ await preserved();
76
+ }
77
+ else if (id === 'single-use') {
78
+ assert.deepEqual(await redeem(), ticket);
79
+ assert.equal(await redeem(), undefined);
80
+ assert.equal(await store.flows.peek(flow.flowId), undefined);
81
+ }
82
+ else if (id === 'concurrent-consume') {
83
+ const replica = host.createStore(namespace);
84
+ const results = await Promise.all([redeem(), replica.consume(input, async () => true)]);
85
+ assert.deepEqual(results.filter(Boolean), [ticket]);
86
+ }
87
+ else if (id === 'expiry-without-cleanup' || id === 'expiry-after-authority') {
88
+ const expired = await seed(1000);
89
+ if (id === 'expiry-without-cleanup')
90
+ await host.wait(1200);
91
+ assert.equal(await store.consume(expired.input, async ({ client }) => {
92
+ await host.wait(1200, client);
93
+ return true;
94
+ }), undefined);
95
+ assert.equal(await store.tickets.peek(expired.ticket.ticketId), undefined);
96
+ assert.equal(await store.flows.peek(expired.flow.flowId), undefined);
97
+ }
98
+ else if (id === 'bounded-cleanup') {
99
+ for (let i = 0; i < 3; i++)
100
+ await seed(1000);
101
+ const other = await seed(50_000, foreign);
102
+ await host.wait(1200);
103
+ let total = 0;
104
+ for (let i = 0; i < 4; i++) {
105
+ const removed = await store.cleanup(2);
106
+ const count = removed.flows + removed.tickets;
107
+ assert.ok(count >= 0 && count <= 2);
108
+ total += count;
109
+ }
110
+ assert.equal(total, 6);
111
+ await preserved();
112
+ assert.deepEqual(await foreign.tickets.peek(other.ticket.ticketId), other.ticket);
113
+ }
114
+ else if (id === 'invalidation-recovery') {
115
+ await store.invalidate();
116
+ const replica = host.createStore(namespace);
117
+ assert.equal(await replica.consume(input, async () => true), undefined);
118
+ assert.equal(await replica.tickets.peek(ticket.ticketId), undefined);
119
+ assert.equal(await replica.flows.peek(flow.flowId), undefined);
120
+ const fresh = await seed();
121
+ assert.deepEqual(await replica.consume(fresh.input, async () => true), fresh.ticket);
122
+ }
123
+ else if (id === 'ttl-cap') {
124
+ for (const ttl of [0, 60_001]) {
125
+ await assert.rejects(store.tickets.issue({ ...expected, ticketId: randomUUID() }, ttl));
126
+ }
127
+ await seed(60_000);
128
+ }
129
+ else if (id === 'flow-association') {
130
+ await assert.rejects(store.flows.put({ ...flow, flowId: randomUUID(), subjectId: 'mallory' }));
131
+ await assert.rejects(store.flows.put({ ...flow, flowId: randomUUID(), expiresAt: ticket.expiresAt + 1 }));
132
+ }
133
+ else if (id === 'db-time-skew') {
134
+ assert.ok(host.inspectTicket, 'inspectTicket hook is required for db-time-skew');
135
+ const originalNow = Date.now;
136
+ try {
137
+ for (const skew of [-120_000, 120_000]) {
138
+ Date.now = () => originalNow() + skew;
139
+ const issued = await seed(60_000);
140
+ const db = await host.inspectTicket(namespace, issued.ticket.ticketId);
141
+ assert.equal(db.expiresAt - db.issuedAt, 60_000);
142
+ assert.equal(issued.ticket.expiresAt, db.expiresAt);
143
+ assert.ok(db.expiresAt - db.now > 59_000 && db.expiresAt - db.now <= 60_000);
144
+ assert.deepEqual(await store.consume(issued.input, async () => true), issued.ticket);
145
+ const expired = await seed(50);
146
+ await host.wait(80);
147
+ assert.equal(await store.consume(expired.input, async () => true), undefined);
148
+ assert.equal(await store.tickets.peek(expired.ticket.ticketId), undefined);
149
+ }
150
+ }
151
+ finally {
152
+ Date.now = originalNow;
153
+ }
154
+ }
155
+ else if (id === 'commit-unknown') {
156
+ assert.ok(host.createStoreWithCommitAckLoss, 'createStoreWithCommitAckLoss hook is required for commit-unknown');
157
+ const uncertain = host.createStoreWithCommitAckLoss(namespace);
158
+ const grants = [];
159
+ await assert.rejects(uncertain.consume(input, async () => true).then(result => grants.push(result)), error => error instanceof ActivationCommitUnknownError);
160
+ assert.deepEqual(grants, []);
161
+ assert.equal(await host.createStore(namespace).consume(input, async () => true), undefined);
162
+ assert.equal(await store.flows.peek(flow.flowId), undefined);
163
+ }
164
+ }
165
+ catch (cause) {
166
+ throw new Error(`UP-T conformance failed: ${id}`, { cause });
167
+ }
168
+ finally {
169
+ for (const target of [store, foreign]) {
170
+ await target.invalidate();
171
+ await target.cleanup(1000);
172
+ }
173
+ }
174
+ passed.push(id);
175
+ host.onPass?.(id);
176
+ }
177
+ return passed;
178
+ }
@@ -0,0 +1,59 @@
1
+ import type { Pool, PoolClient } from 'pg';
2
+ import type { TicketBinding, TicketRecord, TicketStore } from '../content/grants.js';
3
+ export declare const ACTIVATION_MAX_TTL_SECONDS = 60;
4
+ export declare const ACTIVATION_SQL_ASSET = "@sentropic/artifacts/sql/0001_activation.sql";
5
+ export declare const ACTIVATION_SQL_ASSET_SHA256 = "13767c826a62aa5f880809ca2ebc60bdc638b2e59f983e195d4351356b282d8b";
6
+ export interface BrowserFlowBinding extends TicketBinding {
7
+ nonceDigest: string;
8
+ sessionId: string;
9
+ profileDigest: string;
10
+ policyRevision: string;
11
+ targetOrigin: string;
12
+ }
13
+ export interface BrowserFlowRecord extends BrowserFlowBinding {
14
+ flowId: string;
15
+ ticketId: string;
16
+ expiresAt: number;
17
+ }
18
+ export interface BrowserFlowStore {
19
+ put(record: BrowserFlowRecord): Promise<void>;
20
+ peek(flowId: string): Promise<BrowserFlowRecord | undefined>;
21
+ remove(flowId: string): Promise<void>;
22
+ }
23
+ export interface ActivationConsumeInput {
24
+ flowId: string;
25
+ ticketId: string;
26
+ expected: BrowserFlowBinding;
27
+ }
28
+ /** Host locks current session/ACL/release/policy on this client through COMMIT.
29
+ * No cookies, signing or externally visible effects inside this callback.
30
+ */
31
+ export type ActivationAuthority = (context: {
32
+ client: PoolClient;
33
+ namespace: string;
34
+ ticket: Readonly<TicketRecord>;
35
+ flow: Readonly<BrowserFlowRecord>;
36
+ }) => Promise<boolean>;
37
+ export interface PostgresActivationStore {
38
+ tickets: TicketStore;
39
+ flows: BrowserFlowStore;
40
+ consume(input: ActivationConsumeInput, authorize: ActivationAuthority): Promise<TicketRecord | undefined>;
41
+ /** Total deleted rows <= limit (1..1000), including no unbounded cascades. */
42
+ cleanup(limit?: number): Promise<{
43
+ flows: number;
44
+ tickets: number;
45
+ }>;
46
+ /** Closed-serving restore step: invalidate all prior state in O(1). Rotate signing keys separately. */
47
+ invalidate(): Promise<void>;
48
+ }
49
+ export declare class ActivationCommitUnknownError extends Error {
50
+ constructor(cause: unknown);
51
+ }
52
+ /** No eager I/O or DDL. Hosts issue fresh random IDs with ttlSeconds <=60.
53
+ * Use consume for browser activation; standalone TicketStore supports existing services.
54
+ * Only an acknowledged consume result permits the host to sign/set a grant cookie.
55
+ */
56
+ export declare function createPostgresActivationStore(options: {
57
+ pool: Pool;
58
+ namespace: string;
59
+ }): PostgresActivationStore;
@@ -0,0 +1,218 @@
1
+ export const ACTIVATION_MAX_TTL_SECONDS = 60;
2
+ export const ACTIVATION_SQL_ASSET = '@sentropic/artifacts/sql/0001_activation.sql';
3
+ export const ACTIVATION_SQL_ASSET_SHA256 = '13767c826a62aa5f880809ca2ebc60bdc638b2e59f983e195d4351356b282d8b';
4
+ export class ActivationCommitUnknownError extends Error {
5
+ constructor(cause) {
6
+ super('activation commit outcome unknown; start a fresh flow', { cause });
7
+ this.name = 'ActivationCommitUnknownError';
8
+ }
9
+ }
10
+ const bindingKeys = ['subjectId', 'artifactId', 'releaseId', 'channel'];
11
+ const flowKeys = [...bindingKeys, 'nonceDigest', 'sessionId', 'profileDigest', 'policyRevision', 'targetOrigin'];
12
+ const nowSql = 'floor(extract(epoch FROM clock_timestamp()) * 1000)';
13
+ function text(value) {
14
+ if (typeof value !== 'string' || value.length === 0 || value.length > 256 ||
15
+ value.includes('\0') || Buffer.from(value).toString('utf8') !== value) {
16
+ throw new Error('invalid activation binding');
17
+ }
18
+ return value;
19
+ }
20
+ function binding(value) {
21
+ if (value.channel !== 'stable' && value.channel !== 'pinned')
22
+ throw new Error('invalid activation channel');
23
+ return { subjectId: text(value.subjectId), artifactId: text(value.artifactId),
24
+ releaseId: text(value.releaseId), channel: value.channel };
25
+ }
26
+ function deadline(value) {
27
+ if (!Number.isSafeInteger(value) || value <= 0)
28
+ throw new Error('invalid activation deadline');
29
+ return value;
30
+ }
31
+ function flowBinding(value) {
32
+ const targetOrigin = text(value.targetOrigin);
33
+ const url = new URL(targetOrigin);
34
+ if (url.protocol !== 'https:' || url.origin !== targetOrigin)
35
+ throw new Error('invalid activation origin');
36
+ for (const digest of [value.nonceDigest, value.profileDigest]) {
37
+ if (!/^[0-9a-f]{64}$/.test(digest))
38
+ throw new Error('invalid activation digest');
39
+ }
40
+ return { ...binding(value), nonceDigest: value.nonceDigest, profileDigest: value.profileDigest,
41
+ sessionId: text(value.sessionId), policyRevision: text(value.policyRevision), targetOrigin };
42
+ }
43
+ async function transaction(pool, namespace, work, exclusive = false) {
44
+ const client = await pool.connect();
45
+ let committing = false;
46
+ let destroy = false;
47
+ try {
48
+ await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
49
+ await client.query('INSERT INTO artifact_host.activation_namespaces(namespace) VALUES ($1) ON CONFLICT DO NOTHING', [namespace]);
50
+ const { rows } = await client.query(`SELECT generation FROM artifact_host.activation_namespaces WHERE namespace=$1 FOR ${exclusive ? 'UPDATE' : 'SHARE'}`, [namespace]);
51
+ const result = await work(client, rows[0].generation);
52
+ committing = true;
53
+ await client.query('COMMIT');
54
+ return result;
55
+ }
56
+ catch (cause) {
57
+ if (committing) {
58
+ destroy = true;
59
+ throw new ActivationCommitUnknownError(cause);
60
+ }
61
+ try {
62
+ await client.query('ROLLBACK');
63
+ }
64
+ catch {
65
+ destroy = true;
66
+ }
67
+ throw cause;
68
+ }
69
+ finally {
70
+ client.release(destroy);
71
+ }
72
+ }
73
+ function recordStores(run, namespace) {
74
+ const parameters = (id, generation) => [namespace, text(id), generation];
75
+ async function lockTicket(client, id, generation) {
76
+ // Same lock order as consume: browser flow, then ticket (including cascades).
77
+ await client.query('SELECT flow_id FROM artifact_host.browser_flows WHERE namespace=$1 AND ticket_id=$2 FOR UPDATE', [namespace, id]);
78
+ await client.query('SELECT ticket_id FROM artifact_host.activation_tickets WHERE namespace=$1 AND ticket_id=$2 AND generation=$3 FOR UPDATE', parameters(id, generation));
79
+ }
80
+ const tickets = {
81
+ async issue(value, ttlMs) {
82
+ if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > ACTIVATION_MAX_TTL_SECONDS * 1000) {
83
+ throw new Error('invalid activation lifetime');
84
+ }
85
+ const payload = { ...binding(value), ticketId: text(value.ticketId) };
86
+ return run(async (client, generation) => {
87
+ const { rows } = await client.query(`WITH issued AS (SELECT ${nowSql}::bigint AS ms)
88
+ INSERT INTO artifact_host.activation_tickets(namespace,generation,payload,issued_ms)
89
+ SELECT $1,$2,jsonb_set($3::jsonb,'{expiresAt}',to_jsonb(ms+$4::bigint)),ms FROM issued
90
+ RETURNING payload`, [namespace, generation, JSON.stringify(payload), ttlMs]);
91
+ return rows[0].payload;
92
+ });
93
+ },
94
+ async put(value) {
95
+ const payload = { ...binding(value), ticketId: text(value.ticketId), expiresAt: deadline(value.expiresAt) };
96
+ await run(async (client, generation) => {
97
+ await client.query('INSERT INTO artifact_host.activation_tickets(namespace,generation,payload) VALUES ($1,$2,$3)', [namespace, generation, JSON.stringify(payload)]);
98
+ });
99
+ },
100
+ async peek(id) {
101
+ text(id);
102
+ return run(async (client, generation) => {
103
+ const { rows } = await client.query(`SELECT payload FROM artifact_host.activation_tickets WHERE namespace=$1 AND ticket_id=$2 AND generation=$3 AND expires_ms>${nowSql}`, parameters(id, generation));
104
+ return rows[0]?.payload;
105
+ });
106
+ },
107
+ async take(id) {
108
+ text(id);
109
+ return run(async (client, generation) => {
110
+ await lockTicket(client, id, generation);
111
+ const { rows } = await client.query(`DELETE FROM artifact_host.activation_tickets WHERE namespace=$1 AND ticket_id=$2 AND generation=$3 AND expires_ms>${nowSql} RETURNING payload`, parameters(id, generation));
112
+ return rows[0]?.payload;
113
+ });
114
+ },
115
+ async remove(id) {
116
+ text(id);
117
+ await run(async (client, generation) => {
118
+ await lockTicket(client, id, generation);
119
+ await client.query('DELETE FROM artifact_host.activation_tickets WHERE namespace=$1 AND ticket_id=$2 AND generation=$3', parameters(id, generation));
120
+ });
121
+ },
122
+ };
123
+ const flows = {
124
+ async put(value) {
125
+ const payload = { ...flowBinding(value), flowId: text(value.flowId),
126
+ ticketId: text(value.ticketId), expiresAt: deadline(value.expiresAt) };
127
+ await run(async (client, generation) => {
128
+ await client.query('SELECT ticket_id FROM artifact_host.activation_tickets WHERE namespace=$1 AND ticket_id=$2 AND generation=$3 FOR UPDATE', parameters(payload.ticketId, generation));
129
+ const { rowCount } = await client.query(`INSERT INTO artifact_host.browser_flows(namespace,generation,payload,ticket_expires_ms)
130
+ SELECT namespace,generation,$4,expires_ms FROM artifact_host.activation_tickets
131
+ WHERE namespace=$1 AND ticket_id=$2 AND generation=$3 AND expires_ms>${nowSql} AND $5::bigint>${nowSql}`, [...parameters(payload.ticketId, generation), JSON.stringify(payload), payload.expiresAt]);
132
+ if (rowCount !== 1)
133
+ throw new Error('invalid activation ticket');
134
+ });
135
+ },
136
+ async peek(id) {
137
+ text(id);
138
+ return run(async (client, generation) => {
139
+ const { rows } = await client.query(`SELECT payload FROM artifact_host.browser_flows WHERE namespace=$1 AND flow_id=$2 AND generation=$3 AND expires_ms>${nowSql}`, parameters(id, generation));
140
+ return rows[0]?.payload;
141
+ });
142
+ },
143
+ async remove(id) {
144
+ text(id);
145
+ await run(async (client, generation) => {
146
+ await client.query('DELETE FROM artifact_host.browser_flows WHERE namespace=$1 AND flow_id=$2 AND generation=$3', parameters(id, generation));
147
+ });
148
+ },
149
+ };
150
+ return { tickets, flows };
151
+ }
152
+ /** No eager I/O or DDL. Hosts issue fresh random IDs with ttlSeconds <=60.
153
+ * Use consume for browser activation; standalone TicketStore supports existing services.
154
+ * Only an acknowledged consume result permits the host to sign/set a grant cookie.
155
+ */
156
+ export function createPostgresActivationStore(options) {
157
+ const namespace = text(options.namespace);
158
+ const pool = options.pool;
159
+ const run = work => transaction(pool, namespace, work);
160
+ return {
161
+ ...recordStores(run, namespace),
162
+ async consume(value, authorize) {
163
+ // Snapshot before acquisition waits; callers cannot retarget an in-flight consume.
164
+ const flowId = text(value.flowId);
165
+ const ticketId = text(value.ticketId);
166
+ const expected = { ...value.expected };
167
+ return run(async (client, generation) => {
168
+ const params = [namespace, flowId, generation];
169
+ const flowResult = await client.query('SELECT payload FROM artifact_host.browser_flows WHERE namespace=$1 AND flow_id=$2 AND generation=$3 FOR UPDATE', params);
170
+ const flow = flowResult.rows[0]?.payload;
171
+ if (!flow || flow.ticketId !== ticketId || flowKeys.some(key => flow[key] !== expected[key]))
172
+ return undefined;
173
+ const ticketResult = await client.query('SELECT payload FROM artifact_host.activation_tickets WHERE namespace=$1 AND ticket_id=$2 AND generation=$3 FOR UPDATE', [namespace, ticketId, generation]);
174
+ const ticket = ticketResult.rows[0]?.payload;
175
+ if (!ticket || bindingKeys.some(key => ticket[key] !== expected[key]))
176
+ return undefined;
177
+ // This read happens AFTER both row-lock waits; authority may wait again.
178
+ const live = await client.query(`SELECT 1 FROM artifact_host.browser_flows
179
+ WHERE namespace=$1 AND flow_id=$2 AND generation=$3 AND expires_ms>${nowSql}`, params);
180
+ if (live.rows.length === 0)
181
+ return undefined;
182
+ if (await authorize({ client, namespace, ticket: Object.freeze({ ...ticket }), flow: Object.freeze({ ...flow }) }) !== true)
183
+ return undefined;
184
+ // One visibility decision after ALL authority waits; cascade consumes the flow.
185
+ const consumed = await client.query(`DELETE FROM artifact_host.activation_tickets t WHERE t.namespace=$1 AND t.ticket_id=$2
186
+ AND t.generation=$3 AND t.expires_ms>${nowSql} AND EXISTS (
187
+ SELECT 1 FROM artifact_host.browser_flows f WHERE f.namespace=t.namespace
188
+ AND f.ticket_id=t.ticket_id AND f.flow_id=$4 AND f.generation=t.generation AND f.expires_ms>${nowSql})
189
+ RETURNING payload`, [namespace, ticketId, generation, flowId]);
190
+ return consumed.rows[0]?.payload;
191
+ });
192
+ },
193
+ async cleanup(limit = 100) {
194
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1000)
195
+ throw new Error('invalid activation cleanup limit');
196
+ return run(async (client, generation) => {
197
+ const flows = await client.query(`WITH due AS (
198
+ SELECT flow_id FROM artifact_host.browser_flows WHERE namespace=$1
199
+ AND (expires_ms<=${nowSql} OR generation<$2) ORDER BY expires_ms,flow_id
200
+ LIMIT $3 FOR UPDATE SKIP LOCKED)
201
+ DELETE FROM artifact_host.browser_flows f USING due WHERE f.namespace=$1 AND f.flow_id=due.flow_id`, [namespace, generation, limit]);
202
+ const flowCount = flows.rowCount ?? 0;
203
+ const tickets = await client.query(`WITH due AS (
204
+ SELECT ticket_id FROM artifact_host.activation_tickets t WHERE namespace=$1
205
+ AND (expires_ms<=${nowSql} OR generation<$2)
206
+ AND NOT EXISTS (SELECT 1 FROM artifact_host.browser_flows f WHERE f.namespace=t.namespace AND f.ticket_id=t.ticket_id)
207
+ ORDER BY expires_ms,ticket_id LIMIT $3 FOR UPDATE SKIP LOCKED)
208
+ DELETE FROM artifact_host.activation_tickets t USING due WHERE t.namespace=$1 AND t.ticket_id=due.ticket_id`, [namespace, generation, limit - flowCount]);
209
+ return { flows: flowCount, tickets: tickets.rowCount ?? 0 };
210
+ });
211
+ },
212
+ async invalidate() {
213
+ await transaction(pool, namespace, async (client) => {
214
+ await client.query('UPDATE artifact_host.activation_namespaces SET generation=generation+1 WHERE namespace=$1', [namespace]);
215
+ }, true);
216
+ },
217
+ };
218
+ }
@@ -33,6 +33,8 @@ export interface TicketRecord extends TicketBinding {
33
33
  expiresAt: number;
34
34
  }
35
35
  export interface TicketStore {
36
+ /** Optional authoritative-clock issuance; peek/take must also deny expired records. */
37
+ issue?(record: Omit<TicketRecord, 'expiresAt'>, ttlMs: number): Promise<TicketRecord>;
36
38
  put(record: TicketRecord): Promise<void>;
37
39
  peek(ticketId: string): Promise<TicketRecord | undefined>;
38
40
  /** Atomically remove and return the record (single-use consumption). */
@@ -111,6 +111,10 @@ export function createTicketService(options) {
111
111
  (input.channel !== 'stable' && input.channel !== 'pinned'))
112
112
  throw new Error('invalid ticket: binding');
113
113
  const ticketId = ids();
114
+ if (options.store.issue) {
115
+ const record = await options.store.issue({ ...input, ticketId }, ttlMs);
116
+ return { ticketId: record.ticketId, expiresAt: record.expiresAt };
117
+ }
114
118
  const expiresAt = clock() + ttlMs;
115
119
  await options.store.put({
116
120
  ticketId,
@@ -126,7 +130,7 @@ export function createTicketService(options) {
126
130
  const record = await options.store.peek(ticketId);
127
131
  if (record === undefined)
128
132
  throw new Error('invalid ticket');
129
- if (clock() >= record.expiresAt) {
133
+ if (!options.store.issue && clock() >= record.expiresAt) {
130
134
  await options.store.remove(ticketId);
131
135
  throw new Error('ticket expired');
132
136
  }
@@ -10,7 +10,7 @@ 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.2";
13
+ export declare const ARTIFACTS_PROVIDER_VERSION = "0.2.4";
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: {
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.2';
9
+ export const ARTIFACTS_PROVIDER_VERSION = '0.2.4';
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('_', ' ')}.`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentropic/artifacts",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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",
@@ -41,6 +41,15 @@
41
41
  "types": "./dist/adapters/postgres-commit.d.ts",
42
42
  "import": "./dist/adapters/postgres-commit.js"
43
43
  },
44
+ "./adapters/postgres-activation": {
45
+ "types": "./dist/adapters/postgres-activation.d.ts",
46
+ "import": "./dist/adapters/postgres-activation.js"
47
+ },
48
+ "./adapters/postgres-activation/vectors": {
49
+ "types": "./dist/adapters/postgres-activation-vectors.d.ts",
50
+ "import": "./dist/adapters/postgres-activation-vectors.js"
51
+ },
52
+ "./sql/*": "./sql/*",
44
53
  "./migrations/*": "./migrations/*",
45
54
  "./server/ports": {
46
55
  "types": "./dist/server/ports.d.ts",
@@ -168,6 +177,7 @@
168
177
  "l0-freeze",
169
178
  "src/contracts.ts",
170
179
  "migrations",
180
+ "sql",
171
181
  "docs/migration-l6.md",
172
182
  "examples/radar-commit-setup.ts",
173
183
  "README.md",
@@ -0,0 +1,70 @@
1
+ -- UP-T v1. Host migration executor owns serialization, transaction and journal.
2
+ -- Independent of the publication schema. Never run DDL on adapter construction.
3
+ CREATE SCHEMA IF NOT EXISTS artifact_host;
4
+
5
+ CREATE TABLE artifact_host.activation_namespaces (
6
+ namespace text PRIMARY KEY CHECK (length(namespace) BETWEEN 1 AND 256),
7
+ generation bigint NOT NULL DEFAULT 1 CHECK (generation > 0)
8
+ );
9
+
10
+ CREATE TABLE artifact_host.activation_tickets (
11
+ namespace text NOT NULL REFERENCES artifact_host.activation_namespaces(namespace),
12
+ generation bigint NOT NULL,
13
+ payload jsonb NOT NULL,
14
+ ticket_id text GENERATED ALWAYS AS (payload->>'ticketId') STORED NOT NULL,
15
+ subject_id text GENERATED ALWAYS AS (payload->>'subjectId') STORED NOT NULL,
16
+ artifact_id text GENERATED ALWAYS AS (payload->>'artifactId') STORED NOT NULL,
17
+ release_id text GENERATED ALWAYS AS (payload->>'releaseId') STORED NOT NULL,
18
+ channel text GENERATED ALWAYS AS (payload->>'channel') STORED NOT NULL,
19
+ expires_ms bigint GENERATED ALWAYS AS ((payload->>'expiresAt')::bigint) STORED NOT NULL,
20
+ issued_ms bigint NOT NULL DEFAULT floor(extract(epoch FROM clock_timestamp()) * 1000),
21
+ PRIMARY KEY (namespace, ticket_id),
22
+ UNIQUE (namespace, generation, ticket_id, subject_id, artifact_id, release_id, channel, expires_ms),
23
+ CHECK (length(ticket_id) BETWEEN 1 AND 256 AND length(subject_id) BETWEEN 1 AND 256),
24
+ CHECK (length(artifact_id) BETWEEN 1 AND 256 AND length(release_id) BETWEEN 1 AND 256),
25
+ CHECK (channel IN ('stable', 'pinned')),
26
+ CHECK (jsonb_typeof(payload->'expiresAt') = 'number'),
27
+ CHECK (expires_ms > issued_ms AND expires_ms <= issued_ms + 60000)
28
+ );
29
+ CREATE INDEX activation_tickets_expiry ON artifact_host.activation_tickets(namespace, expires_ms, ticket_id);
30
+ CREATE INDEX activation_tickets_generation ON artifact_host.activation_tickets(namespace, generation, ticket_id);
31
+
32
+ CREATE TABLE artifact_host.browser_flows (
33
+ namespace text NOT NULL,
34
+ generation bigint NOT NULL,
35
+ payload jsonb NOT NULL,
36
+ flow_id text GENERATED ALWAYS AS (payload->>'flowId') STORED NOT NULL,
37
+ ticket_id text GENERATED ALWAYS AS (payload->>'ticketId') STORED NOT NULL,
38
+ subject_id text GENERATED ALWAYS AS (payload->>'subjectId') STORED NOT NULL,
39
+ artifact_id text GENERATED ALWAYS AS (payload->>'artifactId') STORED NOT NULL,
40
+ release_id text GENERATED ALWAYS AS (payload->>'releaseId') STORED NOT NULL,
41
+ channel text GENERATED ALWAYS AS (payload->>'channel') STORED NOT NULL,
42
+ expires_ms bigint GENERATED ALWAYS AS ((payload->>'expiresAt')::bigint) STORED NOT NULL,
43
+ ticket_expires_ms bigint NOT NULL,
44
+ PRIMARY KEY (namespace, flow_id),
45
+ UNIQUE (namespace, ticket_id),
46
+ FOREIGN KEY (namespace, generation, ticket_id, subject_id, artifact_id, release_id, channel, ticket_expires_ms)
47
+ REFERENCES artifact_host.activation_tickets
48
+ (namespace, generation, ticket_id, subject_id, artifact_id, release_id, channel, expires_ms)
49
+ ON DELETE CASCADE,
50
+ CHECK (length(flow_id) BETWEEN 1 AND 256),
51
+ CHECK (expires_ms <= ticket_expires_ms),
52
+ CHECK (jsonb_typeof(payload->'expiresAt') = 'number'),
53
+ CHECK (length(payload->>'sessionId') BETWEEN 1 AND 256 AND payload->>'sessionId' IS NOT NULL),
54
+ CHECK (length(payload->>'policyRevision') BETWEEN 1 AND 256 AND payload->>'policyRevision' IS NOT NULL),
55
+ CHECK (payload->>'nonceDigest' ~ '^[0-9a-f]{64}$' AND payload->>'nonceDigest' IS NOT NULL),
56
+ CHECK (payload->>'profileDigest' ~ '^[0-9a-f]{64}$' AND payload->>'profileDigest' IS NOT NULL),
57
+ CHECK (payload->>'targetOrigin' ~ '^https://[^/]+$' AND payload->>'targetOrigin' IS NOT NULL)
58
+ );
59
+ CREATE INDEX browser_flows_expiry ON artifact_host.browser_flows(namespace, expires_ms, flow_id);
60
+ CREATE INDEX browser_flows_generation ON artifact_host.browser_flows(namespace, generation, flow_id);
61
+
62
+ CREATE FUNCTION artifact_host.activation_immutable() RETURNS trigger LANGUAGE plpgsql AS $$
63
+ BEGIN
64
+ RAISE EXCEPTION 'activation records are immutable' USING ERRCODE = '23000';
65
+ END;
66
+ $$;
67
+ CREATE TRIGGER activation_tickets_immutable BEFORE UPDATE ON artifact_host.activation_tickets
68
+ FOR EACH ROW EXECUTE FUNCTION artifact_host.activation_immutable();
69
+ CREATE TRIGGER browser_flows_immutable BEFORE UPDATE ON artifact_host.browser_flows
70
+ FOR EACH ROW EXECUTE FUNCTION artifact_host.activation_immutable();