@jini-ai/platform 0.3.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +121 -2
  3. package/dist/asset-cache.d.ts +0 -22
  4. package/dist/asset-cache.js +3 -138
  5. package/dist/download.js +2 -2
  6. package/dist/fs/atomic-write.d.ts +103 -0
  7. package/dist/fs/atomic-write.js +201 -0
  8. package/dist/fs/durable-json.d.ts +89 -0
  9. package/dist/fs/durable-json.js +166 -0
  10. package/dist/fs/env-text.d.ts +11 -0
  11. package/dist/fs/env-text.js +22 -0
  12. package/dist/fs/file-lock.d.ts +126 -0
  13. package/dist/fs/file-lock.js +306 -0
  14. package/dist/fs/guarded-reader.d.ts +60 -0
  15. package/dist/fs/guarded-reader.js +310 -0
  16. package/dist/fs/index.d.ts +8 -0
  17. package/dist/fs/index.js +9 -0
  18. package/dist/fs/strict-containment.d.ts +5 -0
  19. package/dist/fs/strict-containment.js +13 -0
  20. package/dist/fs.js +2 -3
  21. package/dist/http/guarded/client.d.ts +13 -0
  22. package/dist/http/guarded/client.js +240 -0
  23. package/dist/http/guarded/errors.d.ts +21 -0
  24. package/dist/http/guarded/errors.js +20 -0
  25. package/dist/http/guarded/fetch-adapter.d.ts +23 -0
  26. package/dist/http/guarded/fetch-adapter.js +30 -0
  27. package/dist/http/guarded/index.d.ts +12 -0
  28. package/dist/http/guarded/index.js +7 -0
  29. package/dist/http/guarded/node-ports.d.ts +8 -0
  30. package/dist/http/guarded/node-ports.js +11 -0
  31. package/dist/http/guarded/ports.d.ts +51 -0
  32. package/dist/http/guarded/ports.js +2 -0
  33. package/dist/http/guarded/transport.fetch.d.ts +11 -0
  34. package/dist/http/guarded/transport.fetch.js +109 -0
  35. package/dist/http/guarded/types.d.ts +8 -0
  36. package/dist/http/guarded/types.js +2 -0
  37. package/dist/index.d.ts +61 -1
  38. package/dist/index.js +61 -1
  39. package/dist/mail/adapter-module.d.ts +20 -0
  40. package/dist/mail/adapter-module.js +2 -0
  41. package/dist/mail/delivery-availability.d.ts +7 -0
  42. package/dist/mail/delivery-availability.js +6 -0
  43. package/dist/mail/index.d.ts +7 -0
  44. package/dist/mail/index.js +3 -0
  45. package/dist/mail/ports.d.ts +43 -0
  46. package/dist/mail/ports.js +2 -0
  47. package/dist/mail/purpose-scoped-mailer.d.ts +18 -0
  48. package/dist/mail/purpose-scoped-mailer.js +50 -0
  49. package/dist/mail/smtp.d.ts +79 -0
  50. package/dist/mail/smtp.js +102 -0
  51. package/dist/mail/types.d.ts +83 -0
  52. package/dist/mail/types.js +2 -0
  53. package/dist/messages.d.ts +22 -0
  54. package/dist/messages.js +15 -0
  55. package/dist/net/address.d.ts +38 -0
  56. package/dist/net/address.js +241 -0
  57. package/dist/net/index.d.ts +2 -0
  58. package/dist/net/index.js +2 -0
  59. package/dist/process.d.ts +2 -1
  60. package/dist/process.js +2 -1
  61. package/dist/proxy-env.js +1 -1
  62. package/dist/resource-paths.js +1 -1
  63. package/dist/sandbox-env.d.ts +1 -1
  64. package/dist/sandbox-env.js +1 -1
  65. package/dist/secrets/aad.d.ts +7 -0
  66. package/dist/secrets/aad.js +5 -0
  67. package/dist/secrets/credential-sets/aad.d.ts +7 -0
  68. package/dist/secrets/credential-sets/aad.js +7 -0
  69. package/dist/secrets/credential-sets/index.d.ts +4 -0
  70. package/dist/secrets/credential-sets/index.js +3 -0
  71. package/dist/secrets/credential-sets/repo.memory.d.ts +36 -0
  72. package/dist/secrets/credential-sets/repo.memory.js +72 -0
  73. package/dist/secrets/credential-sets/types.d.ts +67 -0
  74. package/dist/secrets/credential-sets/types.js +6 -0
  75. package/dist/secrets/index.d.ts +63 -0
  76. package/dist/secrets/index.js +35 -0
  77. package/dist/secrets/keyring.env.d.ts +176 -0
  78. package/dist/secrets/keyring.env.js +271 -0
  79. package/dist/secrets/keyring.memory.d.ts +10 -0
  80. package/dist/secrets/keyring.memory.js +9 -0
  81. package/dist/secrets/ports.d.ts +33 -0
  82. package/dist/secrets/ports.js +2 -0
  83. package/dist/secrets/secret-sealer.aesgcm.d.ts +22 -0
  84. package/dist/secrets/secret-sealer.aesgcm.js +64 -0
  85. package/dist/secrets/testing.d.ts +4 -0
  86. package/dist/secrets/testing.js +4 -0
  87. package/dist/secrets/types.d.ts +10 -0
  88. package/dist/secrets/types.js +2 -0
  89. package/dist/shell.d.ts +10 -6
  90. package/dist/shell.js +10 -6
  91. package/dist/terminal.d.ts +2 -2
  92. package/dist/terminal.js +9 -5
  93. package/package.json +79 -4
  94. package/dist/asset-cache.d.ts.map +0 -1
  95. package/dist/asset-cache.js.map +0 -1
  96. package/dist/aws-sigv4.d.ts.map +0 -1
  97. package/dist/aws-sigv4.js.map +0 -1
  98. package/dist/blob-storage.d.ts.map +0 -1
  99. package/dist/blob-storage.js.map +0 -1
  100. package/dist/command.d.ts.map +0 -1
  101. package/dist/command.js.map +0 -1
  102. package/dist/download.d.ts.map +0 -1
  103. package/dist/download.js.map +0 -1
  104. package/dist/fetch-with-timeout.d.ts.map +0 -1
  105. package/dist/fetch-with-timeout.js.map +0 -1
  106. package/dist/fs.d.ts.map +0 -1
  107. package/dist/fs.js.map +0 -1
  108. package/dist/home-expansion.d.ts.map +0 -1
  109. package/dist/home-expansion.js.map +0 -1
  110. package/dist/http.d.ts.map +0 -1
  111. package/dist/http.js.map +0 -1
  112. package/dist/index.d.ts.map +0 -1
  113. package/dist/index.js.map +0 -1
  114. package/dist/process.d.ts.map +0 -1
  115. package/dist/process.js.map +0 -1
  116. package/dist/proxy-env.d.ts.map +0 -1
  117. package/dist/proxy-env.js.map +0 -1
  118. package/dist/resource-paths.d.ts.map +0 -1
  119. package/dist/resource-paths.js.map +0 -1
  120. package/dist/sandbox-env.d.ts.map +0 -1
  121. package/dist/sandbox-env.js.map +0 -1
  122. package/dist/shell.d.ts.map +0 -1
  123. package/dist/shell.js.map +0 -1
  124. package/dist/terminal.d.ts.map +0 -1
  125. package/dist/terminal.js.map +0 -1
  126. package/dist/toolchain.d.ts.map +0 -1
  127. package/dist/toolchain.js.map +0 -1
