@sentropic/artifacts 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +95 -2
- package/dist/adapters/postgres.d.ts +35 -0
- package/dist/adapters/postgres.js +301 -0
- package/dist/adapters/s3.d.ts +19 -0
- package/dist/adapters/s3.js +70 -0
- package/dist/client.d.ts +23 -0
- package/dist/client.js +44 -0
- package/dist/codecs.d.ts +12 -0
- package/dist/codecs.js +77 -0
- package/dist/content/api-profile.d.ts +38 -0
- package/dist/content/api-profile.js +40 -0
- package/dist/content/grants.d.ts +57 -0
- package/dist/content/grants.js +143 -0
- package/dist/content/handler.d.ts +34 -0
- package/dist/content/handler.js +129 -0
- package/dist/content/headers.d.ts +25 -0
- package/dist/content/headers.js +56 -0
- package/dist/content/routing.d.ts +13 -0
- package/dist/content/routing.js +59 -0
- package/dist/contracts.d.ts +102 -0
- package/dist/contracts.js +25 -0
- package/dist/http.d.ts +43 -0
- package/dist/http.js +502 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +29 -0
- package/dist/mcp.d.ts +68 -0
- package/dist/mcp.js +293 -0
- package/dist/parameters.d.ts +29 -0
- package/dist/parameters.js +65 -0
- package/dist/provider-definition.d.ts +36 -0
- package/dist/provider-definition.js +45 -0
- package/dist/server/access.d.ts +39 -0
- package/dist/server/access.js +53 -0
- package/dist/server/authorization.d.ts +17 -0
- package/dist/server/authorization.js +12 -0
- package/dist/server/bindings.d.ts +34 -0
- package/dist/server/bindings.js +60 -0
- package/dist/server/fork.d.ts +51 -0
- package/dist/server/fork.js +75 -0
- package/dist/server/gc.d.ts +24 -0
- package/dist/server/gc.js +90 -0
- package/dist/server/idempotency.d.ts +23 -0
- package/dist/server/idempotency.js +38 -0
- package/dist/server/lifecycle.d.ts +57 -0
- package/dist/server/lifecycle.js +184 -0
- package/dist/server/object-intents.d.ts +41 -0
- package/dist/server/object-intents.js +62 -0
- package/dist/server/ports.d.ts +34 -0
- package/dist/server/ports.js +50 -0
- package/dist/server/publication.d.ts +67 -0
- package/dist/server/publication.js +154 -0
- package/dist/server/reader-profile.d.ts +27 -0
- package/dist/server/reader-profile.js +18 -0
- package/dist/server/runtime.d.ts +33 -0
- package/dist/server/runtime.js +44 -0
- package/dist/server/service.d.ts +19 -0
- package/dist/server/service.js +32 -0
- package/dist/server/sources.d.ts +27 -0
- package/dist/server/sources.js +56 -0
- package/dist/server/templates.d.ts +33 -0
- package/dist/server/templates.js +86 -0
- package/dist/server/uploads.d.ts +29 -0
- package/dist/server/uploads.js +86 -0
- package/package.json +162 -4
package/dist/codecs.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L-5a — canonical manifest codec (spec-v8 §9).
|
|
3
|
+
*
|
|
4
|
+
* Deterministic serialization for content-addressed manifests: key sorting
|
|
5
|
+
* is recursive, `undefined` object properties are omitted, and the digest is
|
|
6
|
+
* the SHA-256 of the canonical UTF-8 bytes. `parseManifest` validates the
|
|
7
|
+
* schema version and digest fail-closed.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import { assertKnownSchemaVersion } from './contracts.js';
|
|
11
|
+
/**
|
|
12
|
+
* Recursively sort object keys and drop `undefined` properties. Arrays are
|
|
13
|
+
* treated as unordered sets: items are canonicalized first, then sorted by
|
|
14
|
+
* their canonical bytes. Rationale: a manifest lists addressed files — the
|
|
15
|
+
* file order carries no semantics, while the file set does.
|
|
16
|
+
*/
|
|
17
|
+
function canonicalize(value) {
|
|
18
|
+
if (Array.isArray(value)) {
|
|
19
|
+
return value
|
|
20
|
+
.map(canonicalize)
|
|
21
|
+
.sort((a, b) => {
|
|
22
|
+
const sa = JSON.stringify(a);
|
|
23
|
+
const sb = JSON.stringify(b);
|
|
24
|
+
return sa < sb ? -1 : sa > sb ? 1 : 0;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
if (typeof value === 'object' && value !== null) {
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const key of Object.keys(value).sort()) {
|
|
30
|
+
const v = value[key];
|
|
31
|
+
if (v !== undefined)
|
|
32
|
+
out[key] = canonicalize(v);
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
/** Deterministic serialization of a manifest (recursive key sort, no `undefined`). */
|
|
39
|
+
export function canonicalManifest(manifest) {
|
|
40
|
+
return JSON.stringify(canonicalize(manifest));
|
|
41
|
+
}
|
|
42
|
+
/** SHA-256 hex digest of the canonical manifest bytes. */
|
|
43
|
+
export function manifestDigest(manifest) {
|
|
44
|
+
return createHash('sha256').update(canonicalManifest(manifest), 'utf8').digest('hex');
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Parse and validate a manifest, fail-closed: JSON object, known schema
|
|
48
|
+
* version, required `artifactId`. A present `digest` must match the body
|
|
49
|
+
* recomputed without it; an absent digest means unsealed working document
|
|
50
|
+
* (construction precedes sealing) and is accepted as-is.
|
|
51
|
+
*/
|
|
52
|
+
export function parseManifest(raw) {
|
|
53
|
+
let parsed;
|
|
54
|
+
try {
|
|
55
|
+
parsed = JSON.parse(raw);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
throw new Error('invalid manifest: cannot parse body as JSON');
|
|
59
|
+
}
|
|
60
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
61
|
+
throw new Error('invalid manifest: not an object');
|
|
62
|
+
}
|
|
63
|
+
const manifest = parsed;
|
|
64
|
+
assertKnownSchemaVersion(manifest.schemaVersion);
|
|
65
|
+
if (typeof manifest.artifactId !== 'string' || manifest.artifactId.length === 0) {
|
|
66
|
+
throw new Error('invalid manifest: missing artifactId');
|
|
67
|
+
}
|
|
68
|
+
if (manifest.digest !== undefined) {
|
|
69
|
+
const { digest: _drop, ...body } = manifest;
|
|
70
|
+
void _drop;
|
|
71
|
+
const expected = manifestDigest(body);
|
|
72
|
+
if (manifest.digest !== expected) {
|
|
73
|
+
throw new Error(`invalid manifest: digest mismatch (expected ${expected})`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return manifest;
|
|
77
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L-5d — reader-delegation API profile (spec-v8 §9 5d).
|
|
3
|
+
*
|
|
4
|
+
* Generic contract for page-initiated business calls: the host profile
|
|
5
|
+
* fixes exact methods/routes and artifact/release bindings (evaluated by
|
|
6
|
+
* the pure reader-profile contract); writes additionally require Origin
|
|
7
|
+
* equality with the content origin and a custom header (simple-request
|
|
8
|
+
* CSRF defense). A content grant alone never becomes business-write
|
|
9
|
+
* authority — the receiving API re-verifies reader rights per operation.
|
|
10
|
+
* Successful calls carry reader attribution for audit.
|
|
11
|
+
*/
|
|
12
|
+
import { type ReaderProfile } from '../server/reader-profile.js';
|
|
13
|
+
export interface ReaderApiCall {
|
|
14
|
+
subjectId: string;
|
|
15
|
+
method: string;
|
|
16
|
+
route: string;
|
|
17
|
+
artifactId?: string;
|
|
18
|
+
releaseId?: string;
|
|
19
|
+
/** Browser Origin header of the calling page (writes only). */
|
|
20
|
+
origin?: string;
|
|
21
|
+
/** The content origin the call must come from (writes only). */
|
|
22
|
+
expectedOrigin?: string;
|
|
23
|
+
/** True when the call carries a non-simple custom request header. */
|
|
24
|
+
hasCustomHeader?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export type ReaderApiDecision = {
|
|
27
|
+
allowed: true;
|
|
28
|
+
attribution: {
|
|
29
|
+
subjectId: string;
|
|
30
|
+
artifactId?: string;
|
|
31
|
+
releaseId?: string;
|
|
32
|
+
};
|
|
33
|
+
} | {
|
|
34
|
+
allowed: false;
|
|
35
|
+
reason: 'profile' | 'origin' | 'csrf';
|
|
36
|
+
};
|
|
37
|
+
/** Authorize one reader API call against the host profile (deny default). */
|
|
38
|
+
export declare function authorizeReaderApiCall(profile: ReaderProfile, call: ReaderApiCall, isApprovedAccount?: (principalId: string) => boolean): ReaderApiDecision;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L-5d — reader-delegation API profile (spec-v8 §9 5d).
|
|
3
|
+
*
|
|
4
|
+
* Generic contract for page-initiated business calls: the host profile
|
|
5
|
+
* fixes exact methods/routes and artifact/release bindings (evaluated by
|
|
6
|
+
* the pure reader-profile contract); writes additionally require Origin
|
|
7
|
+
* equality with the content origin and a custom header (simple-request
|
|
8
|
+
* CSRF defense). A content grant alone never becomes business-write
|
|
9
|
+
* authority — the receiving API re-verifies reader rights per operation.
|
|
10
|
+
* Successful calls carry reader attribution for audit.
|
|
11
|
+
*/
|
|
12
|
+
import { evaluateReaderProfile, } from '../server/reader-profile.js';
|
|
13
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD']);
|
|
14
|
+
/** Authorize one reader API call against the host profile (deny default). */
|
|
15
|
+
export function authorizeReaderApiCall(profile, call, isApprovedAccount = () => false) {
|
|
16
|
+
const inProfile = evaluateReaderProfile(profile, {
|
|
17
|
+
subject: call.subjectId,
|
|
18
|
+
method: call.method,
|
|
19
|
+
route: call.route,
|
|
20
|
+
artifactId: call.artifactId,
|
|
21
|
+
releaseId: call.releaseId,
|
|
22
|
+
}, isApprovedAccount);
|
|
23
|
+
if (!inProfile)
|
|
24
|
+
return { allowed: false, reason: 'profile' };
|
|
25
|
+
if (!SAFE_METHODS.has(call.method)) {
|
|
26
|
+
if (call.expectedOrigin === undefined || call.origin !== call.expectedOrigin) {
|
|
27
|
+
return { allowed: false, reason: 'origin' };
|
|
28
|
+
}
|
|
29
|
+
if (call.hasCustomHeader !== true)
|
|
30
|
+
return { allowed: false, reason: 'csrf' };
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
allowed: true,
|
|
34
|
+
attribution: {
|
|
35
|
+
subjectId: call.subjectId,
|
|
36
|
+
...(call.artifactId === undefined ? {} : { artifactId: call.artifactId }),
|
|
37
|
+
...(call.releaseId === undefined ? {} : { releaseId: call.releaseId }),
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export declare const CONTENT_GRANT_TTL_SECONDS = 55;
|
|
2
|
+
export declare const CONTENT_CUTOFF_BUDGET_SECONDS = 60;
|
|
3
|
+
export declare const CONTENT_TICKET_TTL_SECONDS = 120;
|
|
4
|
+
export interface ContentGrantClaims {
|
|
5
|
+
subjectId: string;
|
|
6
|
+
artifactId: string;
|
|
7
|
+
releaseId?: string;
|
|
8
|
+
scope: 'read';
|
|
9
|
+
issuedAt: number;
|
|
10
|
+
expiresAt: number;
|
|
11
|
+
}
|
|
12
|
+
export interface GrantIssueInput {
|
|
13
|
+
subjectId: string;
|
|
14
|
+
artifactId: string;
|
|
15
|
+
releaseId?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface GrantSigner {
|
|
18
|
+
issue(input: GrantIssueInput): string;
|
|
19
|
+
verify(token: string): ContentGrantClaims;
|
|
20
|
+
}
|
|
21
|
+
/** Create an HMAC grant signer over an injected secret and clock. */
|
|
22
|
+
export declare function createHmacGrantSigner(options: {
|
|
23
|
+
secret: string | Uint8Array;
|
|
24
|
+
clock?: () => number;
|
|
25
|
+
ttlSeconds?: number;
|
|
26
|
+
}): GrantSigner;
|
|
27
|
+
export interface TicketBinding extends GrantIssueInput {
|
|
28
|
+
releaseId: string;
|
|
29
|
+
channel: 'stable' | 'pinned';
|
|
30
|
+
}
|
|
31
|
+
export interface TicketRecord extends TicketBinding {
|
|
32
|
+
ticketId: string;
|
|
33
|
+
expiresAt: number;
|
|
34
|
+
}
|
|
35
|
+
export interface TicketStore {
|
|
36
|
+
put(record: TicketRecord): Promise<void>;
|
|
37
|
+
peek(ticketId: string): Promise<TicketRecord | undefined>;
|
|
38
|
+
/** Atomically remove and return the record (single-use consumption). */
|
|
39
|
+
take(ticketId: string): Promise<TicketRecord | undefined>;
|
|
40
|
+
remove(ticketId: string): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
/** In-memory TicketStore double for unit tests. */
|
|
43
|
+
export declare function createMemoryTicketStore(): TicketStore;
|
|
44
|
+
export interface TicketService {
|
|
45
|
+
issue(input: TicketBinding): Promise<{
|
|
46
|
+
ticketId: string;
|
|
47
|
+
expiresAt: number;
|
|
48
|
+
}>;
|
|
49
|
+
redeem(ticketId: string, expected: TicketBinding): Promise<TicketRecord>;
|
|
50
|
+
}
|
|
51
|
+
/** Single-use bound tickets; mismatched attempts do not consume the ticket. */
|
|
52
|
+
export declare function createTicketService(options: {
|
|
53
|
+
store: TicketStore;
|
|
54
|
+
clock?: () => number;
|
|
55
|
+
ids?: () => string;
|
|
56
|
+
ttlSeconds?: number;
|
|
57
|
+
}): TicketService;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L-5d — content grants and activation tickets (spec-v8 §9 5d).
|
|
3
|
+
*
|
|
4
|
+
* Short signed grants authorize content reads on the separate content
|
|
5
|
+
* domain: 55-second grant TTL inside a 60-second total cutoff budget
|
|
6
|
+
* (grant + skew/propagation). Grants are HMAC-SHA256 signed with a
|
|
7
|
+
* host-injected secret, bound to subject + artifact (+ pinned release),
|
|
8
|
+
* and verified fail-closed (signature, shape, lifetime cap, expiry,
|
|
9
|
+
* future-issued beyond the skew budget). Activation tickets are
|
|
10
|
+
* single-use and bound to an exact release and channel; ticket IDs come from an injected
|
|
11
|
+
* port and MUST be unguessable (default: crypto UUIDs). A grant never
|
|
12
|
+
* replaces the per-request ACL recheck in the content handler.
|
|
13
|
+
*/
|
|
14
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
15
|
+
export const CONTENT_GRANT_TTL_SECONDS = 55;
|
|
16
|
+
export const CONTENT_CUTOFF_BUDGET_SECONDS = 60;
|
|
17
|
+
export const CONTENT_TICKET_TTL_SECONDS = 120;
|
|
18
|
+
const SKEW_MS = (CONTENT_CUTOFF_BUDGET_SECONDS - CONTENT_GRANT_TTL_SECONDS) * 1000;
|
|
19
|
+
function encodeClaims(claims) {
|
|
20
|
+
return Buffer.from(JSON.stringify(claims), 'utf8').toString('base64url');
|
|
21
|
+
}
|
|
22
|
+
function sign(secret, payload) {
|
|
23
|
+
return createHmac('sha256', secret).update(payload, 'utf8').digest('base64url');
|
|
24
|
+
}
|
|
25
|
+
/** Create an HMAC grant signer over an injected secret and clock. */
|
|
26
|
+
export function createHmacGrantSigner(options) {
|
|
27
|
+
const secret = typeof options.secret === 'string'
|
|
28
|
+
? new TextEncoder().encode(options.secret)
|
|
29
|
+
: options.secret;
|
|
30
|
+
if (secret.length === 0)
|
|
31
|
+
throw new Error('invalid grant signer: empty secret');
|
|
32
|
+
const clock = options.clock ?? (() => Date.now());
|
|
33
|
+
const ttlMs = (options.ttlSeconds ?? CONTENT_GRANT_TTL_SECONDS) * 1000;
|
|
34
|
+
return {
|
|
35
|
+
issue(input) {
|
|
36
|
+
if (input.subjectId.length === 0 || input.artifactId.length === 0) {
|
|
37
|
+
throw new Error('invalid grant: empty subject or artifact');
|
|
38
|
+
}
|
|
39
|
+
const issuedAt = clock();
|
|
40
|
+
const claims = {
|
|
41
|
+
subjectId: input.subjectId,
|
|
42
|
+
artifactId: input.artifactId,
|
|
43
|
+
...(input.releaseId === undefined ? {} : { releaseId: input.releaseId }),
|
|
44
|
+
scope: 'read',
|
|
45
|
+
issuedAt,
|
|
46
|
+
expiresAt: issuedAt + ttlMs,
|
|
47
|
+
};
|
|
48
|
+
const payload = encodeClaims(claims);
|
|
49
|
+
return `${payload}.${sign(secret, payload)}`;
|
|
50
|
+
},
|
|
51
|
+
verify(token) {
|
|
52
|
+
const parts = token.split('.');
|
|
53
|
+
if (parts.length !== 2 || parts[0] === undefined || parts[1] === undefined) {
|
|
54
|
+
throw new Error('invalid grant: malformed token');
|
|
55
|
+
}
|
|
56
|
+
const expected = Buffer.from(sign(secret, parts[0]), 'utf8');
|
|
57
|
+
const actual = Buffer.from(parts[1], 'utf8');
|
|
58
|
+
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
|
|
59
|
+
throw new Error('invalid grant: signature mismatch');
|
|
60
|
+
}
|
|
61
|
+
let claims;
|
|
62
|
+
try {
|
|
63
|
+
claims = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8'));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new Error('invalid grant: malformed claims');
|
|
67
|
+
}
|
|
68
|
+
if (typeof claims.subjectId !== 'string' || claims.subjectId.length === 0 ||
|
|
69
|
+
typeof claims.artifactId !== 'string' || claims.artifactId.length === 0 ||
|
|
70
|
+
claims.scope !== 'read' ||
|
|
71
|
+
!Number.isFinite(claims.issuedAt) || !Number.isFinite(claims.expiresAt) ||
|
|
72
|
+
claims.expiresAt <= claims.issuedAt ||
|
|
73
|
+
claims.expiresAt - claims.issuedAt > CONTENT_GRANT_TTL_SECONDS * 1000) {
|
|
74
|
+
throw new Error('invalid grant: malformed claims');
|
|
75
|
+
}
|
|
76
|
+
const now = clock();
|
|
77
|
+
if (claims.issuedAt > now + SKEW_MS)
|
|
78
|
+
throw new Error('invalid grant: issued in the future');
|
|
79
|
+
if (now > claims.expiresAt)
|
|
80
|
+
throw new Error('grant expired');
|
|
81
|
+
return claims;
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** In-memory TicketStore double for unit tests. */
|
|
86
|
+
export function createMemoryTicketStore() {
|
|
87
|
+
const tickets = new Map();
|
|
88
|
+
return {
|
|
89
|
+
put(record) { tickets.set(record.ticketId, record); return Promise.resolve(); },
|
|
90
|
+
peek(ticketId) { return Promise.resolve(tickets.get(ticketId)); },
|
|
91
|
+
take(ticketId) {
|
|
92
|
+
const record = tickets.get(ticketId);
|
|
93
|
+
tickets.delete(ticketId);
|
|
94
|
+
return Promise.resolve(record);
|
|
95
|
+
},
|
|
96
|
+
remove(ticketId) { tickets.delete(ticketId); return Promise.resolve(); },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/** Single-use bound tickets; mismatched attempts do not consume the ticket. */
|
|
100
|
+
export function createTicketService(options) {
|
|
101
|
+
const clock = options.clock ?? (() => Date.now());
|
|
102
|
+
const ids = options.ids ?? (() => crypto.randomUUID());
|
|
103
|
+
const ttlMs = (options.ttlSeconds ?? CONTENT_TICKET_TTL_SECONDS) * 1000;
|
|
104
|
+
if (!Number.isFinite(ttlMs) || ttlMs <= 0)
|
|
105
|
+
throw new Error('invalid ticket: lifetime');
|
|
106
|
+
return {
|
|
107
|
+
async issue(input) {
|
|
108
|
+
if (typeof input.subjectId !== 'string' || input.subjectId.length === 0 ||
|
|
109
|
+
typeof input.artifactId !== 'string' || input.artifactId.length === 0 ||
|
|
110
|
+
typeof input.releaseId !== 'string' || input.releaseId.length === 0 ||
|
|
111
|
+
(input.channel !== 'stable' && input.channel !== 'pinned'))
|
|
112
|
+
throw new Error('invalid ticket: binding');
|
|
113
|
+
const ticketId = ids();
|
|
114
|
+
const expiresAt = clock() + ttlMs;
|
|
115
|
+
await options.store.put({
|
|
116
|
+
ticketId,
|
|
117
|
+
subjectId: input.subjectId,
|
|
118
|
+
artifactId: input.artifactId,
|
|
119
|
+
releaseId: input.releaseId,
|
|
120
|
+
channel: input.channel,
|
|
121
|
+
expiresAt,
|
|
122
|
+
});
|
|
123
|
+
return { ticketId, expiresAt };
|
|
124
|
+
},
|
|
125
|
+
async redeem(ticketId, expected) {
|
|
126
|
+
const record = await options.store.peek(ticketId);
|
|
127
|
+
if (record === undefined)
|
|
128
|
+
throw new Error('invalid ticket');
|
|
129
|
+
if (clock() >= record.expiresAt) {
|
|
130
|
+
await options.store.remove(ticketId);
|
|
131
|
+
throw new Error('ticket expired');
|
|
132
|
+
}
|
|
133
|
+
if (record.subjectId !== expected.subjectId || record.artifactId !== expected.artifactId ||
|
|
134
|
+
record.releaseId !== expected.releaseId || record.channel !== expected.channel) {
|
|
135
|
+
throw new Error('invalid ticket: binding mismatch');
|
|
136
|
+
}
|
|
137
|
+
const taken = await options.store.take(ticketId);
|
|
138
|
+
if (taken === undefined)
|
|
139
|
+
throw new Error('invalid ticket');
|
|
140
|
+
return taken;
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AclStore } from '../server/access.js';
|
|
2
|
+
import type { LifecycleService } from '../server/lifecycle.js';
|
|
3
|
+
import type { PublicationBlobStore, PublicationService } from '../server/publication.js';
|
|
4
|
+
import { type GrantSigner, type TicketService } from './grants.js';
|
|
5
|
+
export interface ContentRequest {
|
|
6
|
+
method: string;
|
|
7
|
+
host: string;
|
|
8
|
+
path: string;
|
|
9
|
+
/** Host-resolved authenticated subject (edge session), when present. */
|
|
10
|
+
actorId?: string;
|
|
11
|
+
cookie?: string;
|
|
12
|
+
ticket?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface ContentResponse {
|
|
15
|
+
status: number;
|
|
16
|
+
headers: Record<string, string>;
|
|
17
|
+
bytes?: Uint8Array;
|
|
18
|
+
body?: unknown;
|
|
19
|
+
}
|
|
20
|
+
export interface ContentHandlerDeps {
|
|
21
|
+
acls: AclStore;
|
|
22
|
+
lifecycle: LifecycleService;
|
|
23
|
+
publication: PublicationService;
|
|
24
|
+
blobs: PublicationBlobStore;
|
|
25
|
+
grants: GrantSigner;
|
|
26
|
+
tickets: TicketService;
|
|
27
|
+
baseDomain: string;
|
|
28
|
+
isApprovedAccount?: (principalId: string) => boolean;
|
|
29
|
+
assetCacheSeconds?: number;
|
|
30
|
+
}
|
|
31
|
+
/** Create the content handler over injected host ports (no server). */
|
|
32
|
+
export declare function createContentHandler(deps: ContentHandlerDeps): {
|
|
33
|
+
handle(request: ContentRequest): Promise<ContentResponse>;
|
|
34
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L-5d — transport-free content handler (spec-v8 §9 5d). Serves raw
|
|
3
|
+
* release bytes on the separate content domain (never HTML on the
|
|
4
|
+
* control origin). Per-version activation: a single-use ticket redeemed
|
|
5
|
+
* by the host-authenticated subject mints a short subject+artifact+
|
|
6
|
+
* release grant as a host-only cookie. Every request re-verifies the
|
|
7
|
+
* grant AND live ACL/lifecycle state, so withdrawal cuts readers at the
|
|
8
|
+
* next request (inside the 60s budget, no cache reliance). Republish
|
|
9
|
+
* demands fresh consent on the stable origin (401); pinned origins keep
|
|
10
|
+
* their release. Unknown/unreadable → same 404; bad grants → 401.
|
|
11
|
+
*/
|
|
12
|
+
import { errorBody } from '../http.js';
|
|
13
|
+
import { CONTENT_GRANT_TTL_SECONDS } from './grants.js';
|
|
14
|
+
import { contentHeaders, grantCookie, parseGrantCookie } from './headers.js';
|
|
15
|
+
import { isServiceWorkerPath, parseContentHost, resolveContentPath } from './routing.js';
|
|
16
|
+
function fail(status) {
|
|
17
|
+
return { status, headers: { 'content-type': 'application/json' }, body: errorBody(status) };
|
|
18
|
+
}
|
|
19
|
+
/** Create the content handler over injected host ports (no server). */
|
|
20
|
+
export function createContentHandler(deps) {
|
|
21
|
+
const isApproved = deps.isApprovedAccount ?? (() => false);
|
|
22
|
+
async function canReadNow(artifactId, subjectId) {
|
|
23
|
+
const acl = await deps.acls.getArtifactAcl(artifactId);
|
|
24
|
+
if (acl === undefined)
|
|
25
|
+
return false;
|
|
26
|
+
const state = await deps.lifecycle.stateOf(artifactId);
|
|
27
|
+
return deps.lifecycle.canRead({ artifactId, state }, acl, subjectId, 'read', isApproved);
|
|
28
|
+
}
|
|
29
|
+
async function serveFile(artifactId, manifest, filePath) {
|
|
30
|
+
const acl = await deps.acls.getArtifactAcl(artifactId);
|
|
31
|
+
if (acl === undefined)
|
|
32
|
+
return fail(404);
|
|
33
|
+
if (acl.storageScopeId === undefined)
|
|
34
|
+
return fail(500);
|
|
35
|
+
const parsed = JSON.parse(manifest);
|
|
36
|
+
const file = (parsed.files ?? []).find((entry) => entry.path === filePath);
|
|
37
|
+
if (file === undefined || typeof file.digest !== 'string')
|
|
38
|
+
return fail(404);
|
|
39
|
+
const hex = file.digest.startsWith('sha256:') ? file.digest.slice(7) : file.digest;
|
|
40
|
+
const bytes = await deps.blobs.getBlob(`artifacts/${acl.storageScopeId}/blobs/sha256/${hex}`);
|
|
41
|
+
if (bytes === undefined)
|
|
42
|
+
return fail(404);
|
|
43
|
+
if (typeof file.size === 'number' && bytes.length !== file.size)
|
|
44
|
+
return fail(422);
|
|
45
|
+
return {
|
|
46
|
+
status: 200,
|
|
47
|
+
headers: contentHeaders({
|
|
48
|
+
mimeType: typeof file.mimeType === 'string' ? file.mimeType : 'application/octet-stream',
|
|
49
|
+
size: bytes.length,
|
|
50
|
+
assetCacheSeconds: deps.assetCacheSeconds,
|
|
51
|
+
}),
|
|
52
|
+
bytes,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
async handle(request) {
|
|
57
|
+
if (request.method !== 'GET' && request.method !== 'HEAD')
|
|
58
|
+
return fail(404);
|
|
59
|
+
const origin = parseContentHost(request.host, deps.baseDomain);
|
|
60
|
+
if (origin === undefined)
|
|
61
|
+
return fail(404);
|
|
62
|
+
let artifactId;
|
|
63
|
+
let release;
|
|
64
|
+
if (origin.kind === 'stable') {
|
|
65
|
+
artifactId = origin.artifactId;
|
|
66
|
+
release = await deps.publication.getCurrent(artifactId);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
release = await deps.publication.getRelease(origin.releaseId);
|
|
70
|
+
if (release === undefined)
|
|
71
|
+
return fail(404);
|
|
72
|
+
const owner = JSON.parse(release.manifest).artifactId;
|
|
73
|
+
if (typeof owner !== 'string')
|
|
74
|
+
return fail(404);
|
|
75
|
+
artifactId = owner;
|
|
76
|
+
}
|
|
77
|
+
if (release === undefined)
|
|
78
|
+
return fail(404);
|
|
79
|
+
if (request.ticket !== undefined) {
|
|
80
|
+
if (request.actorId === undefined || request.actorId.length === 0)
|
|
81
|
+
return fail(401);
|
|
82
|
+
try {
|
|
83
|
+
await deps.tickets.redeem(request.ticket, {
|
|
84
|
+
subjectId: request.actorId, artifactId, releaseId: release.releaseId, channel: origin.kind,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return fail(401);
|
|
89
|
+
}
|
|
90
|
+
if (!(await canReadNow(artifactId, request.actorId)))
|
|
91
|
+
return fail(404);
|
|
92
|
+
const token = deps.grants.issue({
|
|
93
|
+
subjectId: request.actorId,
|
|
94
|
+
artifactId,
|
|
95
|
+
releaseId: release.releaseId,
|
|
96
|
+
});
|
|
97
|
+
return {
|
|
98
|
+
status: 200,
|
|
99
|
+
headers: {
|
|
100
|
+
'content-type': 'application/json',
|
|
101
|
+
'set-cookie': grantCookie(token, CONTENT_GRANT_TTL_SECONDS),
|
|
102
|
+
},
|
|
103
|
+
body: { activated: true, artifactId, releaseId: release.releaseId },
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const token = parseGrantCookie(request.cookie);
|
|
107
|
+
if (token === undefined)
|
|
108
|
+
return fail(401);
|
|
109
|
+
let claims;
|
|
110
|
+
try {
|
|
111
|
+
claims = deps.grants.verify(token);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return fail(401);
|
|
115
|
+
}
|
|
116
|
+
if (claims.artifactId !== artifactId || claims.releaseId !== release.releaseId) {
|
|
117
|
+
return fail(401); // fresh version consent: activate this release
|
|
118
|
+
}
|
|
119
|
+
if (request.actorId !== undefined && request.actorId !== claims.subjectId)
|
|
120
|
+
return fail(401);
|
|
121
|
+
if (!(await canReadNow(artifactId, claims.subjectId)))
|
|
122
|
+
return fail(404);
|
|
123
|
+
const filePath = resolveContentPath(request.path);
|
|
124
|
+
if (filePath === undefined || isServiceWorkerPath(filePath))
|
|
125
|
+
return fail(404);
|
|
126
|
+
return serveFile(artifactId, release.manifest, filePath);
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L-5d — content response headers and the grant cookie (spec-v8 §9 5d).
|
|
3
|
+
*
|
|
4
|
+
* Untrusted HTML is served only on the separate content domain with
|
|
5
|
+
* no-store caching, MIME sniffing disabled, framing denied (embed mode
|
|
6
|
+
* is refused in this pilot) and a restrictive CSP. The declared manifest
|
|
7
|
+
* MIME type is authoritative — types are never sniffed from bytes.
|
|
8
|
+
* Non-HTML assets may be cached only when the host explicitly sets an
|
|
9
|
+
* asset cache budget; the default stays no-store (cache is never a
|
|
10
|
+
* revocation mechanism). The grant cookie is host-only (`__Host-`
|
|
11
|
+
* prefix: Secure, Path=/, no Domain attribute).
|
|
12
|
+
*/
|
|
13
|
+
export declare const GRANT_COOKIE_NAME = "__Host-artifacts_grant";
|
|
14
|
+
export interface ContentHeaderInput {
|
|
15
|
+
mimeType: string;
|
|
16
|
+
size: number;
|
|
17
|
+
/** Explicit host opt-in for non-HTML asset caching (seconds). */
|
|
18
|
+
assetCacheSeconds?: number;
|
|
19
|
+
}
|
|
20
|
+
/** Security/cache headers for one served file (declared MIME wins). */
|
|
21
|
+
export declare function contentHeaders(input: ContentHeaderInput): Record<string, string>;
|
|
22
|
+
/** Host-only grant cookie header value (no Domain attribute, ever). */
|
|
23
|
+
export declare function grantCookie(token: string, maxAgeSeconds: number): string;
|
|
24
|
+
/** Extract the grant token from a Cookie header (undefined when absent). */
|
|
25
|
+
export declare function parseGrantCookie(cookieHeader: string | undefined): string | undefined;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L-5d — content response headers and the grant cookie (spec-v8 §9 5d).
|
|
3
|
+
*
|
|
4
|
+
* Untrusted HTML is served only on the separate content domain with
|
|
5
|
+
* no-store caching, MIME sniffing disabled, framing denied (embed mode
|
|
6
|
+
* is refused in this pilot) and a restrictive CSP. The declared manifest
|
|
7
|
+
* MIME type is authoritative — types are never sniffed from bytes.
|
|
8
|
+
* Non-HTML assets may be cached only when the host explicitly sets an
|
|
9
|
+
* asset cache budget; the default stays no-store (cache is never a
|
|
10
|
+
* revocation mechanism). The grant cookie is host-only (`__Host-`
|
|
11
|
+
* prefix: Secure, Path=/, no Domain attribute).
|
|
12
|
+
*/
|
|
13
|
+
export const GRANT_COOKIE_NAME = '__Host-artifacts_grant';
|
|
14
|
+
const HTML_CSP = [
|
|
15
|
+
"default-src 'self'",
|
|
16
|
+
"script-src 'self' 'unsafe-inline'",
|
|
17
|
+
"style-src 'self' 'unsafe-inline'",
|
|
18
|
+
"img-src 'self' data:",
|
|
19
|
+
"connect-src 'self'",
|
|
20
|
+
"base-uri 'none'",
|
|
21
|
+
"form-action 'none'",
|
|
22
|
+
"frame-ancestors 'none'",
|
|
23
|
+
].join('; ');
|
|
24
|
+
/** Security/cache headers for one served file (declared MIME wins). */
|
|
25
|
+
export function contentHeaders(input) {
|
|
26
|
+
const mimeType = input.mimeType.length === 0 ? 'application/octet-stream' : input.mimeType;
|
|
27
|
+
const isHtml = mimeType === 'text/html';
|
|
28
|
+
const withCharset = mimeType.startsWith('text/') ? `${mimeType}; charset=utf-8` : mimeType;
|
|
29
|
+
const cacheable = !isHtml && input.assetCacheSeconds !== undefined && input.assetCacheSeconds > 0;
|
|
30
|
+
return {
|
|
31
|
+
'content-type': withCharset,
|
|
32
|
+
'content-length': String(input.size),
|
|
33
|
+
'x-content-type-options': 'nosniff',
|
|
34
|
+
'cache-control': cacheable ? `private, max-age=${input.assetCacheSeconds}` : 'no-store',
|
|
35
|
+
'x-frame-options': 'DENY',
|
|
36
|
+
'referrer-policy': 'no-referrer',
|
|
37
|
+
...(isHtml ? { 'content-security-policy': HTML_CSP } : {}),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Host-only grant cookie header value (no Domain attribute, ever). */
|
|
41
|
+
export function grantCookie(token, maxAgeSeconds) {
|
|
42
|
+
return `${GRANT_COOKIE_NAME}=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAgeSeconds}`;
|
|
43
|
+
}
|
|
44
|
+
/** Extract the grant token from a Cookie header (undefined when absent). */
|
|
45
|
+
export function parseGrantCookie(cookieHeader) {
|
|
46
|
+
if (cookieHeader === undefined)
|
|
47
|
+
return undefined;
|
|
48
|
+
for (const part of cookieHeader.split(';')) {
|
|
49
|
+
const trimmed = part.trim();
|
|
50
|
+
if (trimmed.startsWith(`${GRANT_COOKIE_NAME}=`)) {
|
|
51
|
+
const value = trimmed.slice(GRANT_COOKIE_NAME.length + 1);
|
|
52
|
+
return value.length === 0 ? undefined : value;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type ContentOrigin = {
|
|
2
|
+
kind: 'stable';
|
|
3
|
+
artifactId: string;
|
|
4
|
+
} | {
|
|
5
|
+
kind: 'pinned';
|
|
6
|
+
releaseId: string;
|
|
7
|
+
};
|
|
8
|
+
/** Parse a content Host header against the configured base domain. */
|
|
9
|
+
export declare function parseContentHost(host: string, baseDomain: string): ContentOrigin | undefined;
|
|
10
|
+
/** Resolve a request path to a manifest file path (undefined = refuse). */
|
|
11
|
+
export declare function resolveContentPath(path: string): string | undefined;
|
|
12
|
+
/** True for service-worker script paths (denied in this pilot). */
|
|
13
|
+
export declare function isServiceWorkerPath(filePath: string): boolean;
|