@volter/world-core 3.0.164 → 3.0.165
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/dist/generated/pack-facts.json +44 -3660
- package/dist/pack-facts.cjs +4 -4
- package/dist/src/browser.d.ts +4 -0
- package/dist/src/browser.js +10 -0
- package/dist/src/derived-core.d.ts +45 -5
- package/dist/src/derived-core.js +53 -8
- package/dist/src/derived-real.js +144 -2
- package/dist/src/derived.d.ts +1 -1
- package/dist/src/executor.d.ts +2 -2
- package/dist/src/executor.js +13 -0
- package/dist/src/git/index.d.ts +1 -0
- package/dist/src/git/index.js +1 -0
- package/dist/src/git/smart-http-client.d.ts +28 -0
- package/dist/src/git/smart-http-client.js +95 -0
- package/dist/src/git/smart-http.d.ts +3 -0
- package/dist/src/git/smart-http.js +9 -0
- package/dist/src/head.d.ts +16 -1
- package/dist/src/head.js +52 -7
- package/dist/src/html.d.ts +29 -0
- package/dist/src/html.js +64 -0
- package/dist/src/index.d.ts +3 -1
- package/dist/src/index.js +3 -0
- package/dist/src/pack-fetch.js +41 -2
- package/dist/src/pack-paths.d.ts +2 -3
- package/dist/src/pack-paths.js +3 -13
- package/dist/src/packRegistry.d.ts +17 -1
- package/dist/src/runtime.d.ts +2 -0
- package/dist/src/runtime.js +3 -0
- package/dist/src/serve-http.d.ts +1 -1
- package/dist/src/serve-http.js +11 -2
- package/dist/src/smtp-server.d.ts +78 -0
- package/dist/src/smtp-server.js +160 -0
- package/dist/src/smtp.d.ts +29 -11
- package/dist/src/smtp.js +221 -52
- package/generated/pack-facts.json +44 -3660
- package/pack-facts.cjs +4 -4
- package/package.json +4 -2
- package/src/browser.ts +10 -0
- package/src/derived-core.ts +68 -12
- package/src/derived-real.ts +121 -2
- package/src/derived.ts +2 -2
- package/src/executor.ts +13 -2
- package/src/git/index.ts +1 -0
- package/src/git/smart-http-client.ts +99 -0
- package/src/git/smart-http.ts +7 -0
- package/src/head.ts +37 -6
- package/src/html.ts +72 -0
- package/src/index.ts +4 -1
- package/src/pack-fetch.ts +35 -2
- package/src/pack-paths.ts +3 -11
- package/src/packRegistry.ts +9 -1
- package/src/runtime.ts +3 -0
- package/src/serve-http.ts +11 -3
- package/src/smtp-server.ts +166 -0
- package/src/smtp.ts +199 -53
package/src/head.ts
CHANGED
|
@@ -34,6 +34,9 @@ export type PushOutcome = {
|
|
|
34
34
|
actionId?: string;
|
|
35
35
|
/** the group's other entries the answer named an id for (a resource's `companions`) */
|
|
36
36
|
also?: Array<{ actionId: string; externalId: string; data?: Record<string, unknown> }>;
|
|
37
|
+
/** the group's entries the vendor refused while it took the rest (a git push to a root without `atomic`, each ref its
|
|
38
|
+
* own outcome: ADR 0031); each settles refused with its reason */
|
|
39
|
+
refused?: Array<{ actionId: string; reason: string }>;
|
|
37
40
|
};
|
|
38
41
|
/** What a perform is handed beside the executor: a resolver from a local id to the vendor's (an
|
|
39
42
|
* entry authored against `twin-1` crosses against `REAL-42` once that subject's landing adopted it). */
|
|
@@ -269,6 +272,8 @@ export type DeployReport = {
|
|
|
269
272
|
refused?: { actionId: string; check: string; reason: string };
|
|
270
273
|
/** the first failure, with the vendor's words; nothing after it was attempted */
|
|
271
274
|
failed?: { actionId: string; error: string };
|
|
275
|
+
/** a group the vendor took in part: its entries it refused, each with its reason; nothing after it was attempted */
|
|
276
|
+
partial?: Array<{ actionId: string; reason: string }>;
|
|
272
277
|
/** entries after the refusal or failure, not attempted */
|
|
273
278
|
skipped: string[];
|
|
274
279
|
/** entries whose landed copy already said `deployed` — not performed again */
|
|
@@ -287,12 +292,13 @@ function receiptsByEntry(parent: Entry[]): Map<string, Receipt> {
|
|
|
287
292
|
/** Whether an entry records a call of the vendor's surface (an operation or a GraphQL mutation): what a perform can send
|
|
288
293
|
* again (the real-system adapters, "What the derived core records with a write"). */
|
|
289
294
|
function surfaceWrite(e: { input?: Record<string, unknown> }): boolean {
|
|
290
|
-
|
|
295
|
+
// a push to a World repository is the vendor's too: a deploy sends it as a git push (derived-core.ts pushedInput)
|
|
296
|
+
return typeof e.input?.operationId === 'string' || (e.input?.graphql !== undefined && e.input.graphql !== null) || (e.input?.git !== undefined && e.input.git !== null);
|
|
291
297
|
}
|
|
292
298
|
|
|
293
299
|
/** The branch entries a root twin still has to settle: `set` rows, not reverted, whose landed copy is absent or says
|
|
294
300
|
* `failed`/`skipped` (a `deployed` or `refused` copy settles the entry), split into what is offered to the vendor (a
|
|
295
|
-
* surface write on a subject the vendor can have) and what is the World's own: a write a door, a screen, the gap, the
|
|
301
|
+
* surface write or a git push on a subject the vendor can have) and what is the World's own: a write a door, a screen, the gap, the
|
|
296
302
|
* clock or a seed made, and any write on a subject only the World's own writes made. */
|
|
297
303
|
export function settlingEntries(service: string, root?: string): { offered: TwinAction[]; worldOnly: TwinAction[] } {
|
|
298
304
|
const parent = parentEntries(service, root);
|
|
@@ -436,7 +442,10 @@ async function performNow(opts: Parameters<typeof performEntries>[0]): Promise<D
|
|
|
436
442
|
const answered = outcome.data !== null && typeof outcome.data === 'object' && !Array.isArray(outcome.data) ? (outcome.data as Record<string, unknown>) : {};
|
|
437
443
|
const primary = resolved.find((a) => a.id === outcome.actionId) ?? resolved[0]!;
|
|
438
444
|
const also = new Map((outcome.also ?? []).map((a) => [a.actionId, a]));
|
|
445
|
+
const refusedHere = new Map((outcome.refused ?? []).map((r) => [r.actionId, r.reason]));
|
|
439
446
|
for (const action of resolved) {
|
|
447
|
+
const refusal = refusedHere.get(action.id);
|
|
448
|
+
if (refusal !== undefined) { settle(action, at, { status: 'refused', reason: refusal }); refusedIds.add(action.subject.id); continue; }
|
|
440
449
|
const answeredId = action === primary ? outcome.externalId : also.get(action.id)?.externalId;
|
|
441
450
|
const externalId = answeredId || aliases.get(`${action.subject.type}:${action.subject.id}`);
|
|
442
451
|
settle(action, at, { status: 'deployed', ...(action === primary && outcome.url ? { url: outcome.url } : {}) }, { ...action.fields, ...(action === primary ? answered : also.get(action.id)?.data ?? {}) }, externalId || undefined);
|
|
@@ -444,6 +453,7 @@ async function performNow(opts: Parameters<typeof performEntries>[0]): Promise<D
|
|
|
444
453
|
report.pushed += 1;
|
|
445
454
|
report.deployed.push({ actionId: action.id, at, ...(externalId ? { externalId } : {}), ...(data !== undefined ? { data } : {}), ...(action === primary && outcome.response ? { response: outcome.response } : {}) });
|
|
446
455
|
}
|
|
456
|
+
if (refusedHere.size) { report.partial = [...refusedHere].map(([actionId, reason]) => ({ actionId, reason })); stopped = true; }
|
|
447
457
|
} catch (error) {
|
|
448
458
|
// a write the vendor refuses (a 4xx: it can never be sent as it is) settles the group refused, as a check's
|
|
449
459
|
// refusal does, so the next deploy moves on to the group after it; anything else failed and stays offered
|
|
@@ -515,7 +525,7 @@ export async function deferPerform<T>(request: string, fn: () => Promise<T>): Pr
|
|
|
515
525
|
/** Under `auto`, perform the entries one request made as one call (the real-system adapters, "Perform"): the ids the
|
|
516
526
|
* vendor gave them by local id, or the vendor's refusal or failure, the request's entries reverted as performAtHead
|
|
517
527
|
* reverts one. Nothing when the head is not real and auto, or the request wrote nothing the vendor is offered. */
|
|
518
|
-
export async function performRequestGroup(service: string, request: string, root?: string): Promise<{ ids: Map<string, string>; response?: RemoteExecuteResponse } | RefusedWriteError | VendorWriteError> {
|
|
528
|
+
export async function performRequestGroup(service: string, request: string, root?: string): Promise<{ ids: Map<string, string>; response?: RemoteExecuteResponse; refused?: Array<{ actionId: string; reason: string }> } | RefusedWriteError | VendorWriteError> {
|
|
519
529
|
const head = headOf(service, root);
|
|
520
530
|
const ids = new Map<string, string>();
|
|
521
531
|
if (head.kind !== 'real' || head.deploy !== 'auto') return { ids };
|
|
@@ -525,21 +535,42 @@ export async function performRequestGroup(service: string, request: string, root
|
|
|
525
535
|
try { report = await performEntries({ service, actionIds: group.map((e) => e.id), ...(root !== undefined ? { root } : {}) }); }
|
|
526
536
|
catch (error) {
|
|
527
537
|
if (error instanceof DeploymentOrderError) {
|
|
528
|
-
|
|
538
|
+
revertRequest(service, request, group, root);
|
|
529
539
|
return new VendorWriteError(`An earlier ${service} write is still pending; this request was rolled back before reaching the vendor. Retry after that write finishes.`, group[0]!.id);
|
|
530
540
|
}
|
|
531
541
|
throw error instanceof HeadError ? error : new HeadError(error instanceof Error ? error.message : String(error));
|
|
532
542
|
}
|
|
533
543
|
if (report.refused || report.failed) {
|
|
534
|
-
|
|
544
|
+
revertRequest(service, request, group, root);
|
|
535
545
|
return report.refused ? new RefusedWriteError(report.refused.check, report.refused.reason, report.refused.actionId) : new VendorWriteError(report.failed!.error, report.failed!.actionId);
|
|
536
546
|
}
|
|
547
|
+
if (report.partial) revertRequest(service, request, group.filter((e) => report.partial!.some((r) => r.actionId === e.id)), root);
|
|
537
548
|
for (const d of report.deployed) {
|
|
538
549
|
const e = group.find((g) => g.id === d.actionId);
|
|
539
550
|
if (e && d.externalId && d.externalId !== e.subject.id) ids.set(e.subject.id, d.externalId);
|
|
540
551
|
}
|
|
541
552
|
const response = report.deployed.find((entry) => entry.response)?.response;
|
|
542
|
-
return { ids, ...(response ? { response } : {}) };
|
|
553
|
+
return { ids, ...(response ? { response } : {}), ...(report.partial ? { refused: report.partial } : {}) };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Revert what a request's refused entries made, latest first. An entry that is a declared push a screen recorded
|
|
557
|
+
* (`input.git`, ADR 0031) owns the request's writes after it up to the next such push, its push's effects (the
|
|
558
|
+
* reactions and vendor moves the pack runs on it), which go with it; any other refused entry goes alone, as the group
|
|
559
|
+
* it is in. */
|
|
560
|
+
function revertRequest(service: string, request: string, refused: TwinAction[], root?: string): void {
|
|
561
|
+
const pushed = (e: { input?: Record<string, unknown> }): boolean => e.input?.git !== undefined && e.input.git !== null;
|
|
562
|
+
const undo = new Set<string>();
|
|
563
|
+
if (refused.some(pushed)) {
|
|
564
|
+
const made = (branchEntries(service, root) as TwinAction[]).filter((e) => e.correlationId === request && e.op === 'set');
|
|
565
|
+
for (const r of refused) {
|
|
566
|
+
if (!pushed(r)) { undo.add(r.id); continue; }
|
|
567
|
+
const at = made.findIndex((e) => e.id === r.id);
|
|
568
|
+
if (at < 0) { undo.add(r.id); continue; }
|
|
569
|
+
undo.add(r.id);
|
|
570
|
+
for (const e of made.slice(at + 1)) { if (pushed(e)) break; undo.add(e.id); }
|
|
571
|
+
}
|
|
572
|
+
} else for (const r of refused) undo.add(r.id);
|
|
573
|
+
for (const e of branchEntries(service, root).filter((x) => undo.has(x.id)).reverse()) revertAction({ service, actionId: e.id, occurredAt: worldNow(), ...(root !== undefined ? { root } : {}) });
|
|
543
574
|
}
|
|
544
575
|
|
|
545
576
|
/** Wrap a pack's fetch: a `RefusedWriteError` or `VendorWriteError` the head raised becomes the
|
package/src/html.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The WHATWG HTML grammar is shared mechanics; what a pack keeps of a document is the pack's own policy.
|
|
2
|
+
// The exact optional dependency belongs to this kernel, so Node and browser bundles resolve one parser.
|
|
3
|
+
// Source: parse5 7.3.0 (parseFragment, serialize, defaultTreeAdapter).
|
|
4
|
+
//
|
|
5
|
+
// A fragment is parsed in an HTML `<body>` context. Elements keep their namespace (HTML, SVG or MathML) and attributes
|
|
6
|
+
// their prefix and namespace, and serialization reproduces what the tree holds: a foreign subtree survives parsing, and
|
|
7
|
+
// a pack's policy may drop it.
|
|
8
|
+
export type HtmlAttribute = { name: string; value: string; prefix?: string; namespace?: string };
|
|
9
|
+
export type HtmlElement = { type: 'element'; name: string; namespace: string; attributes: HtmlAttribute[]; children: HtmlNode[] };
|
|
10
|
+
export type HtmlText = { type: 'text'; value: string };
|
|
11
|
+
export type HtmlNode = HtmlElement | HtmlText;
|
|
12
|
+
export class HtmlError extends Error {
|
|
13
|
+
constructor(readonly code: 'parser-unavailable') {
|
|
14
|
+
super('HTML parser parse5 7.3.0 is unavailable');
|
|
15
|
+
this.name = 'HtmlError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
type Grammar = typeof import('parse5');
|
|
19
|
+
type Tree = import('parse5').DefaultTreeAdapterMap;
|
|
20
|
+
let grammar: Promise<Grammar> | undefined;
|
|
21
|
+
function loadGrammar(): Promise<Grammar> {
|
|
22
|
+
return grammar ??= import('parse5').catch(() => { grammar = undefined; throw new HtmlError('parser-unavailable'); });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A fragment parsed as WHATWG fragment parsing does in an HTML `<body>` context: elements and text, comments dropped,
|
|
27
|
+
* an HTML `<template>`'s content carried as that element's children. Importing this module does not load the grammar.
|
|
28
|
+
*/
|
|
29
|
+
export async function parseFragment(source: string): Promise<HtmlNode[]> {
|
|
30
|
+
const { parseFragment: parse, defaultTreeAdapter: tree, html } = await loadGrammar();
|
|
31
|
+
const convert = (nodes: Tree['node'][]): HtmlNode[] => {
|
|
32
|
+
const out: HtmlNode[] = [];
|
|
33
|
+
for (const node of nodes) {
|
|
34
|
+
if (node.nodeName === '#text') out.push({ type: 'text', value: (node as Tree['textNode']).value });
|
|
35
|
+
else if ('tagName' in node) {
|
|
36
|
+
const element = node as Tree['element'];
|
|
37
|
+
// only an HTML template has content; an SVG or MathML `template` is a foreign element with children
|
|
38
|
+
const children = element.tagName === 'template' && element.namespaceURI === html.NS.HTML
|
|
39
|
+
? (element as Tree['template']).content.childNodes
|
|
40
|
+
: element.childNodes;
|
|
41
|
+
out.push({
|
|
42
|
+
type: 'element', name: element.tagName, namespace: element.namespaceURI,
|
|
43
|
+
attributes: element.attrs.map(attr => ({ name: attr.name, value: attr.value, ...(attr.prefix ? { prefix: attr.prefix } : {}), ...(attr.namespace ? { namespace: attr.namespace } : {}) })),
|
|
44
|
+
children: convert(children),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
};
|
|
50
|
+
return convert(parse(tree.createElement('body', html.NS.HTML, []), source, {}).childNodes);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Nodes serialized by the WHATWG HTML fragment serialization: its escaping, its void elements, its foreign names. */
|
|
54
|
+
export async function serialize(nodes: HtmlNode[]): Promise<string> {
|
|
55
|
+
const { serialize: write, defaultTreeAdapter: tree, html } = await loadGrammar();
|
|
56
|
+
const append = (parent: Tree['parentNode'], list: HtmlNode[]): void => {
|
|
57
|
+
for (const node of list) {
|
|
58
|
+
if (node.type === 'text') { tree.insertText(parent, node.value); continue; }
|
|
59
|
+
const element = tree.createElement(node.name, node.namespace as import('parse5').html.NS,
|
|
60
|
+
node.attributes.map(({ name, value, prefix, namespace }) => ({ name, value, ...(prefix ? { prefix } : {}), ...(namespace ? { namespace } : {}) })));
|
|
61
|
+
tree.appendChild(parent, element);
|
|
62
|
+
if (node.name === 'template' && node.namespace === html.NS.HTML) {
|
|
63
|
+
const content = tree.createDocumentFragment();
|
|
64
|
+
tree.setTemplateContent(element as Tree['template'], content);
|
|
65
|
+
append(content, node.children);
|
|
66
|
+
} else append(element, node.children);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const fragment = tree.createDocumentFragment();
|
|
70
|
+
append(fragment, nodes);
|
|
71
|
+
return write(fragment);
|
|
72
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -76,11 +76,13 @@ export { passwordHash, personOf, personWith, recordPerson, shownPersonFields, ty
|
|
|
76
76
|
// the World's managed Postgres a handler reaches as `ctx.engine` (managed-database.ts), and a mail's route (`ctx.mail`)
|
|
77
77
|
export { EngineDatabaseError, EngineUnavailableError, quoteIdent } from './managed-database.ts';
|
|
78
78
|
export type { Batch as EngineBatch, ManagedDatabase, Statement as EngineStatement, StatementResult as EngineStatementResult } from './managed-database.ts';
|
|
79
|
-
export type { Mail,
|
|
79
|
+
export type { Mail, MailReply } from './smtp.ts';
|
|
80
80
|
// S3's wire (contract "S3's wire is a kernel library too") and the git plane (contract "The git plane is a kernel library")
|
|
81
81
|
export * as s3 from './s3/wire.ts';
|
|
82
82
|
// Generic strict XML grammar; vendor interpretation remains in its consumer.
|
|
83
83
|
export * as xml from './xml.ts';
|
|
84
|
+
// Generic WHATWG HTML grammar; what a pack keeps of received HTML remains the pack's policy.
|
|
85
|
+
export * as html from './html.ts';
|
|
84
86
|
export * as clickhouse from './clickhouse/index.ts';
|
|
85
87
|
export { sampleFromSchema } from './schema-sample.ts';
|
|
86
88
|
export * as openaiWire from './openai-wire.ts';
|
|
@@ -92,6 +94,7 @@ export { placeholderPng } from './placeholder-image.ts';
|
|
|
92
94
|
export { imageDimensions, readImageHeader, readMp4Header } from './media-header.ts';
|
|
93
95
|
export { presignSigV4, signSigV4, verifySigV4, type SigV4Verdict } from './sigv4.ts';
|
|
94
96
|
export * as redis from './redis/index.ts';
|
|
97
|
+
export * as smtpServer from './smtp-server.ts';
|
|
95
98
|
export * as mysql from './mysql/index.ts';
|
|
96
99
|
export * as live from './live/index.ts';
|
|
97
100
|
export * as mongodb from './mongodb/index.ts';
|
package/src/pack-fetch.ts
CHANGED
|
@@ -19,7 +19,9 @@ import { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, runAsVendorMove } from './
|
|
|
19
19
|
import { parseScenarioDocument, ScenarioEngine, ScenarioError, statefulTwinManifest, twinManifest, type PackScenarioAdapter } from './scenario.ts';
|
|
20
20
|
import { getActiveWorldStore } from './world-store.ts';
|
|
21
21
|
import { runWithCorrelationId } from './actions.ts';
|
|
22
|
-
import { deferPerform, performRequestGroup, RefusedWriteError, VendorWriteError } from './head.ts';
|
|
22
|
+
import { deferPerform, deployableEntries, performRequestGroup, RefusedWriteError, VendorWriteError } from './head.ts';
|
|
23
|
+
import { FLUSH, ZERO_SHA, pktLine, readPktLines } from './git/smart-http.ts';
|
|
24
|
+
import { concat } from './git/objects.ts';
|
|
23
25
|
import { withCors } from './cors.ts';
|
|
24
26
|
import type { ApplicationStandIn } from './events.ts';
|
|
25
27
|
import { contentAddressOf } from './content-route.ts';
|
|
@@ -32,7 +34,7 @@ import { expireSocketWaits, socketUpgrade, type SocketEngine } from './sockets.t
|
|
|
32
34
|
import { liveIngest } from './live/ingest.ts';
|
|
33
35
|
import type { LiveEngine } from './live/types.ts';
|
|
34
36
|
import { readResourceBlob, removeResourceBlob, writeResourceBlob } from './resource-blob.ts';
|
|
35
|
-
import { resourcesOfType } from './derived-core.ts';
|
|
37
|
+
import { gitRepositoryOf, resourcesOfType } from './derived-core.ts';
|
|
36
38
|
import { worldNow } from './world-clock.ts';
|
|
37
39
|
import type { WebSocketUpgrade, WebSocketPeer } from './serve-http.ts';
|
|
38
40
|
import { MACHINE_POOL, POOL_KINDS, resetFakePool } from './machines.ts';
|
|
@@ -643,7 +645,38 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
643
645
|
const id = `req_${globalThis.crypto.randomUUID()}`;
|
|
644
646
|
const { value: answer, appended } = await runWithCorrelationId(id, () => deferPerform(id, () => fetch(request)));
|
|
645
647
|
if (!appended) return answer;
|
|
648
|
+
// a receive-pack's pushes (derived-core.ts pushedInput): what the World's refs held before and after each
|
|
649
|
+
const pushes = m.git ? deployableEntries(m.service, options.root).filter((e) => e.correlationId === id && e.input?.git).map((e) => ({ actionId: e.id, ...(e.input!.git as { name: string; ref: string; before: string; after: string }) })) : [];
|
|
646
650
|
const performed = await runWithCorrelationId(id, () => performRequestGroup(m.service, id, options.root));
|
|
651
|
+
// under `auto`, what the root did is what the World keeps (ADR 0031): a ref it refused goes back to its value before
|
|
652
|
+
// the push, by compare-and-set under the ref lock (a later push built on it keeps its value), its push and effects
|
|
653
|
+
// are reverted (head.ts), and the client hears git's own report, `ok` or `ng` for each ref
|
|
654
|
+
if (pushes.length) {
|
|
655
|
+
const whole = performed instanceof RefusedWriteError ? performed.reason : performed instanceof VendorWriteError ? performed.message : undefined;
|
|
656
|
+
const refused = new Map<string, string>();
|
|
657
|
+
for (const p of pushes) {
|
|
658
|
+
const reason = whole ?? (performed as { refused?: Array<{ actionId: string; reason: string }> }).refused?.find((r) => r.actionId === p.actionId)?.reason;
|
|
659
|
+
if (reason !== undefined) refused.set(p.actionId, reason);
|
|
660
|
+
}
|
|
661
|
+
if (refused.size) {
|
|
662
|
+
for (const p of [...pushes].reverse()) {
|
|
663
|
+
if (!refused.has(p.actionId)) continue;
|
|
664
|
+
const { refs } = gitRepositoryOf(m, p.name, options.root);
|
|
665
|
+
refs.withLock(() => {
|
|
666
|
+
if ((refs.get(p.ref) ?? ZERO_SHA) !== p.after) return;
|
|
667
|
+
if (p.before === ZERO_SHA) refs.delete(p.ref); else refs.set(p.ref, p.before);
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
const ng = new Map(pushes.filter((p) => refused.has(p.actionId)).map((p) => [p.ref, refused.get(p.actionId)!.replace(/\s+/g, ' ')]));
|
|
671
|
+
const report = concat(...readPktLines(new Uint8Array(await answer.arrayBuffer())).lines.map((line) => {
|
|
672
|
+
const ref = line.startsWith('ok ') ? line.slice(3).trim() : undefined;
|
|
673
|
+
return pktLine(ref !== undefined && ng.has(ref) ? `ng ${ref} ${ng.get(ref)}\n` : line);
|
|
674
|
+
}), FLUSH);
|
|
675
|
+
const headers = new Headers(answer.headers);
|
|
676
|
+
headers.delete('content-length');
|
|
677
|
+
return new Response(report as unknown as BodyInit, { status: answer.status, headers });
|
|
678
|
+
}
|
|
679
|
+
}
|
|
647
680
|
// the vendor's refusal, as its status and words (perform names them `<status>: <message>`), in the vendor's error body
|
|
648
681
|
if (performed instanceof RefusedWriteError) { const said = /^(\d{3}): ([\s\S]*)$/.exec(performed.reason); return vendorError(m, { status: said ? Number(said[1]) : 400, message: said ? said[2]! : performed.reason }); }
|
|
649
682
|
if (performed instanceof VendorWriteError) return vendorError(m, { status: 502, message: performed.message });
|
package/src/pack-paths.ts
CHANGED
|
@@ -102,20 +102,12 @@ export function installedPackageDir(from: string, name: string): string | undefi
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
/**
|
|
106
|
-
*
|
|
107
|
-
* sibling checkout merely because one happens to be beside them. */
|
|
105
|
+
/** The pack repository a World is rooted in, when it is one (ADR 0034: a platform checkout finds no pack repository
|
|
106
|
+
* beside it; a pack reaches any other World as an installed or linked package). */
|
|
108
107
|
export function packRepository(from: string): string | undefined {
|
|
109
108
|
let dir = resolve(from);
|
|
110
109
|
for (;;) {
|
|
111
110
|
if (packageName(dir) === 'twin-packs-p3') return dir;
|
|
112
|
-
if (packageName(join(dir, 'packages', 'world-core')) === '@volter/world-core') {
|
|
113
|
-
const env = globalThis.process?.env?.VOLTER_PACKS;
|
|
114
|
-
const named = env ? resolve(env) : undefined;
|
|
115
|
-
if (named && packageName(named) === 'twin-packs-p3') return named;
|
|
116
|
-
const sibling = join(dirname(dir), 'twin-packs-p3');
|
|
117
|
-
if (packageName(sibling) === 'twin-packs-p3') return sibling;
|
|
118
|
-
}
|
|
119
111
|
const up = dirname(dir);
|
|
120
112
|
if (up === dir) return undefined;
|
|
121
113
|
dir = up;
|
|
@@ -125,7 +117,7 @@ export function packRepository(from: string): string | undefined {
|
|
|
125
117
|
/** Resolve one vendor's package directory for host-side reads (assets, infrastructure launchers). */
|
|
126
118
|
export function vendorPackageDir(from: string, vendor: string): string | undefined {
|
|
127
119
|
if (!/^[a-z][a-z0-9-]*$/.test(vendor)) return undefined;
|
|
128
|
-
const installed = installedVendorPacks(from).get(vendor);
|
|
120
|
+
const installed = installedVendorPacks(from, { vendors: [vendor] }).get(vendor);
|
|
129
121
|
const repo = packRepository(from);
|
|
130
122
|
const checkout = repo === undefined ? undefined : join(repo, vendor);
|
|
131
123
|
if (!checkout || packageName(checkout) !== `@volter/twin-${vendor}` || !isDerivedPack(checkout)) return installed;
|
package/src/packRegistry.ts
CHANGED
|
@@ -166,13 +166,16 @@ export async function pullOnSchedule<T>(vendor: PullVendor, pull: () => Promise<
|
|
|
166
166
|
* matches and no exclude does. Validated by scripts/pack-facts.ts: keys are `[a-z0-9-]+`, a
|
|
167
167
|
* foreign key is never another pack's vendor id, belongs to one pack, and must appear in the
|
|
168
168
|
* pack's `adoption.worldIds` (the world wires `<KEY>_TWIN_URL` from there); a pack must claim its
|
|
169
|
-
* own vendor id too; no key is exclude-only; `exclude` is `true` or absent.
|
|
169
|
+
* own vendor id too; no key is exclude-only; `exclude` is `true` or absent. `basic` is a vendor fact for the executor: on
|
|
170
|
+
* a URL the rule names, the host takes the sealed token as HTTP Basic's password under this user (a vendor's git over
|
|
171
|
+
* HTTP whose API takes the same token as a bearer; ADR 0031).
|
|
170
172
|
*/
|
|
171
173
|
export type HostRule = ({ host: string } | { suffix: string } | { hostPattern: string }) & {
|
|
172
174
|
pathPattern?: string;
|
|
173
175
|
demandPathPattern?: string | false;
|
|
174
176
|
key?: string;
|
|
175
177
|
exclude?: true;
|
|
178
|
+
basic?: string;
|
|
176
179
|
};
|
|
177
180
|
|
|
178
181
|
export type RoundTripWrite = { method: string; path: string; body?: unknown; headers?: Record<string, string> };
|
|
@@ -234,6 +237,11 @@ export type TwinPack = {
|
|
|
234
237
|
managedService?: { kind: string; scheme: string; note: string; configurationArg?: '--configuration'; originalInputs?: { version: 1; manifestArg: '--original-inputs'; rootArg: '--original-root'; digestArg: '--original-inputs-sha256'; paths: string[] } };
|
|
235
238
|
managedDatabase?: { kind: 'postgres'; arg: string; user: string; database: string; clock: 'wall'; credentialField: string; note: string };
|
|
236
239
|
sidePorts?: Array<{ name: string; option: string; flag: string; path?: string; scheme?: string; note: string }>;
|
|
240
|
+
/** The vendor's own requests a World's generated seed makes for what its world.json declares (ADR 0033 §3), each a URL
|
|
241
|
+
* at the vendor's own host, defined by the twin's own screen form for that act: its sign-up, and its domain screen's
|
|
242
|
+
* add and verify with `record`, the one `/_twin/` stand-in, for a record at the domain's DNS host; `settles` is the
|
|
243
|
+
* World-clock time the vendor takes to settle a verified domain (`10m`). */
|
|
244
|
+
seed?: { signUp?: string; domain?: { add: string; record: string; verify: string; settles: string } };
|
|
237
245
|
stateSystem?: StateSystemAdapters;
|
|
238
246
|
/** The pack has no vendor-backed half, and why (copied from the manifest by packOf). */
|
|
239
247
|
vendorBacked?: { none: string };
|
package/src/runtime.ts
CHANGED
|
@@ -307,6 +307,8 @@ export * as git from './git/index.ts';
|
|
|
307
307
|
export * as s3 from './s3/wire.ts';
|
|
308
308
|
// Generic strict XML grammar; vendor interpretation remains in its consumer.
|
|
309
309
|
export * as xml from './xml.ts';
|
|
310
|
+
// Generic WHATWG HTML grammar; what a pack keeps of received HTML remains the pack's policy.
|
|
311
|
+
export * as html from './html.ts';
|
|
310
312
|
|
|
311
313
|
// The push arm: the kernel's transaction over a pack's one `perform<Name>Action` (contract section of that name).
|
|
312
314
|
|
|
@@ -343,6 +345,7 @@ export { brandTokensResponse } from './brand-tokens.ts';
|
|
|
343
345
|
export { multipartBoundary, multipartParts, multipartEnvelope, inferMultipartBoundary, multipartDelimiterPattern, type MultipartPart, type MultipartFramedPart, type MultipartEnvelope } from './multipart.ts';
|
|
344
346
|
export { corsHeaders, withCors, type CorsDecl } from './cors.ts';
|
|
345
347
|
export { setInProcessTwins, vendorFetch, VendorUnreachableError, type InProcessTwin } from './vendor-call.ts';
|
|
348
|
+
export { setInProcessMailServers } from './smtp.ts';
|
|
346
349
|
export { closeManagedDatabases, databaseSession, EngineDatabaseError, EngineUnavailableError, managedDatabase, quoteIdent, useDatabaseDriver, type DatabaseDriver, type DatabaseSession } from './managed-database.ts';
|
|
347
350
|
export type { Batch as EngineBatch, ManagedDatabase, Statement as EngineStatement, StatementResult as EngineStatementResult } from './managed-database.ts';
|
|
348
351
|
export { readResourceBlob, readResourceBlobRange, removeResourceBlob, resourceBlobFile, resourceBlobKey, resourceBlobSize, resourceChain, writeResourceBlob, writeResourceBlobStream } from './resource-blob.ts';
|
package/src/serve-http.ts
CHANGED
|
@@ -185,17 +185,19 @@ const STREAM_HIGH_WATER = 4 * 1024 * 1024;
|
|
|
185
185
|
/** A raw-TCP twin's listener (the seam's byte-stream half): its `create<Name>TwinStream` served on a TCP port, each
|
|
186
186
|
* socket one connection, on loopback unless the caller names a hostname. Runtime-neutral, as `serveHttp` is: Node's
|
|
187
187
|
* `net` reached at call time, never imported. A stream that declares `maxConnections` (a live ingest) is held to it. */
|
|
188
|
-
export async function serveStream(options: { port?: number; hostname?: string; stream: TwinStream; maxConnections?: number }): Promise<{ port: number; stop: () => void }> {
|
|
188
|
+
export async function serveStream(options: { port?: number; hostname?: string; stream: TwinStream; maxConnections?: number }): Promise<{ port: number; stop: () => Promise<void> }> {
|
|
189
189
|
const get = (process as { getBuiltinModule?: (id: string) => unknown }).getBuiltinModule;
|
|
190
190
|
const net = typeof get === 'function' ? get('node:net') as typeof import('node:net') | undefined : undefined;
|
|
191
191
|
if (!net || typeof net.createServer !== 'function') throw new StreamListenerUnavailable('this runtime has no TCP listener (node:net)');
|
|
192
192
|
const cap = options.maxConnections ?? (options.stream as { maxConnections?: number }).maxConnections;
|
|
193
193
|
let open = 0;
|
|
194
|
+
const accepted = new Set<import('node:net').Socket>();
|
|
194
195
|
const server = net.createServer((socket) => {
|
|
195
196
|
// past its cap a connection is closed before the stream sees it, so connections that never get anywhere cannot pile up
|
|
196
197
|
if (cap !== undefined && open >= cap) { socket.destroy(); return; }
|
|
197
198
|
open += 1;
|
|
198
|
-
|
|
199
|
+
accepted.add(socket);
|
|
200
|
+
socket.once('close', () => { open -= 1; accepted.delete(socket); });
|
|
199
201
|
const conn = options.stream({ write: (bytes) => { socket.write(bytes); }, end: () => { socket.end(); } }, socket.remoteAddress ?? '127.0.0.1');
|
|
200
202
|
// a connection that says what waits (`backlog`: whole messages its work has not reached) is read no further past the
|
|
201
203
|
// high-water mark until that work has settled, so a peer faster than the twin (a live ingest pushed faster than real
|
|
@@ -212,7 +214,13 @@ export async function serveStream(options: { port?: number; hostname?: string; s
|
|
|
212
214
|
});
|
|
213
215
|
await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(options.port ?? 0, options.hostname ?? '127.0.0.1', () => resolve()); });
|
|
214
216
|
const address = server.address();
|
|
215
|
-
|
|
217
|
+
// stop is the listener's real retirement: it settles once the listener is closed and every connection it accepted has
|
|
218
|
+
// ended (each is closed here, so none holds the close open)
|
|
219
|
+
const stop = (): Promise<void> => new Promise<void>((resolve) => {
|
|
220
|
+
server.close(() => resolve());
|
|
221
|
+
for (const socket of accepted) socket.destroy();
|
|
222
|
+
});
|
|
223
|
+
return { port: typeof address === 'object' && address ? address.port : options.port ?? 0, stop };
|
|
216
224
|
}
|
|
217
225
|
function builtin<T>(name: string): T {
|
|
218
226
|
const get = (process as { getBuiltinModule?: (id: string) => unknown }).getBuiltinModule;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// THE KERNEL'S SMTP SERVER MACHINE (`smtpServer`, from `@volter/world-core`): RFC 5321's mechanics every pack that serves an
|
|
2
|
+
// SMTP server shares (a relay's submission server, a mailbox provider's receiving server: ADR 0032 §4), behind the kernel's
|
|
3
|
+
// stream boundary (TwinStream). It frames a connection's bytes into commands, a message's data and an AUTH response,
|
|
4
|
+
// carries them to the pack's front over the pack's own fetch (a private bridge the pack's key signs), and parses the
|
|
5
|
+
// RFC's command arguments. What a server answers, which senders and recipients it takes and what it keeps stay in each
|
|
6
|
+
// pack: its replies, its policy and its writes. The kernel names no vendor. docs/contributing/architecture.md, "Other
|
|
7
|
+
// wires: lanes, headers and line protocols".
|
|
8
|
+
// source: RFC 5321 §4.1.1.4 "In particular, the sequence \"<LF>.<LF>\" (bare line feeds, without carriage returns) MUST NOT be treated as equivalent to <CRLF>.<CRLF> as the end of mail data indication."
|
|
9
|
+
// source: RFC 5321 §4.5.2 "If the first character is a period and there are other characters on the line, the first character is deleted."
|
|
10
|
+
import type { HandlerContext } from './derived-core.ts';
|
|
11
|
+
import type { TwinStream } from './twin-fetch.ts';
|
|
12
|
+
|
|
13
|
+
/** What a server's front answers the stream: its reply lines, the moved session, and whether the stream reads a
|
|
14
|
+
* message's data next (`data`), an AUTH response (`auth`) or commands, or closes. */
|
|
15
|
+
export type SmtpAnswer<S> = { replies: string[]; session: S; mode: 'command' | 'data' | 'auth'; close?: boolean };
|
|
16
|
+
|
|
17
|
+
/** The bridge between a connection and the pack's front: the header that carries the pack's key, the key, a fresh
|
|
18
|
+
* session, and the pack's answer when its front cannot answer (its words for a local error). */
|
|
19
|
+
export type SmtpBridge<S> = { header: string; key: () => string; fresh: () => S; failure: (session: S) => SmtpAnswer<S> };
|
|
20
|
+
|
|
21
|
+
/** A connection's bytes framed as SMTP frames them, each piece posted to the pack's front (`smtpFront`) through its
|
|
22
|
+
* fetch with the session the connection carries: commands are lines ended by CRLF; after a 354, the message's lines up
|
|
23
|
+
* to the lone dot, their leading dot removed; after a 334, one line of AUTH response. Pipelined commands are answered
|
|
24
|
+
* in order. */
|
|
25
|
+
export function smtpStream<S>(fetch: (request: Request) => Response | Promise<Response>, bridge: SmtpBridge<S>): TwinStream {
|
|
26
|
+
return (sink, peer) => {
|
|
27
|
+
const encoder = new TextEncoder();
|
|
28
|
+
const decoder = new TextDecoder();
|
|
29
|
+
let buffer = '';
|
|
30
|
+
let session = bridge.fresh();
|
|
31
|
+
let mode: SmtpAnswer<S>['mode'] = 'command';
|
|
32
|
+
let data: string[] = [];
|
|
33
|
+
let closed = false;
|
|
34
|
+
let chain: Promise<void> = Promise.resolve();
|
|
35
|
+
|
|
36
|
+
const post = async (body: Record<string, unknown>): Promise<void> => {
|
|
37
|
+
if (closed) return;
|
|
38
|
+
let a: SmtpAnswer<S>;
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetch(new Request('http://smtp.wire/', { method: 'POST', headers: { 'content-type': 'application/json', [bridge.header]: bridge.key(), 'x-forwarded-for': peer }, body: JSON.stringify({ session, ...body }) }));
|
|
41
|
+
a = res.ok ? (await res.json()) as SmtpAnswer<S> : bridge.failure(session);
|
|
42
|
+
if (!Array.isArray(a.replies) || !a.session || !['command', 'data', 'auth'].includes(a.mode)) a = bridge.failure(session);
|
|
43
|
+
} catch { a = bridge.failure(session); }
|
|
44
|
+
session = a.session;
|
|
45
|
+
mode = a.mode;
|
|
46
|
+
sink.write(encoder.encode(a.replies.map((r) => `${r}\r\n`).join('')));
|
|
47
|
+
if (a.close) { closed = true; sink.end(); }
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const line = async (text: string): Promise<void> => {
|
|
51
|
+
if (mode === 'data') {
|
|
52
|
+
if (text === '.') { const message = data.join('\r\n') + (data.length ? '\r\n' : ''); data = []; mode = 'command'; await post({ data: message }); return; }
|
|
53
|
+
data.push(text.startsWith('.') ? text.slice(1) : text);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (mode === 'auth') { await post({ response: text }); return; }
|
|
57
|
+
await post({ line: decoder.decode(Uint8Array.from(text, (c) => c.charCodeAt(0))) });
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const pump = (): void => {
|
|
61
|
+
let at: number;
|
|
62
|
+
while ((at = buffer.indexOf('\r\n')) >= 0) {
|
|
63
|
+
const text = buffer.slice(0, at);
|
|
64
|
+
buffer = buffer.slice(at + 2);
|
|
65
|
+
chain = chain.then(() => line(text));
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
chain = chain.then(() => post({ open: true }));
|
|
70
|
+
return {
|
|
71
|
+
data(chunk) { if (closed) return; buffer += Array.from(chunk, (b) => String.fromCharCode(b)).join(''); pump(); },
|
|
72
|
+
settled: () => chain,
|
|
73
|
+
close() { closed = true; },
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** What a pack's front does with each piece the stream carries: the greeting, a command line, a message's data, an AUTH
|
|
79
|
+
* response (a server that takes none leaves it out). */
|
|
80
|
+
type SmtpServer<S> = {
|
|
81
|
+
open(): SmtpAnswer<S> | Promise<SmtpAnswer<S>>;
|
|
82
|
+
command(session: S, line: string): SmtpAnswer<S> | Promise<SmtpAnswer<S>>;
|
|
83
|
+
data(session: S, data: string): SmtpAnswer<S> | Promise<SmtpAnswer<S>>;
|
|
84
|
+
response?(session: S, response: string): SmtpAnswer<S> | Promise<SmtpAnswer<S>>;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** The pack's front (its `around`): a request the stream signed with the pack's key is a piece of a connection, answered
|
|
88
|
+
* by the pack's server; any other request is the pack's own (its doors and screens). */
|
|
89
|
+
export async function smtpFront<S>(ctx: HandlerContext, next: (request?: Request) => Promise<Response>, bridge: SmtpBridge<S>, server: SmtpServer<S>): Promise<Response> {
|
|
90
|
+
const request = ctx.call.request;
|
|
91
|
+
if (!request.headers.has(bridge.header)) return next();
|
|
92
|
+
if (request.headers.get(bridge.header) !== bridge.key()) return new Response('not the server\'s wire', { status: 403 });
|
|
93
|
+
const w = (ctx.body ?? {}) as { session?: S; open?: boolean; line?: string; data?: string; response?: string };
|
|
94
|
+
const s = w.session ?? bridge.fresh();
|
|
95
|
+
if (w.open) return Response.json(await server.open());
|
|
96
|
+
if (w.data !== undefined) return Response.json(await server.data(s, w.data));
|
|
97
|
+
if (w.response !== undefined && server.response) return Response.json(await server.response(s, w.response));
|
|
98
|
+
return Response.json(await server.command(s, String(w.line ?? '')));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A command's verb (upper-cased) and its argument. */
|
|
102
|
+
export function smtpCommand(line: string): { verb: string; arg: string } {
|
|
103
|
+
const [verb = '', ...rest] = line.split(' ');
|
|
104
|
+
return { verb: verb.toUpperCase(), arg: rest.join(' ') };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The session machine's word on a command from where a session stands (the manifest's states): a refusal's reply, or
|
|
108
|
+
* undefined to go on. */
|
|
109
|
+
export function smtpOrder(ctx: Pick<HandlerContext, 'legal'>, machine: string, stage: string, op: string, to?: string): string | undefined {
|
|
110
|
+
const refusal = ctx.legal(machine, 'stage', op, stage, to);
|
|
111
|
+
return refusal ? `${refusal.status} ${String(refusal.code)} ${refusal.message}` : undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** EHLO's multi-line 250 reply: the server's name, then each keyword. */
|
|
115
|
+
// source: RFC 5321 §4.1.1.1 "ehlo-ok-rsp"
|
|
116
|
+
export const smtpEhlo = (keywords: string[]): string[] => keywords.map((k, i) => `250${i === keywords.length - 1 ? ' ' : '-'}${k}`);
|
|
117
|
+
|
|
118
|
+
/** An address in angle brackets (`<a@b>`, `<>` for the null sender), or undefined when it is not one. */
|
|
119
|
+
function smtpAddress(arg: string): string | undefined {
|
|
120
|
+
const m = /^<([^<>\s]*)>$/.exec(arg.trim());
|
|
121
|
+
if (!m) return undefined;
|
|
122
|
+
const a = m[1]!;
|
|
123
|
+
if (a === '') return '';
|
|
124
|
+
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(a) ? a : undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** An address's domain, lower-cased. */
|
|
128
|
+
export const smtpDomain = (address: string): string => address.slice(address.lastIndexOf('@') + 1).toLowerCase();
|
|
129
|
+
|
|
130
|
+
/** MAIL's argument: the sender and its SIZE, BODY and SMTPUTF8 parameters, or why it is refused (the pack words it). */
|
|
131
|
+
// source: RFC 5321 §4.1.1.2 "MAIL FROM:<reverse-path> [SP <mail-parameters> ] <CRLF>"
|
|
132
|
+
export function smtpMailFrom(arg: string): { from: string; size?: number; smtputf8: boolean } | { refused: 'syntax' | 'sender' | 'size' } | { refused: 'parameter'; parameter: string } {
|
|
133
|
+
const m = /^FROM:\s*(<[^>]*>)((?:\s+\S+)*)\s*$/i.exec(arg);
|
|
134
|
+
if (!m) return { refused: 'syntax' };
|
|
135
|
+
const from = smtpAddress(m[1]!);
|
|
136
|
+
if (from === undefined) return { refused: 'sender' };
|
|
137
|
+
let size: number | undefined;
|
|
138
|
+
let smtputf8 = false;
|
|
139
|
+
for (const parameter of m[2]!.trim().split(/\s+/).filter(Boolean)) {
|
|
140
|
+
const [k = '', v] = parameter.split('=');
|
|
141
|
+
if (k.toUpperCase() === 'SIZE') { if (!/^\d+$/.test(v ?? '')) return { refused: 'size' }; size = Number(v); }
|
|
142
|
+
else if (k.toUpperCase() === 'BODY' && ['7BIT', '8BITMIME'].includes((v ?? '').toUpperCase())) continue;
|
|
143
|
+
else if (k.toUpperCase() === 'SMTPUTF8' && v === undefined) smtputf8 = true;
|
|
144
|
+
else return { refused: 'parameter', parameter };
|
|
145
|
+
}
|
|
146
|
+
return { from, ...(size !== undefined ? { size } : {}), smtputf8 };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** RCPT's argument: the recipient, or why it is refused (the pack words it). */
|
|
150
|
+
// source: RFC 5321 §4.1.1.3 "RCPT TO:<forward-path> [ SP <rcpt-parameters> ] <CRLF>"
|
|
151
|
+
export function smtpRcptTo(arg: string): { to: string } | { refused: 'syntax' | 'recipient' } | { refused: 'parameter'; parameter: string } {
|
|
152
|
+
const m = /^TO:\s*(<[^>]*>)(?:\s+\S+)*\s*$/i.exec(arg);
|
|
153
|
+
if (!m) return { refused: 'syntax' };
|
|
154
|
+
// source: RFC 5321 §4.1.1.11 "command, it will return code 555."
|
|
155
|
+
if (/^TO:\s*<[^>]*>\s+\S/i.test(arg)) return { refused: 'parameter', parameter: arg.slice(arg.indexOf('>') + 1).trim() };
|
|
156
|
+
const to = smtpAddress(m[1]!);
|
|
157
|
+
return to ? { to } : { refused: 'recipient' };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A transaction's state cleared, the greeting kept (RSET, a new EHLO, the end of DATA): the session's `stage`,
|
|
161
|
+
* `greeted` and an empty `rcpt`, with whatever of the session the pack keeps across transactions (`kept`). */
|
|
162
|
+
// source: RFC 5321 §4.1.1.5 "This command specifies that the current mail transaction will be aborted."
|
|
163
|
+
export function smtpCleared<S extends { stage: string; rcpt: string[]; greeted?: 'helo' | 'ehlo' }>(s: S, stage: string, kept: Omit<S, 'stage' | 'rcpt' | 'greeted' | 'from'>): Omit<S, 'stage' | 'rcpt' | 'greeted' | 'from'> & { stage: string; rcpt: string[]; greeted?: 'helo' | 'ehlo' } {
|
|
164
|
+
const greeted = stage === 'helo' || stage === 'ehlo' ? stage : s.greeted;
|
|
165
|
+
return { ...kept, stage, rcpt: [], ...(greeted ? { greeted } : {}) };
|
|
166
|
+
}
|