@pepys/ingest 0.0.0-stage → 0.0.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/README.md +32 -2
- package/package.json +32 -4
- package/src/blob-store/blob-store.ts +6 -0
- package/src/blob-store/build-blob-hash.ts +6 -0
- package/src/blob-store/garage/create-garage-blob-store.ts +82 -0
- package/src/blob-store/local-folder/create-local-folder-blob-store.ts +56 -0
- package/src/config/read-ingest-config.ts +50 -0
- package/src/fact-log/check-append-batch.ts +45 -0
- package/src/fact-log/fact-log.ts +35 -0
- package/src/fact-log/make-batch-appender.ts +198 -0
- package/src/fact-log/memory/create-memory-fact-log.ts +174 -0
- package/src/fact-log/parse-stored-body.ts +18 -0
- package/src/fact-log/parse-stored-fact.ts +17 -0
- package/src/fact-log/pick-settlement.ts +20 -0
- package/src/fact-log/plan-appends.ts +45 -0
- package/src/fact-log/s2/create-s2-fact-log.ts +171 -0
- package/src/fact-log/s2/find-append-outcome.ts +35 -0
- package/src/index.ts +28 -0
- package/src/receive/answers/build-failure-answer.ts +12 -0
- package/src/receive/answers/build-success-answer.ts +19 -0
- package/src/receive/create-ingest.ts +90 -0
- package/src/receive/find-batch-problem.ts +26 -0
- package/src/receive/make-receive-handler.ts +241 -0
- package/src/receive/plan-batch.ts +31 -0
- package/src/receive/read-decoded-body.ts +33 -0
- package/src/receive/read-sent-body.ts +22 -0
- package/src/receive/read-stream-up-to.ts +44 -0
- package/src/receive/recent-bodies/create-recent-bodies.ts +55 -0
- package/src/receive/resync-marks/collect-returned-entities.ts +16 -0
- package/src/receive/resync-marks/collect-tied-entities.ts +27 -0
- package/src/receive/resync-marks/create-resync-marks.ts +106 -0
- package/src/receive/write-batch.ts +79 -0
- package/src/sender-proof/fake/create-fake-sender-proof.ts +14 -0
- package/src/sender-proof/imp-identity/create-imp-identity-sender-proof.ts +58 -0
- package/src/sender-proof/imp-identity/read-imp-identity-trust.ts +87 -0
- package/src/sender-proof/imp-identity/types.ts +38 -0
- package/src/sender-proof/none/no-sender-proof.ts +7 -0
- package/src/serve/serve-ingest.ts +85 -0
- package/src/source-port/types.ts +64 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Sender, SenderProof } from '@pepys/kit';
|
|
2
|
+
import type { ImpIdentityTrust, ImpIdentityVerifier, VerifiedImp } from './types.ts';
|
|
3
|
+
|
|
4
|
+
interface ImpIdentityOptions<Document> {
|
|
5
|
+
verify: ImpIdentityVerifier<Document>['verify'];
|
|
6
|
+
trust: ImpIdentityTrust<Document>;
|
|
7
|
+
now: () => number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// The broker removes any `Imp-Identity` header a guest sends and adds its own: a compact JWS. A
|
|
11
|
+
// header of any other shape, two joined headers included, never reaches the verifier.
|
|
12
|
+
const compactToken = /^[\w-]+\.[\w-]+\.[\w-]+$/u;
|
|
13
|
+
|
|
14
|
+
// Proves the sender from the signed identity token of a worker's machine. The issuer and the
|
|
15
|
+
// subject come only from the verified token, and every failure, a throw included, proves nothing.
|
|
16
|
+
// It never logs the token.
|
|
17
|
+
export function createImpIdentitySenderProof<Document>(
|
|
18
|
+
options: ImpIdentityOptions<Document>,
|
|
19
|
+
): SenderProof {
|
|
20
|
+
return {
|
|
21
|
+
prove: async (requirement, headers) => {
|
|
22
|
+
const documents = options.trust.issuers.get(requirement.issuersEnv) ?? [];
|
|
23
|
+
const audience = options.trust.audiences.get(requirement.audienceEnv);
|
|
24
|
+
const header = headers.get('imp-identity');
|
|
25
|
+
|
|
26
|
+
if (
|
|
27
|
+
documents.length === 0 ||
|
|
28
|
+
audience === undefined ||
|
|
29
|
+
header === null ||
|
|
30
|
+
!compactToken.test(header)
|
|
31
|
+
) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const verified = await options.verify(header, {
|
|
37
|
+
issuers: documents,
|
|
38
|
+
audience,
|
|
39
|
+
nowSeconds: Math.floor(options.now() / 1000),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return verified === null ? null : toSender(verified);
|
|
43
|
+
} catch {
|
|
44
|
+
console.error('the identity verifier failed, so the delivery proves no sender');
|
|
45
|
+
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function toSender(verified: VerifiedImp): Sender | null {
|
|
53
|
+
if (verified.issuer === '' || verified.id === '') {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { issuer: verified.issuer, subject: verified.id, name: verified.name };
|
|
58
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { Result, SenderRequirement, SourceDefinition } from '@pepys/kit';
|
|
4
|
+
import type { ImpIdentityTrust, ImpIdentityVerifier } from './types.ts';
|
|
5
|
+
|
|
6
|
+
interface TrustReadOptions<Document> {
|
|
7
|
+
sources: readonly SourceDefinition[];
|
|
8
|
+
|
|
9
|
+
// the environment of the ingest process; never a request
|
|
10
|
+
environment: Readonly<Record<string, string | undefined>>;
|
|
11
|
+
loadIssuers: ImpIdentityVerifier<Document>['loadIssuers'];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// An unset or empty variable, or an empty folder, leaves its paths with no entry, so they refuse
|
|
15
|
+
// every delivery. A folder that cannot be read, or a document the verifier refuses, fails the
|
|
16
|
+
// start.
|
|
17
|
+
export async function readImpIdentityTrust<Document>(
|
|
18
|
+
options: TrustReadOptions<Document>,
|
|
19
|
+
): Promise<Result<ImpIdentityTrust<Document>, string>> {
|
|
20
|
+
const requirements = collectSenderRequirements(options.sources);
|
|
21
|
+
|
|
22
|
+
const issuers = new Map<string, readonly Document[]>();
|
|
23
|
+
const audiences = new Map<string, string>();
|
|
24
|
+
|
|
25
|
+
for (const name of new Set(requirements.map((requirement) => requirement.audienceEnv))) {
|
|
26
|
+
const audience = options.environment[name] ?? '';
|
|
27
|
+
|
|
28
|
+
if (audience !== '') {
|
|
29
|
+
audiences.set(name, audience);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (const name of new Set(requirements.map((requirement) => requirement.issuersEnv))) {
|
|
34
|
+
const folder = options.environment[name] ?? '';
|
|
35
|
+
|
|
36
|
+
if (folder === '') {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const documents = await readFolderDocuments(folder);
|
|
41
|
+
|
|
42
|
+
if (!documents.ok) {
|
|
43
|
+
return { ok: false, failure: `${name}: ${documents.failure}` };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (documents.value.length === 0) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
issuers.set(name, options.loadIssuers(documents.value));
|
|
52
|
+
} catch (error) {
|
|
53
|
+
const reason = error instanceof Error ? error.message : 'the verifier gave no reason';
|
|
54
|
+
|
|
55
|
+
return { ok: false, failure: `${name}: the trust documents are invalid: ${reason}` };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { ok: true, value: { issuers, audiences } };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function collectSenderRequirements(sources: readonly SourceDefinition[]): SenderRequirement[] {
|
|
63
|
+
return sources.flatMap((source) =>
|
|
64
|
+
source.live?.kind === 'receive' && source.live.sender !== undefined ? [source.live.sender] : [],
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function readFolderDocuments(folder: string): Promise<Result<unknown[], string>> {
|
|
69
|
+
try {
|
|
70
|
+
const entries = await readdir(folder);
|
|
71
|
+
|
|
72
|
+
const files = entries.filter((file) => file.endsWith('.json')).toSorted();
|
|
73
|
+
const documents: unknown[] = [];
|
|
74
|
+
|
|
75
|
+
for (const file of files) {
|
|
76
|
+
const document: unknown = await Bun.file(join(folder, file)).json();
|
|
77
|
+
|
|
78
|
+
documents.push(document);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { ok: true, value: documents };
|
|
82
|
+
} catch (error) {
|
|
83
|
+
const reason = error instanceof Error ? error.message : 'no reason given';
|
|
84
|
+
|
|
85
|
+
return { ok: false, failure: `the trust documents cannot be read: ${reason}` };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The imp that a verified identity token names. The shape of the verifier's answer in the
|
|
2
|
+
// `@impz/client/identity` entry of impz.
|
|
3
|
+
export interface VerifiedImp {
|
|
4
|
+
// the token's `iss`: the impd that signed it
|
|
5
|
+
readonly issuer: string;
|
|
6
|
+
|
|
7
|
+
// the token's `sub`: the imp's ID, unique on one impd only
|
|
8
|
+
readonly id: string;
|
|
9
|
+
|
|
10
|
+
// the imp's name, for logs; a name is free again after a destroy, so it is no key
|
|
11
|
+
readonly name: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// What the deployment trusts, read from its environment at start: the trust documents of each
|
|
15
|
+
// `issuersEnv` and the audience of each `audienceEnv` that its paths name. A name with no entry
|
|
16
|
+
// refuses every delivery on its paths.
|
|
17
|
+
export interface ImpIdentityTrust<Document> {
|
|
18
|
+
readonly issuers: ReadonlyMap<string, readonly Document[]>;
|
|
19
|
+
readonly audiences: ReadonlyMap<string, string>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface VerifyOptions<Document> {
|
|
23
|
+
readonly issuers: readonly Document[];
|
|
24
|
+
readonly audience: string;
|
|
25
|
+
readonly nowSeconds: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The verifier of `@impz/client/identity`, which owns the token format and the trust document
|
|
29
|
+
// format. `Document` stays opaque to ingest.
|
|
30
|
+
export interface ImpIdentityVerifier<Document> {
|
|
31
|
+
// Answers null for a token to refuse: a bad signature, an issuer with no trust document, another
|
|
32
|
+
// audience, or a token out of its time. Throws on an empty or doubled list of documents.
|
|
33
|
+
verify: (header: string | null, options: VerifyOptions<Document>) => Promise<VerifiedImp | null>;
|
|
34
|
+
|
|
35
|
+
// Parses the documents a deployment pinned. Throws on an invalid document, on none, and on two
|
|
36
|
+
// with one issuer.
|
|
37
|
+
loadIssuers: (documents: readonly unknown[]) => Document[];
|
|
38
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SenderProof } from '@pepys/kit';
|
|
2
|
+
|
|
3
|
+
// Proves no sender, so a path that needs one refuses every delivery. Ingest uses it for a path whose
|
|
4
|
+
// trust documents are missing.
|
|
5
|
+
export const noSenderProof: SenderProof = {
|
|
6
|
+
prove: () => Promise.resolve(null),
|
|
7
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { checkDeployment, listReceiveRoutes } from '@pepys/kit';
|
|
2
|
+
import type { Deployment, SourceDefinition, StreamName } from '@pepys/kit';
|
|
3
|
+
import { makeShutdownHandler } from '@pepys/schema';
|
|
4
|
+
import { toTagged } from '@zgeoff/std';
|
|
5
|
+
import { readIngestConfig } from '../config/read-ingest-config.ts';
|
|
6
|
+
import { createS2FactLog } from '../fact-log/s2/create-s2-fact-log.ts';
|
|
7
|
+
import { createIngest } from '../receive/create-ingest.ts';
|
|
8
|
+
import { noSenderProof } from '../sender-proof/none/no-sender-proof.ts';
|
|
9
|
+
|
|
10
|
+
interface ServeIngestOptions {
|
|
11
|
+
deployment: Deployment;
|
|
12
|
+
environment: Readonly<Record<string, string | undefined>>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// The ingest process of a deployment. It exits 1 at start on a failed deployment check or on any
|
|
16
|
+
// missing variable, so no route ever runs with an empty secret.
|
|
17
|
+
export async function serveIngest(options: ServeIngestOptions): Promise<void> {
|
|
18
|
+
const checked = checkDeployment(options.deployment);
|
|
19
|
+
|
|
20
|
+
if (!checked.ok) {
|
|
21
|
+
console.error('the deployment fails its check', JSON.stringify(checked.failure));
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const sources = checked.value.sources;
|
|
26
|
+
const config = readIngestConfig(options.environment, collectSecretNames(sources));
|
|
27
|
+
|
|
28
|
+
if (!config.ok) {
|
|
29
|
+
console.error(config.failure.join('\n'));
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The S2 adapter creates the basin and each missing stream, so no setup command creates streams.
|
|
34
|
+
const log = await createS2FactLog({ ...config.value.s2, streams: collectStreams(sources) });
|
|
35
|
+
|
|
36
|
+
// No adapter verifies a sender yet, so a path that needs one refuses every delivery.
|
|
37
|
+
const ingest = createIngest({
|
|
38
|
+
deployment: checked.value,
|
|
39
|
+
secrets: config.value.secrets,
|
|
40
|
+
log,
|
|
41
|
+
senderProof: noSenderProof,
|
|
42
|
+
appendRetry: { maxAttempts: 5, retryDelayMs: 200 },
|
|
43
|
+
now: () => Date.now(),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
for (const route of listReceiveRoutes(options.deployment)) {
|
|
47
|
+
console.log(`route ${route.route}`, JSON.stringify(route));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const server = Bun.serve({
|
|
51
|
+
hostname: config.value.host,
|
|
52
|
+
port: config.value.port,
|
|
53
|
+
fetch: ingest.handleRequest,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
console.log(`ingest listening on ${server.hostname}:${server.port}`);
|
|
57
|
+
|
|
58
|
+
const shutdown = makeShutdownHandler({
|
|
59
|
+
stop: () => server.stop(),
|
|
60
|
+
logError: (message, error) => {
|
|
61
|
+
console.error(message, error);
|
|
62
|
+
},
|
|
63
|
+
exit: (code) => process.exit(code),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
|
67
|
+
process.on(signal, () => {
|
|
68
|
+
void shutdown();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function collectSecretNames(sources: readonly SourceDefinition[]): string[] {
|
|
74
|
+
const names = sources.flatMap((source) =>
|
|
75
|
+
source.live?.kind === 'receive' ? [source.live.check.secretEnv] : [],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return [...new Set(names)];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function collectStreams(sources: readonly SourceDefinition[]): StreamName[] {
|
|
82
|
+
const streams = sources.flatMap((source) => source.families.map((family) => family.stream));
|
|
83
|
+
|
|
84
|
+
return [...new Set(streams)].map((stream) => toTagged<StreamName>(stream));
|
|
85
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BlobHash,
|
|
3
|
+
ConsumerDeclaration,
|
|
4
|
+
EntityKind,
|
|
5
|
+
FactEnvelope,
|
|
6
|
+
LinkTypeDeclaration,
|
|
7
|
+
Result,
|
|
8
|
+
StreamName,
|
|
9
|
+
} from '@pepys/kit';
|
|
10
|
+
|
|
11
|
+
export interface FactFamily<Payload> {
|
|
12
|
+
stream: StreamName;
|
|
13
|
+
types: readonly string[];
|
|
14
|
+
api: string;
|
|
15
|
+
parsePayload: (raw: unknown) => Result<Payload, { reason: string }>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Provisional: source-retention
|
|
19
|
+
export interface SourceDefinition {
|
|
20
|
+
name: string;
|
|
21
|
+
families: readonly FactFamily<unknown>[];
|
|
22
|
+
entityKinds: readonly EntityKind[];
|
|
23
|
+
linkTypes: readonly LinkTypeDeclaration[];
|
|
24
|
+
consumers: readonly ConsumerDeclaration[];
|
|
25
|
+
live: ReceivePath | PullPath;
|
|
26
|
+
recovery: PullPath;
|
|
27
|
+
backfill: PullPath;
|
|
28
|
+
resync: PullPath;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ReceivePath {
|
|
32
|
+
kind: 'receive';
|
|
33
|
+
route: string;
|
|
34
|
+
receive: (request: Request) => Promise<Received>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type Received =
|
|
38
|
+
| { kind: 'refused'; status: 400 | 401 | 405 | 422; reason: string }
|
|
39
|
+
| { kind: 'ignored' }
|
|
40
|
+
| { kind: 'batch'; batch: Batch };
|
|
41
|
+
|
|
42
|
+
export interface PullPath {
|
|
43
|
+
kind: 'pull';
|
|
44
|
+
name: string;
|
|
45
|
+
intervalMs: number | null;
|
|
46
|
+
pull: (cursor: string | null) => Promise<Pulled>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Provisional: source-retention
|
|
50
|
+
export type Pulled =
|
|
51
|
+
| { kind: 'batch'; batch: Batch; cursor: string; isDone: boolean }
|
|
52
|
+
| { kind: 'expired' }
|
|
53
|
+
| { kind: 'unavailable'; retryAfterMs: number }
|
|
54
|
+
| { kind: 'rejected'; reason: string };
|
|
55
|
+
|
|
56
|
+
export interface Batch {
|
|
57
|
+
facts: readonly PendingFact[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface PendingFact {
|
|
61
|
+
stream: StreamName;
|
|
62
|
+
fact: FactEnvelope<unknown>;
|
|
63
|
+
blobs: readonly { hash: BlobHash; bytes: Uint8Array }[];
|
|
64
|
+
}
|