package/CHANGELOG.md ADDED
@@ -0,0 +1,150 @@
1
+ # @jini-ai/platform
2
+
3
+ ## 0.5.1 — 2026-10-04
4
+
5
+ - `./secrets` exports the canonical site-key names (`SiteKeyHandle`, `FixedSiteKeyKeyring`, `UnusableSiteKeyError`, `SiteKeyFileAlreadyExistsError`, `parseSiteKeyHex`, `fingerprintSiteKeyHex`, `inspectSiteKeyMaterial`, `revealSiteKeyMaterial`, `generateFileSiteKey`, `deriveFromSiteKey`, `deriveSigningSecretFromSiteKey` and the matching types). Every `RootKey*` name stays exported as a `@deprecated` alias of the same binding, to be removed on or after 2026-11-01. Additive: no behavior or signature change.
6
+
7
+ ## 0.5.0 — 2026-10-02
8
+
9
+ ### BREAKING
10
+
11
+ - Use canonical core ports and platform ./net and ./fs/file-lock. Analytics and trash moved to their domain packages; credential scope and durable filesystem contracts changed.
12
+ - Distribution includes runtime output, release documentation and required assets only. Process records and per-job neutrality checks are no longer part of the package surface.
13
+
14
+ ## Unreleased
15
+
16
+ ### Compilation fixes
17
+
18
+ - Omit an absent guarded HTTP abort signal to satisfy the canonical request contract.
19
+ - Import the mail adapter's HTTP port from core primitives and type the key-rotation test
20
+ fake against `KeyringPort`, preserving its assertions and behavior.
21
+
22
+ ### Breaking domain extraction
23
+
24
+ - Remove `./analytics` and the analytics root namespace; import the standalone `@jini-ai/analytics` package.
25
+ - Remove `./trash` and the trash root namespace; import `@jini-ai/cms/trash`.
26
+ - Move both capabilities' behavior tests with their source; platform retains only generic OS/transport capabilities.
27
+
28
+
29
+ ### Shared platform primitives
30
+
31
+ - BREAKING: HTTP primitive types now come from `@jini-ai/core/primitives`; guarded HTTP exports
32
+ `GuardedClock` with `nowMs()` and `timeoutSignal({ timeoutMs })`. Mail uses the kernel clock.
33
+ Analytics primitive declarations also come from core. Add core as a regular dependency.
34
+ - BREAKING: address classifiers move from asset-cache.ts to `./net`; `isPrivateAddress` and
35
+ `expandIpv6` take `{ address }`. The root IP-classifier export points to the canonical module.
36
+ - Add `./fs/file-lock` async/sync token-and-inode-owned locks sharing one stale decision.
37
+ Durable JSON retains its lock budgets, user copy, read/quarantine behavior and JSON bytes.
38
+ - BREAKING: the synchronous atomic filesystem seam owns descriptors and exposes open/sync/close/
39
+ lstat as well as existing mode/rename/remove effects. Add file and directory fsync and owned-temp
40
+ failure cleanup; move `tempName` into argument two as an optional token factory with the native pid.UUID default. Add async file/JSON
41
+ writers, secret-mode verification, optional symlink refusal, and sidecar newline/parent support.
42
+ Durable JSON delegates to the canonical writer, retains its temp layout/new-file mode, and gains
43
+ exclusive temp creation, cleanup on failure, and directory fsync.
44
+ - Add exported `defaultPlatformMessages` with host replacement seams. Egress refusal and invalid
45
+ root-key recovery warnings use neutral defaults. Keyring source-permission flags and derivation
46
+ formats are unchanged. Tests and builds are deferred by the owner.
47
+
48
+ - Security defaults: purpose-gated mail validates runtime modes at composition; only explicit local mode bypasses readiness. Root-key adapters require an injected environment reader and explicit fallback/generation booleans. Fresh inspection/reveal also require that reader. Existing wire derivation is unchanged.
49
+
50
+ - Guarded HTTP restores per-socket inactivity timeouts. Requests accept explicit
51
+ `idleTimeoutMs`, with `timeoutMs` retained as its legacy alias and the historical
52
+ `connectTimeoutMs` policy ceiling applied per socket. Optional `totalDeadlineMs`
53
+ bounds DNS, all redirects and decoded body reads independently; it is off by default.
54
+ Idle failures retain `Error("request timed out after …ms")`; explicit deadline failures
55
+ use `FetchTimeoutError`. Slow but progressing transfers are no longer cut off by
56
+ an implicit total deadline. Verification is deferred by owner directive.
57
+
58
+ ### Integration finish pass
59
+
60
+ - Complete Node runtime metadata for every export, the filesystem barrel, and additive root
61
+ namespaces for `./http/guarded`, `./mail`, `./mail/smtp`, `./fs`, `./fs/guarded-reader`,
62
+ `./fs/durable-json`, `./analytics`, `./trash`, `./secrets`, `./secrets/credential-sets`,
63
+ and `./secrets/testing`. No version change or dependency addition.
64
+ - BREAKING (new extraction APIs): native factories and transport/memory constructors take `{}`;
65
+ keyring `activeKey`, mailer `capabilities`, clock/random methods and durable JSON `read` take
66
+ `{}`. SMTP transport `sendMail` takes `{ mail }`; the native nodemailer payload remains
67
+ behind `NativeSmtpTransport`. Analytics/trash policy errors take `{ message }` and optional
68
+ `{ cause }`. Existing legacy root functions retain their compatibility signatures.
69
+ - Reconcile the export-map contract test and remove application names from analytics fixtures.
70
+ Verification of this finish pass is deferred by owner directive.
71
+
72
+ - Add privacy analytics ingest with required HKDF context and privacy policy, injectable hash/hook seams, and a recent-hit memory sink.
73
+ - Add trash write/sweep orchestration with required retention/entity policy, reentrant transaction ports, host scheduler, record-store adapter, and optional follow-ups in arg 2.
74
+ - Add `./fs` with mode-preserving atomic file/JSON writes, narrow env-text transforms, and strict lexical containment; keep all existing filesystem exports unchanged.
75
+
76
+ - Add guarded outbound HTTP with injected DNS, pinned transport, clock, policy, and required user agent. Reuse existing address validation and timeout errors. Preserve redirect refusal and response-byte contracts.
77
+ - Add mail contracts, a purpose/readiness gate, delivery capability checks, and an optional SMTP subpath over a host-supplied transport/module; no new dependency.
78
+ - Add contained and bounded file readers with caller-owned deny rules/limits, and durable JSON file operations with injectable filesystem, time, liveness, sleep, randomness, and notices.
79
+ - Convert the unpublished secrets surface to required-object/optional-object signatures; preserve pinned derivation, AAD, ciphertext, and envelope bytes.
80
+
81
+ ## 0.4.0
82
+
83
+ ### Minor Changes
84
+
85
+ - Add `./secrets` with AES-256-GCM sealing, root-key ports, explicit env/file and fixed-key
86
+ adapters, HKDF helpers, and root-key validation, status, reveal, and exclusive generation.
87
+ Env names, key paths, and HKDF salts are caller-supplied, with no defaults. New seals require
88
+ AAD; opening historical ciphertext without AAD remains supported. The ciphertext envelope
89
+ and derivation labels are preserved and covered by pinned vectors.
90
+ - Add `./secrets/credential-sets` with generic vendor credential records, repository contracts,
91
+ the stable v1 AAD builder, and a memory repository. Add `./secrets/testing` with ephemeral and
92
+ fixed root-key adapters and the memory repository. Existing exports and dependencies are unchanged.
93
+
94
+ ## 0.1.2
95
+
96
+ ### Patch Changes
97
+
98
+ - Add top-level `main`/`types` fields alongside the existing `exports` map. A consumer on
99
+ TypeScript's classic `moduleResolution: "node"` (node10) — which ignores `package.json#exports`
100
+ entirely — could not resolve this package's types at all (`TS2307: Cannot find module`) even
101
+ after the previous exports-map fix restored `require()` at runtime; type resolution and runtime
102
+ resolution are separate algorithms. Verified against a real external consumer (whose
103
+ tsconfig uses this legacy resolution mode): adding these two fields, with its tsconfig completely
104
+ unchanged, made the error disappear. Also fixes absolute-path `require()` (distinct from a bare
105
+ specifier, which already worked) for the same reason — `main` was previously absent.
106
+
107
+ Purely additive: every modern resolver (Node's own runtime `exports` resolution, TypeScript's
108
+ `bundler`/`node16`/`nodenext`) prefers `exports` over `main`/`types` when both are present, so
109
+ this changes nothing for a consumer already on a modern resolver.
110
+
111
+ ## 0.1.1
112
+
113
+ ### Patch Changes
114
+
115
+ - Add a `"default"` export condition to every published package's `exports` map — every one of
116
+ them lacked it, which meant `require()` failed with `ERR_PACKAGE_PATH_NOT_EXPORTED` for any
117
+ CommonJS consumer (found via a real external integration attempt; Node needs `require(esm)`
118
+ support, i.e. Node >=22.12, for this to resolve).
119
+
120
+ `@jini-ai/agent-runtime`:
121
+
122
+ - **New**: `RuntimeBuildOptions.permissionMode` (`'bypass' | 'restricted'`) lets a caller opt a
123
+ run OUT of the auto-approve-every-permission-prompt flag every def with one
124
+ (`bypassPermissions` / `--yolo` / `--dangerously-skip-permissions`) previously pushed
125
+ unconditionally, with no way to turn it off. Omitting it keeps today's default (bypass)
126
+ behavior unchanged.
127
+ - **New**: `ClaudeStreamEvent`, `CopilotStreamEvent`, and `QoderEvent` are now real exported
128
+ discriminated unions instead of `Record<string, unknown>` — a real external consumer guessed a
129
+ nonexistent field name (`event.text` instead of the actual `event.delta`) against the old
130
+ untyped sink and silently lost every streamed token with no compile or runtime error.
131
+ - Fixed a doc/implementation mismatch in `claude-stream.ts`: the module doc claimed `tool_result`
132
+ events carry `{ tool_use_id, content, is_error }`; the actual emitted shape is
133
+ `{ toolUseId, content, isError }`.
134
+
135
+ `@jini-ai/daemon`: `AgentExecutorRunInput.permissionMode` forwards the new
136
+ `RuntimeBuildOptions.permissionMode` through to `buildArgs`, so a host can actually reach the new
137
+ opt-out from the daemon's real run-input surface, not just from `@jini-ai/agent-runtime` in
138
+ isolation.
139
+
140
+ `@jini-ai/agentic`: `setAtPointer` no longer throws on a malformed (e.g. missing leading `/`)
141
+ `updateDataModel` path — degrades to a no-op like its sibling `getAtPointer`, matching this
142
+ package's own "a bad binding must not crash the renderer" contract. That path is agent-authored
143
+ wire data with no error boundary above it in any host, so the uncaught throw could unmount an
144
+ entire chat UI from ~40 bytes of malformed input.
145
+
146
+ `@jini-ai/chat-react`: a local (client-resolved) A2UI button action is no longer a silent no-op —
147
+ `A2uiSurfaceCard` now surfaces the resolved value. New `ExtEventErrorBoundary` confines a
148
+ `kind: 'ext'` event group's renderer to its own card instead of letting a render/effect-phase
149
+ throw from agent-controlled content unmount the whole chat root (there was no error boundary
150
+ anywhere in this package or its hosts before this).
package/README.md CHANGED
@@ -6,7 +6,7 @@ This is the Node-only substrate a daemon host needs to spawn and supervise child
6
6
  for a port to come up, resolve a user's installed toolchains, manage terminal (PTY) sessions, and
