@volter/world-core 3.0.164 → 3.0.166

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.
Files changed (60) hide show
  1. package/attach.cjs +7 -3
  2. package/dist/attach.cjs +7 -3
  3. package/dist/generated/pack-facts.json +44 -3660
  4. package/dist/pack-facts.cjs +4 -4
  5. package/dist/src/browser.d.ts +4 -0
  6. package/dist/src/browser.js +10 -0
  7. package/dist/src/checkout.d.ts +4 -0
  8. package/dist/src/checkout.js +13 -0
  9. package/dist/src/derived-core.d.ts +45 -5
  10. package/dist/src/derived-core.js +53 -8
  11. package/dist/src/derived-real.js +144 -2
  12. package/dist/src/derived.d.ts +1 -1
  13. package/dist/src/executor.d.ts +2 -2
  14. package/dist/src/executor.js +13 -0
  15. package/dist/src/git/index.d.ts +1 -0
  16. package/dist/src/git/index.js +1 -0
  17. package/dist/src/git/smart-http-client.d.ts +28 -0
  18. package/dist/src/git/smart-http-client.js +95 -0
  19. package/dist/src/git/smart-http.d.ts +3 -0
  20. package/dist/src/git/smart-http.js +9 -0
  21. package/dist/src/head.d.ts +16 -1
  22. package/dist/src/head.js +52 -7
  23. package/dist/src/html.d.ts +29 -0
  24. package/dist/src/html.js +64 -0
  25. package/dist/src/index.d.ts +3 -1
  26. package/dist/src/index.js +3 -0
  27. package/dist/src/pack-fetch.js +41 -2
  28. package/dist/src/pack-paths.d.ts +2 -3
  29. package/dist/src/pack-paths.js +3 -13
  30. package/dist/src/packRegistry.d.ts +17 -1
  31. package/dist/src/runtime.d.ts +2 -0
  32. package/dist/src/runtime.js +3 -0
  33. package/dist/src/serve-http.d.ts +1 -1
  34. package/dist/src/serve-http.js +11 -2
  35. package/dist/src/smtp-server.d.ts +78 -0
  36. package/dist/src/smtp-server.js +160 -0
  37. package/dist/src/smtp.d.ts +29 -11
  38. package/dist/src/smtp.js +221 -52
  39. package/generated/pack-facts.json +44 -3660
  40. package/pack-facts.cjs +4 -4
  41. package/package.json +8 -2
  42. package/src/browser.ts +10 -0
  43. package/src/checkout.ts +12 -0
  44. package/src/derived-core.ts +68 -12
  45. package/src/derived-real.ts +121 -2
  46. package/src/derived.ts +2 -2
  47. package/src/executor.ts +13 -2
  48. package/src/git/index.ts +1 -0
  49. package/src/git/smart-http-client.ts +99 -0
  50. package/src/git/smart-http.ts +7 -0
  51. package/src/head.ts +37 -6
  52. package/src/html.ts +72 -0
  53. package/src/index.ts +4 -1
  54. package/src/pack-fetch.ts +35 -2
  55. package/src/pack-paths.ts +3 -11
  56. package/src/packRegistry.ts +9 -1
  57. package/src/runtime.ts +3 -0
  58. package/src/serve-http.ts +11 -3
  59. package/src/smtp-server.ts +166 -0
  60. 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
- return typeof e.input?.operationId === 'string' || (e.input?.graphql !== undefined && e.input.graphql !== null);
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
- for (const e of [...group].reverse()) revertAction({ service, actionId: e.id, occurredAt: worldNow(), ...(root !== undefined ? { root } : {}) });
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
- for (const e of [...group].reverse()) revertAction({ service, actionId: e.id, occurredAt: worldNow(), ...(root !== undefined ? { root } : {}) });
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, SmtpRoute } from './smtp.ts';
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
- /** A pack repository itself, or a platform checkout's P3 repository: the one `VOLTER_PACKS` names (a worktree's own
106
- * pinned packs, as the repository's scripts read it), else the one beside it. Applications do not acquire a
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;
@@ -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
- socket.once('close', () => { open -= 1; });
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
- return { port: typeof address === 'object' && address ? address.port : options.port ?? 0, stop: () => { server.close(); } };
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
+ }