@amalgm/core 0.1.0

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 (87) hide show
  1. package/PURPOSE.md +94 -0
  2. package/README.md +30 -0
  3. package/dist/adapters/index.d.ts +13 -0
  4. package/dist/adapters/index.d.ts.map +1 -0
  5. package/dist/adapters/index.js +11 -0
  6. package/dist/adapters/index.js.map +1 -0
  7. package/dist/identity/auth-records.d.ts +147 -0
  8. package/dist/identity/auth-records.d.ts.map +1 -0
  9. package/dist/identity/auth-records.js +354 -0
  10. package/dist/identity/auth-records.js.map +1 -0
  11. package/dist/identity/ids.d.ts +57 -0
  12. package/dist/identity/ids.d.ts.map +1 -0
  13. package/dist/identity/ids.js +80 -0
  14. package/dist/identity/ids.js.map +1 -0
  15. package/dist/identity/index.d.ts +14 -0
  16. package/dist/identity/index.d.ts.map +1 -0
  17. package/dist/identity/index.js +14 -0
  18. package/dist/identity/index.js.map +1 -0
  19. package/dist/identity/labels.d.ts +115 -0
  20. package/dist/identity/labels.d.ts.map +1 -0
  21. package/dist/identity/labels.js +154 -0
  22. package/dist/identity/labels.js.map +1 -0
  23. package/dist/identity/layout.d.ts +188 -0
  24. package/dist/identity/layout.d.ts.map +1 -0
  25. package/dist/identity/layout.js +233 -0
  26. package/dist/identity/layout.js.map +1 -0
  27. package/dist/identity/ports.d.ts +31 -0
  28. package/dist/identity/ports.d.ts.map +1 -0
  29. package/dist/identity/ports.js +9 -0
  30. package/dist/identity/ports.js.map +1 -0
  31. package/dist/identity/product-state.d.ts +49 -0
  32. package/dist/identity/product-state.d.ts.map +1 -0
  33. package/dist/identity/product-state.js +55 -0
  34. package/dist/identity/product-state.js.map +1 -0
  35. package/dist/index.d.ts +19 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +19 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/supervision/app-health.d.ts +148 -0
  40. package/dist/supervision/app-health.d.ts.map +1 -0
  41. package/dist/supervision/app-health.js +165 -0
  42. package/dist/supervision/app-health.js.map +1 -0
  43. package/dist/supervision/app-restart.d.ts +105 -0
  44. package/dist/supervision/app-restart.d.ts.map +1 -0
  45. package/dist/supervision/app-restart.js +97 -0
  46. package/dist/supervision/app-restart.js.map +1 -0
  47. package/dist/supervision/health.d.ts +218 -0
  48. package/dist/supervision/health.d.ts.map +1 -0
  49. package/dist/supervision/health.js +227 -0
  50. package/dist/supervision/health.js.map +1 -0
  51. package/dist/supervision/index.d.ts +17 -0
  52. package/dist/supervision/index.d.ts.map +1 -0
  53. package/dist/supervision/index.js +17 -0
  54. package/dist/supervision/index.js.map +1 -0
  55. package/dist/supervision/service-registration.d.ts +343 -0
  56. package/dist/supervision/service-registration.d.ts.map +1 -0
  57. package/dist/supervision/service-registration.js +543 -0
  58. package/dist/supervision/service-registration.js.map +1 -0
  59. package/dist/supervision/update.d.ts +231 -0
  60. package/dist/supervision/update.d.ts.map +1 -0
  61. package/dist/supervision/update.js +336 -0
  62. package/dist/supervision/update.js.map +1 -0
  63. package/dist/transport/admission.d.ts +235 -0
  64. package/dist/transport/admission.d.ts.map +1 -0
  65. package/dist/transport/admission.js +313 -0
  66. package/dist/transport/admission.js.map +1 -0
  67. package/dist/transport/auth.d.ts +232 -0
  68. package/dist/transport/auth.d.ts.map +1 -0
  69. package/dist/transport/auth.js +266 -0
  70. package/dist/transport/auth.js.map +1 -0
  71. package/dist/transport/index.d.ts +21 -0
  72. package/dist/transport/index.d.ts.map +1 -0
  73. package/dist/transport/index.js +21 -0
  74. package/dist/transport/index.js.map +1 -0
  75. package/dist/transport/ingress.d.ts +94 -0
  76. package/dist/transport/ingress.d.ts.map +1 -0
  77. package/dist/transport/ingress.js +171 -0
  78. package/dist/transport/ingress.js.map +1 -0
  79. package/dist/transport/routing.d.ts +235 -0
  80. package/dist/transport/routing.d.ts.map +1 -0
  81. package/dist/transport/routing.js +288 -0
  82. package/dist/transport/routing.js.map +1 -0
  83. package/dist/transport/shipped-routes.d.ts +21 -0
  84. package/dist/transport/shipped-routes.d.ts.map +1 -0
  85. package/dist/transport/shipped-routes.js +53 -0
  86. package/dist/transport/shipped-routes.js.map +1 -0
  87. package/package.json +51 -0