7
7
  store blobs — all without depending on `@jini-ai/daemon` or any product code. Ported mostly
8
8
  verbatim from Open Design's own `packages/platform` plus several generic daemon-support files
9
- that were split out of OD's monolithic daemon (see `source-map.md` for the full per-file trail).
9
+ that were split out of OD's monolithic daemon (see the archived provenance ledger for the full per-file trail).
10
10
 
11
11
  ## Install
12
12
 
@@ -85,4 +85,123 @@ ESM only — ships `"type": "module"` with no CommonJS `require` build.
85
85
 
86
86
  ## Provenance
87
87
 
88
- See [source-map.md](./source-map.md) for per-file provenance and scope decisions. Apache-2.0.
88
+ See the archived provenance ledger for per-file provenance and scope decisions. Apache-2.0.
89
+ # Additional subpaths
90
+
91
+ The package also exposes `./http/guarded`, `./mail`, `./mail/smtp`,
92
+ `./fs/guarded-reader`, `./fs/durable-json`, and `./secrets`. New APIs take a required
93
+ object followed by an optional object. Application identity and policy values belong to the caller.
94
+
95
+ ```ts
96
+ import { createHttpClient, createNodeGuardedHttpPorts, guardedFetch } from "@jini-ai/platform/http/guarded";
97
+
98
+ const client = createHttpClient({
99
+ ...createNodeGuardedHttpPorts({}),
100
+ userAgent: "MyApplication/1.0",
101
+ policy: {
102
+ allowedSchemes: ["https"], denyPrivateAddresses: true, devHostAllowlist: [],
103
+ maxRedirects: 3, connectTimeoutMs: 5000,
104
+ maxResponseBytes: 1_000_000, maxDecompressedBytes: 1_000_000,
105
+ },
106
+ });
107
+ const response = await guardedFetch(
108
+ { client, url: "https://provider.example/metadata", timeoutMs: 5000 },
109
+ { redirect: "error" },
110
+ );
111
+ ```
112
+
113
+ `guardedFetch` returns a native Response, refuses truncated bodies, and defaults to redirect
114
+ refusal. SDKs with positional fetch callbacks can adapt them at the host boundary. The HTTP
115
+ transport pins the vetted IP while preserving authority and TLS SNI; every followed GET redirect
116
+ is checked again. The `rateLimit` policy field is reserved metadata and does not implement pacing.
117
+
118
+ Mail sends put message, idempotency key, workspace, and source context in argument one, with
119
+ lane/purpose/timeout in argument two. `wrapMailerWithPurposeGate` requires an inner mailer,
120
+ runtime mode, and a readiness port. Unknown or absent lanes require a durable path in production.
121
+ The SMTP subpath accepts a transport and clock; its nodemailer factory accepts the host's module
122
+ as a structural port. Core loads no mail vendor and adds no mail dependency.
123
+
124
+ The guarded reader requires root, deny rules, all read/traversal limits, and a filesystem port.
125
+ The durable JSON factory requires file path, filesystem/time/sleep/liveness/random/reporting ports,
126
+ and host notices. It preserves atomic replacement and corrupt-file states; parent-directory
127
+ persistence and the inherited simultaneous stale-lock-break race remain filesystem/locking limits.
128
+
129
+ See [API.md](./API.md) for the source/export mapping and host wiring,
130
+ and [the secrets guide](./src/secrets/README.md) for ciphertext-compatible object signatures.
131
+
132
+ ## Integrated extraction APIs
133
+
134
+ All exports declare a Node runtime. The root exposes `filesystem`, `guardedHttp`, `mail`,
135
+ `smtp`, `secrets`, and `credentialSets` namespaces; the dedicated subpaths
136
+ remain available. `./fs` now also exports guarded readers and durable JSON helpers. Legacy root
137
+ functions keep their existing signatures for consumers already using them.
138
+
139
+ New factories/functions/methods accept a required object and, where needed, a second optional
140
+ object. No-input operations take `{}`: `createNodeGuardedHttpPorts({})`,
141
+ `createNodeGuardedReaderFilesystem({})`, `createNodeDurableJsonPorts({})`,
142
+ `new FetchHttpTransportAdapter({})`, `new InMemoryTrashRepo({})`,
143
+ `new InMemoryVendorCredentialSetRepo({})`, `keyring.activeKey({})`,
144
+ `mailer.capabilities({})`, `clock.nowMs({})`, `clock.nowIso({})`, `random.token({})`,
145
+ and `file.read({})`. SMTP ports receive `sendMail({ mail })`; `NodemailerPort` exposes the
146
+ vendor-shaped `NativeSmtpTransport` so the factory adapts the payload once. Policy errors use
147
+ `new AnalyticsPiiRejectedError({ message }, { cause })` and
148
+ `new TrashAdapterMissingError({ message }, { cause })`.
149
+
150
+ | Subpath | API and required host ports/policy |
151
+ | --- | --- |
152
+ | `@jini-ai/analytics` (separate package) | `ingestHit({ input, deps }, { hooks, hash })`, `normalizeIngestContext({ input, dailySalt }, { hash })`, `deriveDailySalt({ rootKeySeed, workspaceId, utcDate, saltContext })`. The host supplies sink/config, host resolution, HKDF context, and privacy bounds. `LocalBufferSink({}, { initialHits })` is process-local. |
153
+ | `@jini-ai/cms/trash` (separate package) | `createTrashService({ repo, adapters, transaction, idGen, entityPolicy }, { retentionDays, ...optional })`, `createTrashSweep({ repo, adapters, transaction, entityPolicy })`, and `startTrashSweeper({ sweep, clock, scheduler, leaseOwner }, optional)`. The host owns authorization, transactions, retention, scheduling, and adapters; follow-up hooks are in argument two. |
154
+ | `./fs` | `writeFileAtomic({ filePath, content, fs, tempName })`, `writeJsonFileAtomic({ filePath, data, fs, tempName })`, `upsertEnvLine({ source, key, value })`, `readEnvLine({ source, key })`, `resolvePathWithin({ root, segment })`. Atomic writes preserve existing modes and require filesystem/temp-name ports. |
155
+
156
+ The extraction mappings, caller counts, remaining ownership needs, and serial verification
157
+ commands are recorded in [the integration handoff](./integration-platform-report.md).
158
+
159
+ ## Design decisions
160
+
161
+ - [Unknown mail purposes take the restrictive delivery lane](docs/decisions/DR-001-purpose-scoped-mail.md).
162
+ - [Outbound HTTP inherits guards from one injected client](docs/decisions/DR-002-guarded-egress.md).
163
+
164
+ ## Shared kernel and filesystem/network primitives
165
+
166
+ HTTP request/response/redirect/client contracts and `Clock`, `UUID`, `ISODateTime`, and JSON
167
+ contracts come from `@jini-ai/core/primitives`. Guarded construction uses `GuardedClock`, which
168
+ adds `timeoutSignal({ timeoutMs })` to `nowMs()`. SMTP acceptance time uses `nowIso({ clock })`.
169
+ Mail adapter kits use that same core `HttpClientPort`; guarded requests omit `signal` when
170
+ neither caller cancellation nor a total deadline supplies one.
171
+ Socket idle budgets and optional total deadlines retain their existing behavior.
172
+
173
+ `@jini-ai/platform/net` publishes `isPrivateAddress({ address })`, `expandIpv6({ address })`,
174
+ `isLoopbackApiHost({ hostname })`, `isBlockedExternalApiHostname({ hostname })`, and
175
+ `isLoopbackHostname({ hostname })`. Resolved-peer SSRF classification fails closed and blocks
176
+ loopback, including compatible/mapped IPv6. Provider hostname policy deliberately allows loopback
177
+ and requires DNS/connection-time checks separately. The daemon hostname predicate retains its
178
+ narrower policy. The IP classifier no longer lives in asset-cache.ts.
179
+
180
+ `@jini-ai/platform/fs` publishes `writeFileAtomic` / `writeJsonFileAtomic` and their `Async`
181
+ counterparts. Synchronous calls take `{ filePath, content, fs }` (JSON uses `data`),
182
+ where `fs` comes from `createNodeAtomicFilesystem({})`. Async calls take `{ filePath, content }` and accept a native `fs/promises` seam in the optional object. Both preserve existing
183
+ permissions, default new files to 0600, create a sibling exclusively, flush file bytes before rename,
184
+ flush the directory after rename, and clean owned temps on failure. The optional object
185
+ accepts `tempName`; omitting it uses
186
+ `${process.pid}.${randomUUID()}`. Options include `mode`, `defaultMode`, `refuseSymlink`,
187
+ `verifyOwnerOnly`, and `platform`; async calls also support `createParent`, and async JSON supports
188
+ `trailingNewline` for runtime-state files. Secret stores pass `{ mode: 0o600, verifyOwnerOnly: true }`;
189
+ POSIX permission verification fails closed. Directory fsync failures surface after replacement.
190
+
191
+ `@jini-ai/platform/fs/file-lock` publishes `withFileLock({ lockPath, run }, options)` and
192
+ `withFileLockSync(...)`. Defaults are 15 seconds to wait, 10 seconds stale, and 10ms polling;
193
+ options accept `timeoutMs`, `staleMs`, `pollMs`, and the kernel `clock`, plus filesystem/liveness/
194
+ sleep seams. Run receives an ownership handle with `assertHeld({})`. Age or dead local-owner
195
+ liveness permits stealing only after inode/device, contents and mtime remain unchanged. Unknown or
196
+ foreign-owner pids do not authorize death-based stealing. `staleMs: Infinity` disables age stealing.
197
+ The documented final check-to-unlink race remains; use a stale budget longer than the operation.
198
+ Durable JSON passes its existing 5s/30s/10ms values, legacy token/temp format, and timeout copy.
199
+
200
+ `defaultPlatformMessages` is exported from the root, guarded HTTP, and secrets entries. Hosts can
201
+ supply a replacement `PlatformMessages` object to `EgressRefusedError`, `EnvOrFileKeyring`, or the
202
+ lock/atomic-writer options. Explicit `callerSafeMessage` still takes precedence for an egress refusal.
203
+ Credential records retain the required `workspaceId` field: it binds existing repository rows and
204
+ AES-GCM AAD. Renaming that persisted field requires a host adapter migration as well as a type edit.
205
+ Analytics is available from the standalone `@jini-ai/analytics` package; soft-delete and purge
206
+ services are available from `@jini-ai/cms/trash`. Their platform subpaths and root namespaces
207
+ have been removed.
@@ -16,28 +16,6 @@ export declare class AssetCacheError extends Error {
16
16
  readonly status: number;
17
17
  constructor(status: number, message: string);
18
18
  }
