@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 +149 -0
- package/dist/adapters/postgres-activation-vectors.d.ts +31 -0
- package/dist/adapters/postgres-activation-vectors.js +178 -0
- package/dist/adapters/postgres-activation.d.ts +59 -0
- package/dist/adapters/postgres-activation.js +218 -0
- package/dist/content/grants.d.ts +2 -0
- package/dist/content/grants.js +5 -1
- package/dist/mcp-core.d.ts +1 -1
- package/dist/mcp-core.js +1 -1
- package/package.json +11 -1
- package/sql/0001_activation.sql +70 -0
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
|
+
}
|
package/dist/content/grants.d.ts
CHANGED
|
@@ -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). */
|
package/dist/content/grants.js
CHANGED
|
@@ -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
|
}
|
package/dist/mcp-core.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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();
|