package/PURPOSE.md ADDED
@@ -0,0 +1,94 @@
1
+ # @amalgm/core
2
+
3
+ ## Purpose
4
+
5
+ `@amalgm/core` is the Amalgm kernel: the smallest set of contracts and
6
+ decision logic a machine needs to **boot** the platform, **prove who it is**,
7
+ **stay alive**, and **route traffic to everything else**. Every product —
8
+ live, chat, automations, apps, agents, toolbox, channels — rides on core;
9
+ core depends on none of them.
10
+
11
+ The membership test for this repository is one sentence: **if it is needed to
12
+ boot, supervise, or route to an app, and it cannot itself be an app, it is
13
+ core. Everything else is not.**
14
+
15
+ Core owns exactly four things:
16
+
17
+ 1. **Identity** — who this machine is, for which user, on which branch.
18
+ The `~/.amalgm/<user>/<branch>/` layout, machine/instance identity,
19
+ auth-store contracts, and the branch-binding rule surfaces use to decide
20
+ whether they may attach.
21
+ 2. **Transport** — the pipe. The local gateway's routing and admission
22
+ rules, the tunnel connection contract, and the event rail. The pipe
23
+ carries payloads it never interprets: sync semantics belong to
24
+ `@amalgm/live`, chat semantics to `@amalgm/chat`. (SIM card, not
25
+ FaceTime.)
26
+ 3. **Supervision** — the cascade that keeps everything running: launchd →
27
+ CLI parent → runtime → app supervisor, health contracts, restart policy,
28
+ and the updater.
29
+ 4. **Host capabilities** — the narrow, named powers a host grants
30
+ (spawn a service, write a plist, open a port), expressed as adapter
31
+ ports, never reached for directly.
32
+
33
+ `docs/responsibilities.md` enumerates every responsibility in full and maps
34
+ each engine module to its target repo. This repository is where the kernel's
35
+ rules become executable and testable; the engine repo remains the production
36
+ source until each module is cut over.
37
+
38
+ ## Axioms
39
+
40
+ 1. **Core is the floor, not the building.** Nothing product-shaped lives
41
+ here — no chat, no documents, no automations, no tools. A feature that
42
+ could be an app is a bug in core.
43
+ 2. **Identity is one story.** User, machine, branch — resolved once, by one
44
+ rule set, and every path, socket, and token scope derives from it. A
45
+ second place that computes a home directory or a branch match is a bug.
46
+ 3. **The pipe never reads the mail.** Transport moves opaque payloads
47
+ between named endpoints. A routing rule that inspects payload semantics
48
+ belongs to the product that owns the payload.
49
+ 4. **One routing table.** Gateway, tunnel, and runtime consult one
50
+ definition of prefixes, ports, and admission. The engine's three drifted
51
+ copies are the wound this axiom closes.
52
+ 5. **Supervision flows one way.** launchd supervises the parent, the parent
53
+ supervises the runtime, the runtime supervises apps. A child never
54
+ restarts its parent, and each layer reports its children's health
55
+ honestly — `degraded` and `unresponsive` are states, not failures to
56
+ hide.
57
+ 6. **A host supplies its own effects.** The SDK receives process,
58
+ filesystem, clock, network, and crypto adapters. Nothing in `src/` may
59
+ import `node:fs`, `node:net`, `node:child_process`, or read an
60
+ environment variable.
61
+ 7. **Protocol evolution is additive only.** Every peer treats missing
62
+ fields and unknown frames as "the other end is older" and keeps the
63
+ legacy behavior. A change that breaks a deployed peer is rejected in
64
+ review.
65
+ 8. **Contracts are byte-exact.** Path formats, plist labels, health
66
+ vocabularies, error strings, and version-compare rules are preserved
67
+ verbatim from the shipped engine; parity tests pin them until cutover.
68
+ 9. **The current runtime remains the single production source until moved.**
69
+ This package extracts contracts and decision logic with their tests; it
70
+ does not create a parallel execution path. The engine adopts it module by
71
+ module, deleting its duplicated copy in the same change.
72
+ 10. **Amalgm's own surfaces use this SDK.** A private shortcut in the CLI,
73
+ gateway, or runtime means the SDK is incomplete — fix the SDK, not the
74
+ caller.
75
+
76
+ ## Five surfaces, one behavior
77
+
78
+ ```
79
+ src/ the SDK — the behavior. Contracts + decision cores + ports.
80
+ surfaces/api REST router factory a host mounts (identity/health/routing).
81
+ surfaces/cli `amalgm-core` — resolve layouts, explain routes, check health.
82
+ surfaces/mcp MCP server exposing the same actions to agents.
83
+ skills/core the skill: teaches an agent the kernel via the CLI/MCP.
84
+ ```
85
+
86
+ Every surface is a thin adapter over `src/`. A surface that implements
87
+ behavior instead of delegating to the SDK is a bug.
88
+
89
+ ## Status
90
+
91
+ Charter stage. Inventory of the engine's kernel territory is in
92
+ `docs/responsibilities.md`; extraction proceeds pure-cores-first, exactly as
93
+ `@amalgm/live` was built. The engine runtime and CLI remain the production
94
+ source; nothing consumes this package yet.
package/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # @amalgm/core
2
+
3
+ The Amalgm kernel SDK: identity, transport, and supervision contracts —
4
+ every law a machine needs to boot the platform, prove who it is, stay
5
+ alive, and route traffic to everything else. Start with
6
+ [PURPOSE.md](PURPOSE.md) (the charter) and
7
+ [docs/responsibilities.md](docs/responsibilities.md) (every duty, and the
8
+ knife table mapping the engine monolith onto its target repos).
9
+
10
+ ```
11
+ src/identity/ labels, layout grammar, ids, auth records
12
+ src/transport/ the auth boundary, ONE routing table, admission, ingress
13
+ src/supervision/ health contracts, restart machines, updater laws,
14
+ service registration
15
+ src/adapters/ the ports a host injects (clock, hash, random, hmac…)
16
+ surfaces/ cli · api · mcp — thin doors over src/
17
+ skills/core/ the skill that teaches an agent this kernel
18
+ ```
19
+
20
+ ```bash
21
+ npm install
22
+ npm test # build + every exam
23
+ node surfaces/cli/amalgm-core.mjs label canary
24
+ ```
25
+
26
+ Rules of the house: zero runtime dependencies; nothing in `src/` imports
27
+ `node:*` or reads `process.env`; contracts are byte-exact against the
28
+ shipped engine (the production source until cutover — PURPOSE.md axiom 9);
29
+ tests are exams — each states a law, and a mutant must be convicted by at
30
+ least one.
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The adapter ports — every real-world effect the kernel SDK needs,
3
+ * gathered in one place so a host can see the full contract it must
4
+ * satisfy (PURPOSE.md axiom 6: a host supplies its own effects).
5
+ *
6
+ * Nothing in `src/` reaches for `node:*` or `process.env`; these types are
7
+ * the entire boundary. A Node host wires them from `node:crypto`,
8
+ * `node:path`, and `Date`; a test wires them from fixtures.
9
+ */
10
+ export type { ClockIsoPort, ClockMsPort, RandomHexPort, Sha256HexPort, ResolvePathPort, } from '../identity/ports.js';
11
+ export type { TimingSafeEqualPort } from '../transport/auth.js';
12
+ export type { HmacHexPort } from '../transport/ingress.js';
13
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/adapters/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,YAAY,EACV,YAAY,EACZ,WAAW,EACX,aAAa,EACb,aAAa,EACb,eAAe,GAChB,MAAM,sBAAsB,CAAC;AAC9B,YAAY,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAChE,YAAY,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC"}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The adapter ports — every real-world effect the kernel SDK needs,
3
+ * gathered in one place so a host can see the full contract it must
4
+ * satisfy (PURPOSE.md axiom 6: a host supplies its own effects).
5
+ *
6
+ * Nothing in `src/` reaches for `node:*` or `process.env`; these types are
7
+ * the entire boundary. A Node host wires them from `node:crypto`,
8
+ * `node:path`, and `Date`; a test wires them from fixtures.
9
+ */
10
+ export {};
11
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/adapters/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG"}
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Credential-record shaping laws (engine packages/amalgm/lib/auth-store.js
3
+ * and runtime/scripts/proxy-token-store.js) — how a computer record splits
4
+ * into its PUBLIC face (`computer.json`) and its SECRET envelope
5
+ * (`auth.json`), how the two merge back, and when a proxy token counts as
6
+ * fresh. Pure record computations only: reading, writing, and env access
7
+ * stay host-side; the clock arrives as a port.
8
+ *
9
+ * Records are loose JSON from disk, so inputs are typed as they truly are:
10
+ * dictionaries of unknowns, validated key by key.
11
+ */
12
+ import type { ClockIsoPort, ClockMsPort } from './ports.js';
13
+ type Dict = Record<string, unknown>;
14
+ /** Emails compare lowercased; a record's email is stored lowercased. */
15
+ export declare function normalizeUserEmail(value: unknown): string;
16
+ /** A record's email under any of its historical spellings (auth-store.js:74-76). */
17
+ export declare function userEmailFromRecord(record: unknown): string;
18
+ /**
19
+ * Every key that must NEVER appear in the public `computer.json`
20
+ * (auth-store.js:24-35). Secrets live only in `auth.json`; the exact list —
21
+ * including the legacy camelCase and hmac spellings — is the contract.
22
+ */
23
+ export declare const SECRET_COMPUTER_KEYS: ReadonlySet<string>;
24
+ /** Does this record carry any secret worth an auth envelope?
25
+ * (auth-store.js:78-81). Whitespace-only values do not count. */
26
+ export declare function hasComputerSecrets(record: unknown): boolean;
27
+ /**
28
+ * The public face of a computer record (auth-store.js:83-92): the record
29
+ * with every secret key removed, its email normalized, and an `auth_file`
30
+ * pointer naming the sibling envelope by BASENAME — the record must stay
31
+ * true when the directory moves. DELIBERATE DEVIATION: the engine's
32
+ * parameter is a full path passed through `node:path.basename`; the same
33
+ * basename law is implemented here inline to stay dependency-free.
34
+ */
35
+ export declare function publicComputerRecord(record: unknown, authFile?: string): Dict;
36
+ /**
37
+ * The full secret envelope distilled from a computer record
38
+ * (auth-store.js:94-156):
39
+ *
40
+ * { schema_version: 1, computer_id, device_id, user_id, user_email,
41
+ * app_url, tunnel: { token, url, chat_tunnel_url }, machine: { token },
42
+ * proxy: { token, expires_in, expires_at, refreshed_at } }
43
+ *
44
+ * Merging over `existing` is additive: identity keys are copied only when
45
+ * the record actually carries them, each token section is (re)stamped only
46
+ * when its token arrives, and a record carrying only tunnel URLs updates
47
+ * the URLs while PRESERVING the existing tunnel token. `expires_in`
48
+ * collapses to undefined (dropped by JSON) when absent or zero. Legacy
49
+ * proxy-token spellings (proxyToken, hmac_token, hmacToken) are honored on
50
+ * input; the envelope speaks only the canonical shape.
51
+ */
52
+ export declare function authFromRecord(record: unknown, existing: unknown, ports: ClockIsoPort): Dict;
53
+ /**
54
+ * Rejoin a public record with its secret envelope (auth-store.js:158-189).
55
+ * THE REFUSAL: when both sides name a computer_id and they disagree, the
56
+ * envelope is IGNORED wholesale — credentials from one machine identity
57
+ * must never dress up a record claiming another. On agreement the envelope
58
+ * only fills gaps; a value the record already carries always wins. Absent
59
+ * envelope values fill gaps as `''` — exactly the engine's bytes on disk.
60
+ */
61
+ export declare function mergeRecordAuth(record: unknown, auth: unknown): Dict | null;
62
+ /**
63
+ * May this stored record serve the runtime labeled `currentLabel`?
64
+ * (auth-store.js:191-199). The record's claim is read with key precedence
65
+ * runtime_label > label > branch through the NON-coercing normalizer, so a
66
+ * record with no recognizable label — one that predates labels — belongs
67
+ * to `main` and only main may claim it. Pure over the injected current
68
+ * label instead of module state.
69
+ */
70
+ export declare function recordMatchesCurrentRuntime(record: unknown, currentLabel: string): boolean;
71
+ /**
72
+ * The scope guard (auth-store.js:201-209): credentials for user A must
73
+ * never be saved into user B's scope. Enforced only when both sides are
74
+ * known and the scope is a real user — the `local` scope predates
75
+ * enrollment and accepts anyone. The refusal string is the ratified text.
76
+ */
77
+ export declare function assertRecordMatchesUserScope(record: unknown, context: {
78
+ readonly runtimeLabel: string;
79
+ readonly userScope: string;
80
+ }): void;
81
+ /** Refresh proxy tokens 10 minutes before they die (auth-store.js:22,
82
+ * proxy-token-store.js:20) — one shared buffer, so the two freshness laws
83
+ * below can never disagree about "soon". */
84
+ export declare const PROXY_REFRESH_BUFFER_MS: number;
85
+ /**
86
+ * A record's proxy token under every spelling (auth-store.js:298-306), with
87
+ * an explicitly supplied environment override winning. Pure: the engine
88
+ * reads `process.env.AMALGM_PROXY_TOKEN`; the host passes that value in.
89
+ */
90
+ export declare function proxyTokenFromRecord(record: unknown, envProxyToken?: string): string;
91
+ /**
92
+ * The RECORD-side freshness law (auth-store.js:308-313), asked of a flat
93
+ * computer record: no token at all means refresh (true); a token whose
94
+ * expiry is missing or unparseable is trusted (false) — a legacy token
95
+ * without metadata keeps working; otherwise refresh inside the buffer.
96
+ * Note the deliberate asymmetry with {@link proxyTokenExpiresSoon}: over
97
+ * there, missing metadata means refresh — the envelope is expected to
98
+ * carry it.
99
+ */
100
+ export declare function proxyTokenNeedsRefresh(record: unknown, ports: ClockMsPort, envProxyToken?: string): boolean;
101
+ /**
102
+ * The ENVELOPE-side freshness law (proxy-token-store.js:110-117), asked of
103
+ * an auth envelope, three branches exactly: no token → false (nothing to
104
+ * refresh ahead of); token with missing OR unparseable expiry → true (an
105
+ * envelope is expected to know its expiry; not knowing is stale); else
106
+ * refresh inside the buffer.
107
+ */
108
+ export declare function proxyTokenExpiresSoon(auth: unknown, ports: ClockMsPort): boolean;
109
+ /** Everything a proxy-token refresh call needs, distilled from the pair. */
110
+ export interface ProxyRefreshContext {
111
+ readonly machineToken: string;
112
+ readonly appUrl: string;
113
+ readonly computerId: string;
114
+ readonly deviceId: string;
115
+ }
116
+ /**
117
+ * The refresh-credential precedence chain (proxy-token-store.js:88-98):
118
+ * the machine token is the machine's own auth token first, the legacy flat
119
+ * spelling second, and — last resort — the tunnel token; addresses and ids
120
+ * prefer the record over the envelope.
121
+ */
122
+ export declare function proxyRefreshContext(record: unknown, auth: unknown): ProxyRefreshContext;
123
+ /**
124
+ * May a refresh even be attempted? (proxy-token-store.js:100-103): a
125
+ * machine token, an app URL, and a computer id are required; the device id
126
+ * is optional context.
127
+ */
128
+ export declare function canRefreshProxyToken(context: {
129
+ readonly record: unknown;
130
+ readonly auth: unknown;
131
+ }): boolean;
132
+ /**
133
+ * The persisted `auth.json` after a successful proxy-token refresh
134
+ * (proxy-token-store.js:154-173): the prior envelope carried forward,
135
+ * identity restated from the refresh context, the server's answers winning
136
+ * over prior values field by field, and both `proxy.refreshed_at` and
137
+ * `updated_at` stamped now. `expires_in` collapses to undefined (dropped
138
+ * by JSON) when absent or zero. The caller guards with
139
+ * {@link canRefreshProxyToken}; this is only the shape law.
140
+ */
141
+ export declare function refreshedProxyAuth(input: {
142
+ readonly record: unknown;
143
+ readonly auth: unknown;
144
+ readonly refreshed: unknown;
145
+ }, ports: ClockIsoPort): Dict;
146
+ export {};
147
+ //# sourceMappingURL=auth-records.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth-records.d.ts","sourceRoot":"","sources":["../../src/identity/auth-records.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE5D,KAAK,IAAI,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AASpC,wEAAwE;AACxE,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAEzD;AAED,oFAAoF;AACpF,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,CAG3D;AAED;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,EAAE,WAAW,CAAC,MAAM,CAWnD,CAAC;AAEH;iEACiE;AACjE,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,OAAO,GAAG,OAAO,CAI3D;AAUD;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,SAAc,GAAG,IAAI,CASlF;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,OAAO,EACf,QAAQ,EAAE,OAAO,EACjB,KAAK,EAAE,YAAY,GAClB,IAAI,CA+DN;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,GAAG,IAAI,GAAG,IAAI,CAoC3E;AAED;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAK1F;AAED;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAC1C,MAAM,EAAE,OAAO,EACf,OAAO,EAAE;IAAE,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GACrE,IAAI,CAQN;AAED;;4CAE4C;AAC5C,eAAO,MAAM,uBAAuB,QAAiB,CAAC;AAEtD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,OAAO,EAAE,aAAa,SAAK,GAAG,MAAM,CAShF;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,OAAO,EACf,KAAK,EAAE,WAAW,EAClB,aAAa,SAAK,GACjB,OAAO,CAMT;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAQhF;AAED,4EAA4E;AAC5E,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,GAAG,mBAAmB,CAcvF;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE;IAAE,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAG3G;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE;IAAE,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;CAAE,EACxF,KAAK,EAAE,YAAY,GAClB,IAAI,CA0BN"}
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Credential-record shaping laws (engine packages/amalgm/lib/auth-store.js
3
+ * and runtime/scripts/proxy-token-store.js) — how a computer record splits
4
+ * into its PUBLIC face (`computer.json`) and its SECRET envelope
5
+ * (`auth.json`), how the two merge back, and when a proxy token counts as
6
+ * fresh. Pure record computations only: reading, writing, and env access
7
+ * stay host-side; the clock arrives as a port.
8
+ *
9
+ * Records are loose JSON from disk, so inputs are typed as they truly are:
10
+ * dictionaries of unknowns, validated key by key.
11
+ */
12
+ import { normalizeRuntimeLabel } from './labels.js';
13
+ const asDict = (value) => (value && typeof value === 'object' ? value : null);
14
+ function cleanString(value) {
15
+ return typeof value === 'string' && value.trim() ? value.trim() : '';
16
+ }
17
+ /** Emails compare lowercased; a record's email is stored lowercased. */
18
+ export function normalizeUserEmail(value) {
19
+ return cleanString(value).toLowerCase();
20
+ }
21
+ /** A record's email under any of its historical spellings (auth-store.js:74-76). */
22
+ export function userEmailFromRecord(record) {
23
+ const r = asDict(record);
24
+ return normalizeUserEmail(r?.['user_email'] || r?.['userEmail'] || r?.['email']);
25
+ }
26
+ /**
27
+ * Every key that must NEVER appear in the public `computer.json`
28
+ * (auth-store.js:24-35). Secrets live only in `auth.json`; the exact list —
29
+ * including the legacy camelCase and hmac spellings — is the contract.
30
+ */
31
+ export const SECRET_COMPUTER_KEYS = new Set([
32
+ 'tunnel_token',
33
+ 'computer_auth_token',
34
+ 'proxy_token',
35
+ 'proxy_token_expires_in',
36
+ 'proxy_token_expires_at',
37
+ 'proxy_token_refreshed_at',
38
+ 'proxyToken',
39
+ 'proxyTokenExpiresAt',
40
+ 'hmac_token',
41
+ 'hmacToken',
42
+ ]);
43
+ /** Does this record carry any secret worth an auth envelope?
44
+ * (auth-store.js:78-81). Whitespace-only values do not count. */
45
+ export function hasComputerSecrets(record) {
46
+ const r = asDict(record);
47
+ if (!r)
48
+ return false;
49
+ return Array.from(SECRET_COMPUTER_KEYS).some((key) => cleanString(r[key]));
50
+ }
51
+ /** The engine's `path.basename` over both separators, with trailing
52
+ * separators stripped — only the file's own name is published. */
53
+ const baseName = (file) => {
54
+ const trimmed = file.replace(/[\\/]+$/, '');
55
+ const cut = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
56
+ return cut === -1 ? trimmed : trimmed.slice(cut + 1);
57
+ };
58
+ /**
59
+ * The public face of a computer record (auth-store.js:83-92): the record
60
+ * with every secret key removed, its email normalized, and an `auth_file`
61
+ * pointer naming the sibling envelope by BASENAME — the record must stay
62
+ * true when the directory moves. DELIBERATE DEVIATION: the engine's
63
+ * parameter is a full path passed through `node:path.basename`; the same
64
+ * basename law is implemented here inline to stay dependency-free.
65
+ */
66
+ export function publicComputerRecord(record, authFile = 'auth.json') {
67
+ const out = {
68
+ ...(asDict(record) || {}),
69
+ auth_file: baseName(authFile),
70
+ };
71
+ const email = userEmailFromRecord(out);
72
+ if (email)
73
+ out['user_email'] = email;
74
+ for (const key of SECRET_COMPUTER_KEYS)
75
+ delete out[key];
76
+ return out;
77
+ }
78
+ /**
79
+ * The full secret envelope distilled from a computer record
80
+ * (auth-store.js:94-156):
81
+ *
82
+ * { schema_version: 1, computer_id, device_id, user_id, user_email,
83
+ * app_url, tunnel: { token, url, chat_tunnel_url }, machine: { token },
84
+ * proxy: { token, expires_in, expires_at, refreshed_at } }
85
+ *
86
+ * Merging over `existing` is additive: identity keys are copied only when
87
+ * the record actually carries them, each token section is (re)stamped only
88
+ * when its token arrives, and a record carrying only tunnel URLs updates
89
+ * the URLs while PRESERVING the existing tunnel token. `expires_in`
90
+ * collapses to undefined (dropped by JSON) when absent or zero. Legacy
91
+ * proxy-token spellings (proxyToken, hmac_token, hmacToken) are honored on
92
+ * input; the envelope speaks only the canonical shape.
93
+ */
94
+ export function authFromRecord(record, existing, ports) {
95
+ const now = ports.nowIso();
96
+ const r = asDict(record);
97
+ const auth = {
98
+ schema_version: 1,
99
+ ...(asDict(existing) || {}),
100
+ updated_at: now,
101
+ };
102
+ for (const key of ['computer_id', 'device_id', 'user_id', 'user_email', 'app_url']) {
103
+ const value = key === 'user_email' ? userEmailFromRecord(r) : cleanString(r?.[key]);
104
+ if (value)
105
+ auth[key] = value;
106
+ }
107
+ const priorTunnel = asDict(auth['tunnel']);
108
+ const tunnelToken = cleanString(r?.['tunnel_token']);
109
+ if (tunnelToken) {
110
+ auth['tunnel'] = {
111
+ ...(priorTunnel || {}),
112
+ token: tunnelToken,
113
+ url: cleanString(r?.['tunnel_url']) || cleanString(priorTunnel?.['url']),
114
+ chat_tunnel_url: cleanString(r?.['chat_tunnel_url']) || cleanString(priorTunnel?.['chat_tunnel_url']),
115
+ updated_at: now,
116
+ };
117
+ }
118
+ else if (r?.['tunnel_url'] || r?.['chat_tunnel_url']) {
119
+ auth['tunnel'] = {
120
+ ...(priorTunnel || {}),
121
+ url: cleanString(r?.['tunnel_url']) || cleanString(priorTunnel?.['url']),
122
+ chat_tunnel_url: cleanString(r?.['chat_tunnel_url']) || cleanString(priorTunnel?.['chat_tunnel_url']),
123
+ };
124
+ }
125
+ const computerAuthToken = cleanString(r?.['computer_auth_token']);
126
+ if (computerAuthToken) {
127
+ auth['machine'] = {
128
+ ...(asDict(auth['machine']) || {}),
129
+ token: computerAuthToken,
130
+ updated_at: now,
131
+ };
132
+ }
133
+ const proxyToken = (cleanString(r?.['proxy_token'])
134
+ || cleanString(r?.['proxyToken'])
135
+ || cleanString(r?.['hmac_token'])
136
+ || cleanString(r?.['hmacToken']));
137
+ if (proxyToken) {
138
+ const priorProxy = asDict(auth['proxy']);
139
+ auth['proxy'] = {
140
+ ...(priorProxy || {}),
141
+ token: proxyToken,
142
+ expires_in: Number(r?.['proxy_token_expires_in'] || priorProxy?.['expires_in'] || 0) || undefined,
143
+ expires_at: cleanString(r?.['proxy_token_expires_at'])
144
+ || cleanString(r?.['proxyTokenExpiresAt'])
145
+ || cleanString(priorProxy?.['expires_at']),
146
+ refreshed_at: cleanString(r?.['proxy_token_refreshed_at'])
147
+ || cleanString(priorProxy?.['refreshed_at'])
148
+ || now,
149
+ };
150
+ }
151
+ return auth;
152
+ }
153
+ /**
154
+ * Rejoin a public record with its secret envelope (auth-store.js:158-189).
155
+ * THE REFUSAL: when both sides name a computer_id and they disagree, the
156
+ * envelope is IGNORED wholesale — credentials from one machine identity
157
+ * must never dress up a record claiming another. On agreement the envelope
158
+ * only fills gaps; a value the record already carries always wins. Absent
159
+ * envelope values fill gaps as `''` — exactly the engine's bytes on disk.
160
+ */
161
+ export function mergeRecordAuth(record, auth) {
162
+ const r = asDict(record);
163
+ if (!r)
164
+ return null;
165
+ const merged = { ...r };
166
+ const a = asDict(auth);
167
+ if (!a)
168
+ return merged;
169
+ const authComputerId = cleanString(a['computer_id']);
170
+ const recordComputerId = cleanString(r['computer_id']);
171
+ if (authComputerId && recordComputerId && authComputerId !== recordComputerId)
172
+ return merged;
173
+ const tunnel = asDict(a['tunnel']);
174
+ const machine = asDict(a['machine']);
175
+ const proxy = asDict(a['proxy']);
176
+ if (!merged['tunnel_token'])
177
+ merged['tunnel_token'] = cleanString(tunnel?.['token']) || cleanString(a['tunnel_token']);
178
+ if (!merged['tunnel_url'])
179
+ merged['tunnel_url'] = cleanString(tunnel?.['url']);
180
+ if (!merged['chat_tunnel_url'])
181
+ merged['chat_tunnel_url'] = cleanString(tunnel?.['chat_tunnel_url']);
182
+ if (!merged['computer_auth_token']) {
183
+ merged['computer_auth_token'] = cleanString(machine?.['token']) || cleanString(a['computer_auth_token']);
184
+ }
185
+ if (!merged['proxy_token']) {
186
+ merged['proxy_token'] = cleanString(proxy?.['token']) || cleanString(a['proxy_token']);
187
+ }
188
+ if (!merged['proxy_token_expires_at']) {
189
+ merged['proxy_token_expires_at'] = cleanString(proxy?.['expires_at']) || cleanString(a['proxy_token_expires_at']);
190
+ }
191
+ if (!merged['proxy_token_expires_in'] && proxy?.['expires_in']) {
192
+ merged['proxy_token_expires_in'] = proxy['expires_in'];
193
+ }
194
+ if (!merged['proxy_token_refreshed_at']) {
195
+ merged['proxy_token_refreshed_at'] = cleanString(proxy?.['refreshed_at']);
196
+ }
197
+ if (!merged['user_id'])
198
+ merged['user_id'] = cleanString(a['user_id']);
199
+ if (!merged['user_email'])
200
+ merged['user_email'] = normalizeUserEmail(a['user_email']);
201
+ if (!merged['app_url'])
202
+ merged['app_url'] = cleanString(a['app_url']);
203
+ return merged;
204
+ }
205
+ /**
206
+ * May this stored record serve the runtime labeled `currentLabel`?
207
+ * (auth-store.js:191-199). The record's claim is read with key precedence
208
+ * runtime_label > label > branch through the NON-coercing normalizer, so a
209
+ * record with no recognizable label — one that predates labels — belongs
210
+ * to `main` and only main may claim it. Pure over the injected current
211
+ * label instead of module state.
212
+ */
213
+ export function recordMatchesCurrentRuntime(record, currentLabel) {
214
+ const r = asDict(record);
215
+ const label = normalizeRuntimeLabel(r?.['runtime_label'] || r?.['label'] || r?.['branch']);
216
+ if (!label)
217
+ return currentLabel === 'main';
218
+ return label === currentLabel;
219
+ }
220
+ /**
221
+ * The scope guard (auth-store.js:201-209): credentials for user A must
222
+ * never be saved into user B's scope. Enforced only when both sides are
223
+ * known and the scope is a real user — the `local` scope predates
224
+ * enrollment and accepts anyone. The refusal string is the ratified text.
225
+ */
226
+ export function assertRecordMatchesUserScope(record, context) {
227
+ const userId = cleanString(asDict(record)?.['user_id']);
228
+ const expectedScope = cleanString(context.userScope);
229
+ if (!userId || !expectedScope || expectedScope === 'local')
230
+ return;
231
+ if (userId === expectedScope)
232
+ return;
233
+ throw new Error(`Refusing to save ${context.runtimeLabel} computer credentials for user ${userId} in scope ${expectedScope}.`);
234
+ }
235
+ /** Refresh proxy tokens 10 minutes before they die (auth-store.js:22,
236
+ * proxy-token-store.js:20) — one shared buffer, so the two freshness laws
237
+ * below can never disagree about "soon". */
238
+ export const PROXY_REFRESH_BUFFER_MS = 10 * 60 * 1000;
239
+ /**
240
+ * A record's proxy token under every spelling (auth-store.js:298-306), with
241
+ * an explicitly supplied environment override winning. Pure: the engine
242
+ * reads `process.env.AMALGM_PROXY_TOKEN`; the host passes that value in.
243
+ */
244
+ export function proxyTokenFromRecord(record, envProxyToken = '') {
245
+ const r = asDict(record);
246
+ return (cleanString(envProxyToken)
247
+ || cleanString(r?.['proxy_token'])
248
+ || cleanString(r?.['proxyToken'])
249
+ || cleanString(r?.['hmac_token'])
250
+ || cleanString(r?.['hmacToken']));
251
+ }
252
+ /**
253
+ * The RECORD-side freshness law (auth-store.js:308-313), asked of a flat
254
+ * computer record: no token at all means refresh (true); a token whose
255
+ * expiry is missing or unparseable is trusted (false) — a legacy token
256
+ * without metadata keeps working; otherwise refresh inside the buffer.
257
+ * Note the deliberate asymmetry with {@link proxyTokenExpiresSoon}: over
258
+ * there, missing metadata means refresh — the envelope is expected to
259
+ * carry it.
260
+ */
261
+ export function proxyTokenNeedsRefresh(record, ports, envProxyToken = '') {
262
+ if (!proxyTokenFromRecord(record, envProxyToken))
263
+ return true;
264
+ const r = asDict(record);
265
+ const expiresAt = Date.parse(String(r?.['proxy_token_expires_at'] || r?.['proxyTokenExpiresAt'] || ''));
266
+ if (!Number.isFinite(expiresAt))
267
+ return false;
268
+ return expiresAt - ports.nowMs() < PROXY_REFRESH_BUFFER_MS;
269
+ }
270
+ /**
271
+ * The ENVELOPE-side freshness law (proxy-token-store.js:110-117), asked of
272
+ * an auth envelope, three branches exactly: no token → false (nothing to
273
+ * refresh ahead of); token with missing OR unparseable expiry → true (an
274
+ * envelope is expected to know its expiry; not knowing is stale); else
275
+ * refresh inside the buffer.
276
+ */
277
+ export function proxyTokenExpiresSoon(auth, ports) {
278
+ const proxy = asDict(asDict(auth)?.['proxy']);
279
+ if (!cleanString(proxy?.['token']))
280
+ return false;
281
+ const expiresAtRaw = cleanString(proxy?.['expires_at']);
282
+ if (!expiresAtRaw)
283
+ return true;
284
+ const expiresAt = Date.parse(expiresAtRaw);
285
+ if (!Number.isFinite(expiresAt))
286
+ return true;
287
+ return expiresAt - ports.nowMs() < PROXY_REFRESH_BUFFER_MS;
288
+ }
289
+ /**
290
+ * The refresh-credential precedence chain (proxy-token-store.js:88-98):
291
+ * the machine token is the machine's own auth token first, the legacy flat
292
+ * spelling second, and — last resort — the tunnel token; addresses and ids
293
+ * prefer the record over the envelope.
294
+ */
295
+ export function proxyRefreshContext(record, auth) {
296
+ const r = asDict(record);
297
+ const a = asDict(auth);
298
+ const machine = asDict(a?.['machine']);
299
+ const tunnel = asDict(a?.['tunnel']);
300
+ return {
301
+ machineToken: cleanString(machine?.['token'])
302
+ || cleanString(a?.['computer_auth_token'])
303
+ || cleanString(tunnel?.['token']),
304
+ appUrl: cleanString(r?.['app_url']) || cleanString(a?.['app_url']),
305
+ computerId: cleanString(r?.['computer_id']) || cleanString(a?.['computer_id']),
306
+ deviceId: cleanString(r?.['device_id']) || cleanString(a?.['device_id']),
307
+ };
308
+ }
309
+ /**
310
+ * May a refresh even be attempted? (proxy-token-store.js:100-103): a
311
+ * machine token, an app URL, and a computer id are required; the device id
312
+ * is optional context.
313
+ */
314
+ export function canRefreshProxyToken(context) {
315
+ const refresh = proxyRefreshContext(context.record, context.auth);
316
+ return !!(refresh.machineToken && refresh.appUrl && refresh.computerId);
317
+ }
318
+ /**
319
+ * The persisted `auth.json` after a successful proxy-token refresh
320
+ * (proxy-token-store.js:154-173): the prior envelope carried forward,
321
+ * identity restated from the refresh context, the server's answers winning
322
+ * over prior values field by field, and both `proxy.refreshed_at` and
323
+ * `updated_at` stamped now. `expires_in` collapses to undefined (dropped
324
+ * by JSON) when absent or zero. The caller guards with
325
+ * {@link canRefreshProxyToken}; this is only the shape law.
326
+ */
327
+ export function refreshedProxyAuth(input, ports) {
328
+ const { appUrl, computerId, deviceId } = proxyRefreshContext(input.record, input.auth);
329
+ const r = asDict(input.record);
330
+ const a = asDict(input.auth) || {};
331
+ const refreshed = asDict(input.refreshed);
332
+ const priorProxy = asDict(a['proxy']);
333
+ const now = ports.nowIso();
334
+ return {
335
+ ...a,
336
+ schema_version: 1,
337
+ computer_id: computerId,
338
+ device_id: deviceId,
339
+ user_id: cleanString(r?.['user_id']) || cleanString(a['user_id']),
340
+ user_email: cleanString(refreshed?.['user_email']).toLowerCase()
341
+ || cleanString(r?.['user_email']).toLowerCase()
342
+ || cleanString(a['user_email']).toLowerCase(),
343
+ app_url: appUrl,
344
+ proxy: {
345
+ ...(priorProxy || {}),
346
+ token: cleanString(refreshed?.['proxy_token']) || cleanString(priorProxy?.['token']),
347
+ expires_in: Number(refreshed?.['proxy_token_expires_in'] || priorProxy?.['expires_in'] || 0) || undefined,
348
+ expires_at: cleanString(refreshed?.['proxy_token_expires_at']) || cleanString(priorProxy?.['expires_at']),
349
+ refreshed_at: now,
350
+ },
351
+ updated_at: now,
352
+ };
353
+ }
354
+ //# sourceMappingURL=auth-records.js.map