19
- /** Expand an IPv6 literal (with `::` compression and/or an embedded dotted
20
- * IPv4 tail) into its eight 16-bit groups, or null if it does not parse.
21
- * Node's URL parser normalizes mapped literals to hex (`::ffff:127.0.0.1` →
22
- * `::ffff:7f00:1`), so a string regex can't reliably spot the mapped range —
23
- * we have to canonicalize.
24
- *
25
- * Exported (previously module-private) so its parse-failure guards are
26
- * directly unit-testable: `isPrivateAddress`'s only real call site gates
27
- * every call behind `net.isIP(addr) === 6`, and Node's `isIP` already fully
28
- * validates IPv6 syntax (including embedded-IPv4-tail octet ranges) before
29
- * returning 6 — empirically re-verified this session by fuzzing ~10k
30
- * `isIP`-accepted v6 literals through this function's guards with zero
31
- * hits — so none of these guards are reachable through that path. They stay
32
- * as real defense-in-depth (fail closed with `null` rather than trust a
33
- * platform primitive never changing behavior), matching this file's
34
- * "exported for direct unit testing" precedent (see `readBodyCapped`'s
35
- * abort parameter). */
36
- export declare function expandIpv6(addr: string): number[] | null;
37
- /** True for any address an external CDN must never resolve to: loopback,
38
- * link-local, private, CGNAT, ULA, multicast, or unspecified. An unparseable
39
- * input is treated as unsafe. */
40
- export declare function isPrivateAddress(addr: string): boolean;
41
19
  /** Cheap up-front rejection: enforce http(s), no embedded credentials, no
42
20
  * localhost, and no *literal* private IP. This is NOT the DNS-rebinding guard
43
21
  * (that is `createValidatingLookup`, applied at connection time) — it only
@@ -25,6 +25,7 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
25
25
  import { isIP } from 'node:net';
26
26
  import path from 'node:path';
27
27
  import { Agent } from 'undici';
28
+ import { isPrivateAddress } from './net/address.js';
28
29
  /** Media extensions we are willing to cache + proxy. Anything else (HTML,
29
30
  * CSS, JS, fonts) is intentionally left alone so this surface never becomes a
30
31
  * general-purpose open proxy. */
@@ -75,142 +76,6 @@ export class AssetCacheError extends Error {
75
76
  this.status = status;
76
77
  }
77
78
  }
78
- /** Expand an IPv6 literal (with `::` compression and/or an embedded dotted
79
- * IPv4 tail) into its eight 16-bit groups, or null if it does not parse.
80
- * Node's URL parser normalizes mapped literals to hex (`::ffff:127.0.0.1` →
81
- * `::ffff:7f00:1`), so a string regex can't reliably spot the mapped range —
82
- * we have to canonicalize.
83
- *
84
- * Exported (previously module-private) so its parse-failure guards are
85
- * directly unit-testable: `isPrivateAddress`'s only real call site gates
86
- * every call behind `net.isIP(addr) === 6`, and Node's `isIP` already fully
87
- * validates IPv6 syntax (including embedded-IPv4-tail octet ranges) before
88
- * returning 6 — empirically re-verified this session by fuzzing ~10k
89
- * `isIP`-accepted v6 literals through this function's guards with zero
90
- * hits — so none of these guards are reachable through that path. They stay
91
- * as real defense-in-depth (fail closed with `null` rather than trust a
92
- * platform primitive never changing behavior), matching this file's
93
- * "exported for direct unit testing" precedent (see `readBodyCapped`'s
94
- * abort parameter). */
95
- export function expandIpv6(addr) {
96
- // `String#split` always returns a non-empty array, so index 0 is never
97
- // `undefined` — the `!` narrows a `noUncheckedIndexedAccess` false
98
- // positive, not a real fallback path.
99
- let s = addr.toLowerCase().split('%')[0];
100
- if (!s.includes(':'))
101
- return null;
102
- // Fold a trailing dotted IPv4 (`…:1.2.3.4`) into two hex groups.
103
- const v4 = /^(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(s);
104
- if (v4) {
105
- // `v4[2]` is the regex's second (mandatory, non-optional) capture group
106
- // of a successful match — always defined.
107
- const o = v4[2].split('.').map((n) => Number(n));
108
- if (o.length !== 4 || o.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
109
- return null;
110
- // `o` is now a validated 4-element array — indices 0-3 always defined.
111
- const hi = (((o[0] << 8) | o[1])).toString(16);
112
- const lo = (((o[2] << 8) | o[3])).toString(16);
113
- s = `${v4[1]}${hi}:${lo}`;
114
- }
115
- const halves = s.split('::');
116
- if (halves.length > 2)
117
- return null;
118
- const parse = (p) => p === '' ? [] : p.split(':').map((g) => (/^[0-9a-f]{1,4}$/.test(g) ? parseInt(g, 16) : Number.NaN));
119
- // `halves` always has at least one element (split guarantee).
120
- const left = parse(halves[0]);
121
- let groups;
122
- if (halves.length === 1) {
123
- groups = left;
124
- }
125
- else {
126
- // `halves.length === 2` here (the `> 2` case already returned above).
127
- const right = parse(halves[1]);
128
- const fill = 8 - left.length - right.length;
129
- if (fill < 1)
130
- return null;
131
- groups = [...left, ...new Array(fill).fill(0), ...right];
132
- }
133
- if (groups.length !== 8 || groups.some((g) => Number.isNaN(g) || g < 0 || g > 0xffff))
134
- return null;
135
- return groups;
136
- }
137
- /** True for any address an external CDN must never resolve to: loopback,
138
- * link-local, private, CGNAT, ULA, multicast, or unspecified. An unparseable
139
- * input is treated as unsafe. */
140
- export function isPrivateAddress(addr) {
141
- const fam = isIP(addr);
142
- if (fam === 4) {
143
- // `net.isIP(addr) === 4` already guarantees `addr` is a syntactically
144
- // valid dotted-quad with four 0-255 decimal octets (no leading-zero
145
- // octal ambiguity, no out-of-range component) — empirically re-verified
146
- // this session by fuzzing 500k+ candidate strings through `isIP` and
147
- // this same re-derivation with zero divergences. `o` is therefore always
148
- // a validated 4-element array.
149
- const o = addr.split('.').map((n) => Number(n));
150
- const a = o[0];
151
- const b = o[1];
152
- if (a === 0)
153
- return true; // "this" network / unspecified
154
- if (a === 10)
155
- return true; // private
156
- if (a === 127)
157
- return true; // loopback
158
- if (a === 169 && b === 254)
159
- return true; // link-local
160
- if (a === 172 && b >= 16 && b <= 31)
161
- return true; // private
162
- if (a === 192 && b === 168)
163
- return true; // private
164
- if (a === 100 && b >= 64 && b <= 127)
165
- return true; // CGNAT
166
- if (a >= 224)
167
- return true; // multicast + reserved
168
- return false;
169
- }
170
- if (fam === 6) {
171
- const groups = expandIpv6(addr);
172
- // `net.isIP(addr) === 6` already guarantees `addr` is syntactically valid
173
- // IPv6 (Node validates this before classifying the family), and
174
- // `expandIpv6` implements the same canonicalization Node's own parser
175
- // does, so it never returns `null` for an `isIP`-accepted literal —
176
- // empirically re-verified this session by fuzzing ~10k `isIP`-accepted
177
- // v6 literals through `expandIpv6` with zero `null` results (see
178
- // `expandIpv6`'s doc comment and `source-map.md`'s 2026-07-22 entry).
179
- // Kept as a real fail-closed guard (defense-in-depth against a future
180
- // change to either parser) rather than asserted away, so it stays
181
- // provably-unreachable-but-intentional instead of a forced test.
182
- if (!groups)
183
- return true; // can't canonicalize a "valid" v6 → refuse
184
- // IPv4-mapped (`::ffff:0:0/96`) and the deprecated IPv4-compatible
185
- // (`::0:0/96`, excluding :: / ::1) carry an embedded IPv4 — classify it by
186
- // its real v4 range so `::ffff:7f00:1` (== 127.0.0.1) is caught.
187
- const topZero = groups.slice(0, 5).every((g) => g === 0);
188
- if (topZero && (groups[5] === 0xffff || groups[5] === 0)) {
189
- // `groups` is a validated 8-element array once non-null — indices 6-7
190
- // always defined.
191
- const a = groups[6] >> 8;
192
- const b = groups[6] & 0xff;
193
- const c = groups[7] >> 8;
194
- const d = groups[7] & 0xff;
195
- if (groups[5] === 0xffff || a !== 0 || b !== 0) {
196
- return isPrivateAddress(`${a}.${b}.${c}.${d}`);
197
- }
198
- }
199
- if (groups.every((g) => g === 0))
200
- return true; // :: (unspecified)
201
- if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1)
202
- return true; // ::1 loopback
203
- const first = groups[0];
204
- if ((first & 0xffc0) === 0xfe80)
205
- return true; // link-local fe80::/10 (fe80–febf)
206
- if ((first & 0xfe00) === 0xfc00)
207
- return true; // unique-local fc00::/7
208
- if ((first & 0xff00) === 0xff00)
209
- return true; // multicast ff00::/8
210
- return false;
211
- }
212
- return true; // not a literal IP — caller must resolve before trusting it
213
- }
214
79
  /** Cheap up-front rejection: enforce http(s), no embedded credentials, no
215
80
  * localhost, and no *literal* private IP. This is NOT the DNS-rebinding guard
216
81
  * (that is `createValidatingLookup`, applied at connection time) — it only
@@ -234,7 +99,7 @@ export function assertSafePublicUrl(raw) {
234
99
  if (lowerHost === 'localhost' || lowerHost.endsWith('.localhost')) {
235
100
  throw new AssetCacheError(400, 'localhost is not allowed');
236
101
  }
237
- if (isIP(host) && isPrivateAddress(host)) {
102
+ if (isIP(host) && isPrivateAddress({ address: host })) {
238
103
  throw new AssetCacheError(400, 'url points at a private address');
239
104
  }
240
105
  return url;
@@ -254,7 +119,7 @@ export function createValidatingLookup(lookupImpl = dnsLookupCb) {
254
119
  const list = Array.isArray(address) ? address : [{ address, family }];
255
120
  for (const entry of list) {
256
121
  const addr = typeof entry === 'string' ? entry : entry.address;
257
- if (isPrivateAddress(addr)) {
122
+ if (isPrivateAddress({ address: addr })) {
258
123
  return cb(new AssetCacheError(400, 'host resolves to a private address'));
259
124
  }
260
125
  }
package/dist/download.js CHANGED
@@ -130,7 +130,7 @@ function targetFromOptions(options) {
130
130
  // `path.resolve` never actually walked upward. Empirically verified this
131
131
  // session: a 300k-segment fuzz found ~0.2% of `normalizeSegment`-valid
132
132
  // segments (every one starting with literal "..") trip this exact guard —
133
- // see source-map.md's 2026-07-22 entry and this file's own test for a
133
+ // see archived provenance ledger's 2026-07-22 entry and this file's own test for a
134
134
  // concrete example (bucket "..evil").
135
135
  for (const path of [finalPath, manifestPath, partialPath, lockPath]) {
136
136
  if (!pathContains(basePath, path)) {
@@ -473,7 +473,7 @@ async function acquireLock(target) {
473
473
  // no trailing fallback throw is needed. Every path out of this loop body is
474
474
  // a `return` or a `throw`; V8 still instruments the loop's closing brace as
475
475
  // a branch (whether execution "falls off" the loop normally), but that
476
- // outcome is structurally impossible here — see source-map.md's
476
+ // outcome is structurally impossible here — see archived provenance ledger's
477
477
  // 2026-07-22 entry.
478
478
  for (let attempt = 0;; attempt += 1) {
479
479
  try {
@@ -0,0 +1,103 @@
1
+ import fs from "node:fs";
2
+ import * as fsPromises from "node:fs/promises";
3
+ import { type PlatformMessages } from "../messages.js";
4
+ /** Synchronous seam preserves the original fail-before-rename and permission contract.
5
+ * Descriptor ownership separates failed exclusive creates from failures after creating a temp. */
6
+ export interface AtomicFilesystemPort {
7
+ stat(required: {
8
+ path: string;
9
+ }): {
10
+ mode: number;
11
+ };
12
+ lstat(required: {
13
+ path: string;
14
+ }): {
15
+ mode: number;
16
+ isSymbolicLink(): boolean;
17
+ };
18
+ open(required: {
19
+ path: string;
20
+ flags: "wx" | "r";
21
+ mode?: number;
22
+ }): number;
23
+ write(required: {
24
+ descriptor: number;
25
+ content: string;
26
+ }): void;
27
+ sync(required: {
28
+ descriptor: number;
29
+ }): void;
30
+ close(required: {
31
+ descriptor: number;
32
+ }): void;
33
+ chmod(required: {
34
+ path: string;
35
+ mode: number;
36
+ }): void;
37
+ rename(required: {
38
+ from: string;
39
+ to: string;
40
+ }): void;
41
+ remove(required: {
42
+ path: string;
43
+ }): void;
44
+ }
45
+ export type AtomicNativeFilesystem = Pick<typeof fs, "statSync" | "lstatSync" | "openSync" | "writeFileSync" | "fsyncSync" | "closeSync" | "chmodSync" | "renameSync" | "rmSync">;
46
+ export type AtomicAsyncFilesystem = Pick<typeof fsPromises, "stat" | "lstat" | "open" | "chmod" | "rename" | "rm" | "mkdir">;
47
+ export interface AtomicWriteRequired {
48
+ filePath: string;
49
+ content: string;
50
+ fs: AtomicFilesystemPort;
51
+ }
52
+ export interface AtomicWriteOptions {
53
+ messages?: PlatformMessages;
54
+ /** Optional collision token; native pid + UUID restores the original default. */
55
+ tempName?: (required: {
56
+ filePath: string;
57
+ }) => string;
58
+ /** Explicit mode overrides preservation, e.g. secret-bearing files must use 0600. */
59
+ mode?: number;
60
+ /** Default for a missing destination; shared durable stores retain their former 0666 create mode. */
61
+ defaultMode?: number;
62
+ refuseSymlink?: boolean;
63
+ /** Verify owner-only permissions before rename on POSIX, as secret stores require. */
64
+ verifyOwnerOnly?: boolean;
65
+ platform?: NodeJS.Platform;
66
+ /** Same-directory legacy temp layout, for stores that expose their temp path publicly. */
67
+ tempPath?: (required: {
68
+ filePath: string;
69
+ }) => string;
70
+ }
71
+ /** Optional adapter wiring: no layout, env key, or application name is selected here. */
72
+ export declare function createNodeAtomicFilesystem(_required: Record<string, never>, { filesystem }?: {
73
+ filesystem?: AtomicNativeFilesystem;
74
+ }): AtomicFilesystemPort;
75
+ /** Same-directory rename; preserve existing mode exactly, default new files to 0600.
76
+ * Only ENOENT means absent. Stat/write/chmod failures propagate before any rename.
77
+ * Flush file bytes before rename so metadata cannot precede content; then flush the parent dir.
78
+ * A directory-sync failure is reported after replacement, because rename cannot be rolled back safely.
79
+ */
80
+ export declare function writeFileAtomic(required: AtomicWriteRequired, options?: AtomicWriteOptions): void;
81
+ /** Pretty JSON delegates to the same mode-preserving atomic primitive. */
82
+ export declare function writeJsonFileAtomic(required: Omit<AtomicWriteRequired, "content"> & {
83
+ data: unknown;
84
+ }, options?: AtomicWriteOptions): void;
85
+ /** Async counterpart with the same ownership, permission and durability guarantees.
86
+ * Parent creation supports runtime-state and secret stores without another writer implementation. */
87
+ export declare function writeFileAtomicAsync(required: {
88
+ filePath: string;
89
+ content: string;
90
+ }, options?: AtomicWriteOptions & {
91
+ fs?: AtomicAsyncFilesystem;
92
+ createParent?: boolean;
93
+ }): Promise<void>;
94
+ /** Pretty JSON can retain a state-store's trailing newline without changing its serialization. */
95
+ export declare function writeJsonFileAtomicAsync(required: {
96
+ filePath: string;
97
+ data: unknown;
98
+ }, options?: AtomicWriteOptions & {
99
+ fs?: AtomicAsyncFilesystem;
100
+ createParent?: boolean;
101
+ trailingNewline?: boolean;
102
+ }): Promise<void>;
103
+ //# sourceMappingURL=atomic-write.d.ts.map