haechi 1.4.0 → 1.5.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.
- package/README.md +3 -1
- package/docs/current/api-stability.ko.md +2 -2
- package/docs/current/api-stability.md +2 -2
- package/docs/current/config-version.ko.md +1 -1
- package/docs/current/config-version.md +2 -2
- package/docs/current/configuration.ko.md +1 -1
- package/docs/current/configuration.md +1 -1
- package/docs/current/operations-runbook.ko.md +1 -1
- package/docs/current/operations-runbook.md +1 -1
- package/docs/current/plugin-signing-and-trust.ko.md +1 -1
- package/docs/current/plugin-signing-and-trust.md +1 -1
- package/docs/current/release-process.ko.md +1 -1
- package/docs/current/release-process.md +1 -1
- package/docs/current/risk-register-release-gate.ko.md +6 -5
- package/docs/current/risk-register-release-gate.md +5 -4
- package/docs/current/shared-responsibility.ko.md +1 -1
- package/docs/current/shared-responsibility.md +1 -1
- package/docs/current/threat-model.ko.md +1 -1
- package/docs/current/threat-model.md +1 -1
- package/package.json +1 -1
- package/packages/audit/index.mjs +98 -32
- package/packages/token-vault/index.mjs +167 -56
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
[](https://github.com/raeseoklee/haechi/actions/workflows/ci.yml)
|
|
9
9
|
[](LICENSE)
|
|
10
10
|
[](https://nodejs.org)
|
|
11
|
-
[](docs/current/api-stability.md)
|
|
12
12
|
|
|
13
13
|
**English** | [한국어](README.ko.md)
|
|
14
14
|
|
|
@@ -379,3 +379,5 @@ Haechi is deliberately scoped. These are real, current limitations — listed op
|
|
|
379
379
|
1.3.1 → 1.3.3 are security-remediation and hardening **patches** (no API/config change). 1.3.1 and 1.3.2 close two external code-review rounds — proxy header-boundary credential leak, hex IPv4-mapped IPv6 SSRF, response-header/streaming bounds, and non-JSON streaming inspection (1.3.1); proxy upstream-reader cancel-on-disconnect, token-vault audit-log hygiene, and plugin IPC reply bounds (1.3.2). 1.3.3 tightens the response-direction marker skip (a model can't wrap a secret in a fake `[TOKEN:…]` to evade scanning) and adds the cosign-signed GHCR container image.
|
|
380
380
|
|
|
381
381
|
1.4.0 adds the first-party authoring CLI for the signed-plugin trust gate: `haechi plugin-keygen` (Ed25519 keypair — private key `0600`, public key is the trust anchor), `plugin-sign` (signs the exact entry-file bytes; `authProvider` must declare `readsCredentials`), and `plugin-verify` (runs the runtime's verification, fail-closed, with `--allow-capability`). A new [`plugin-signing-and-trust.md`](docs/current/plugin-signing-and-trust.md) runbook covers the keygen → sign → wire `auth.plugin.trustAnchors` → verify → rotate/pin/revoke lifecycle. Additive CLI surface (a **minor** under strict semver); no config change (`configVersion` stays `1`).
|
|
382
|
+
|
|
383
|
+
1.5.0 begins the **fleet-readiness** track: the audit sink and token vault gain an injectable **store seam** (`createAuditSink({ store })` / `createTokenVault({ store })` + the default `createFileAuditStore`/`createFileTokenStore`), so a shared store can back the sha256 audit hash chain and the token vault across **multiple replicas** — closing the per-process / single-writer limitations called out under [Known limitations](#known-limitations). The file-backed defaults are byte-identical, and `createJsonlAuditSink`/`createLocalTokenVault` stay as back-compat wrappers. A `haechi-store-redis` satellite (the Redis adapters for both seams) is the production consumer. Additive (a **minor**); `configVersion` stays `1`; core stays zero runtime dependency.
|
|
@@ -29,10 +29,10 @@
|
|
|
29
29
|
| `haechi/runtime` — `createRuntime`, `normalizeConfig` (config shape), `defaultConfig`, `loadConfig`, `writeDefaultConfig`, `isValidPort`, `DEFAULT_CONFIG_PATH` | **FROZEN** |
|
|
30
30
|
| `haechi/auth` — `authProvider` 계약, `buildIdentity`, `buildExternalIdentity`, `validateLabels`, `createBearerAuthProvider`, token store (`readAuthStore`, `addToken`, `listTokens`, `revokeToken`), `DEFAULT_ALLOWED_LABEL_KEYS` | **FROZEN** |
|
|
31
31
|
| `haechi/crypto` — `cryptoProvider` 계약, `assertCryptoProviderConformance`, `canonicalize`, `createLocalCryptoProvider`, `initLocalKeyFile` | **FROZEN** |
|
|
32
|
-
| `haechi/audit` — audit **event schema** (§2.3), `verifyAuditChain`, `sanitizeAudit`, `createJsonlAuditSink`, `readAuditSummary`, `FORBIDDEN_KEYS` | **FROZEN** |
|
|
32
|
+
| `haechi/audit` — audit **event schema** (§2.3), `verifyAuditChain`, `sanitizeAudit`, `createJsonlAuditSink`, `readAuditSummary`, `FORBIDDEN_KEYS`, 그리고 1.5.0 주입 가능한 store 시임 `createAuditSink`, `createFileAuditStore`, `buildIntegrityRecord` | **FROZEN** |
|
|
33
33
|
| `haechi/policy` — `buildPolicy`, `createPolicyEngine`, `createPolicyProfiles`, `validatePolicy`, `ACTION_STRENGTH` (action ordering) | **FROZEN** |
|
|
34
34
|
| `haechi/filter` — `createDefaultFilterEngine`, `detectEntry`, 그리고 **rule/detection shape** | **FROZEN** |
|
|
35
|
-
| `haechi/token-vault` — `createLocalTokenVault`, `readVault`, token format, reveal-governance
|
|
35
|
+
| `haechi/token-vault` — `createLocalTokenVault`, `readVault`, token format, reveal-governance 계약, 그리고 1.5.0 주입 가능한 store 시임 `createTokenVault`, `createFileTokenStore` | **FROZEN** |
|
|
36
36
|
| `haechi/protocol-adapters` — `createProtocolAdapter`, `knownProtocolAdapters`, adapter classification 계약 | **FROZEN** |
|
|
37
37
|
| `haechi/plugin` — `validatePluginManifest`, `validatePluginManifestFile`, manifest schema, 1.0 signed-plugin sandbox 표면 | **FROZEN** |
|
|
38
38
|
| `haechi/proxy` — `createHaechiProxy`, `assertSafeProxyBind`, `DEFAULT_PROXY_PORT` | **FROZEN BEHAVIOR + wire/contract** (사람이 읽는 log/error **텍스트**는 변경 가능) |
|
|
@@ -29,10 +29,10 @@ Every `package.json` `exports` subpath and the CLI is classed. There is no silen
|
|
|
29
29
|
| `haechi/runtime` — `createRuntime`, `normalizeConfig` (config shape), `defaultConfig`, `loadConfig`, `writeDefaultConfig`, `isValidPort`, `DEFAULT_CONFIG_PATH` | **FROZEN** |
|
|
30
30
|
| `haechi/auth` — the `authProvider` contract, `buildIdentity`, `buildExternalIdentity`, `validateLabels`, `createBearerAuthProvider`, the token store (`readAuthStore`, `addToken`, `listTokens`, `revokeToken`), `DEFAULT_ALLOWED_LABEL_KEYS` | **FROZEN** |
|
|
31
31
|
| `haechi/crypto` — the `cryptoProvider` contract, `assertCryptoProviderConformance`, `canonicalize`, `createLocalCryptoProvider`, `initLocalKeyFile` | **FROZEN** |
|
|
32
|
-
| `haechi/audit` — the audit **event schema** (§2.3), `verifyAuditChain`, `sanitizeAudit`, `createJsonlAuditSink`, `readAuditSummary`, `FORBIDDEN_KEYS` | **FROZEN** |
|
|
32
|
+
| `haechi/audit` — the audit **event schema** (§2.3), `verifyAuditChain`, `sanitizeAudit`, `createJsonlAuditSink`, `readAuditSummary`, `FORBIDDEN_KEYS`, plus the 1.5.0 injectable store seam `createAuditSink`, `createFileAuditStore`, `buildIntegrityRecord` | **FROZEN** |
|
|
33
33
|
| `haechi/policy` — `buildPolicy`, `createPolicyEngine`, `createPolicyProfiles`, `validatePolicy`, `ACTION_STRENGTH` (action ordering) | **FROZEN** |
|
|
34
34
|
| `haechi/filter` — `createDefaultFilterEngine`, `detectEntry`, and the **rule/detection shape** | **FROZEN** |
|
|
35
|
-
| `haechi/token-vault` — `createLocalTokenVault`, `readVault`, the token format, and the reveal-governance contract | **FROZEN** |
|
|
35
|
+
| `haechi/token-vault` — `createLocalTokenVault`, `readVault`, the token format, and the reveal-governance contract, plus the 1.5.0 injectable store seam `createTokenVault`, `createFileTokenStore` | **FROZEN** |
|
|
36
36
|
| `haechi/protocol-adapters` — `createProtocolAdapter`, `knownProtocolAdapters`, and the adapter classification contract | **FROZEN** |
|
|
37
37
|
| `haechi/plugin` — `validatePluginManifest`, `validatePluginManifestFile`, the manifest schema, and the 1.0 signed-plugin sandbox surface | **FROZEN** |
|
|
38
38
|
| `haechi/proxy` — `createHaechiProxy`, `assertSafeProxyBind`, `DEFAULT_PROXY_PORT` | **FROZEN BEHAVIOR + wire/contract** (human-readable log/error **text** may change) |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Haechi `configVersion` & 업그레이드 노트
|
|
2
2
|
|
|
3
|
-
- 상태: Living document (코어 1.
|
|
3
|
+
- 상태: Living document (코어 1.5.x 추적)
|
|
4
4
|
|
|
5
5
|
`configVersion`는 `haechi.config.json`(및 `haechi.config.example.json`) 최상위에 찍히는 단일 정수입니다. 향후 호환성을 깨는 설정 스키마 변경이 구체적으로 게이트할 수 있는 **버전 앵커**로서, 다른 Haechi 빌드가 쓴 설정을 조용히 잘못 읽는 일을 막습니다.
|
|
6
6
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Haechi `configVersion` & Upgrade Notes
|
|
2
2
|
|
|
3
|
-
- Status: Living document (tracks core 1.
|
|
3
|
+
- Status: Living document (tracks core 1.5.x)
|
|
4
4
|
|
|
5
5
|
`configVersion` is a single integer stamped at the top of `haechi.config.json`
|
|
6
6
|
(and `haechi.config.example.json`). It is a **versioned anchor** so a future
|
|
@@ -34,7 +34,7 @@ the "policies only get stronger / fail closed" invariant intact.
|
|
|
34
34
|
|
|
35
35
|
| `configVersion` | Core line | Notes |
|
|
36
36
|
|---|---|---|
|
|
37
|
-
| `1` | 1.0 – 1.4.x | Initial stamp. All keys are additive over the 1.0 frozen config surface (`api-stability.md` §2.4). The 1.1.x additive keys (`logging`, `metrics`, the WS4-B `limits.maxInFlight` / `limits.shutdownGraceMs` / `limits.requestTimeoutMs` / `limits.headersTimeoutMs`, `configVersion` itself) and the 1.2.0 Reliability-Hardening keys (`filters.minConfidence` / `filters.allowlist`, `proxy.tls` / `proxy.trustForwardedProto`) all default to prior behavior. The 1.3.0 additions are new *values*, not new keys — `target.type` `anthropic`/`gemini`, additional detection types, and the `asia-pdpa`/`jp-appi` `privacy.profile` values. The 1.4.0 plugin-signing CLI (`plugin-keygen`/`plugin-sign`/`plugin-verify`) is **CLI surface, not config** — it adds no config keys. So the config schema (and `configVersion`) is unchanged. No migration needed. |
|
|
37
|
+
| `1` | 1.0 – 1.4.x | Initial stamp. All keys are additive over the 1.0 frozen config surface (`api-stability.md` §2.4). The 1.1.x additive keys (`logging`, `metrics`, the WS4-B `limits.maxInFlight` / `limits.shutdownGraceMs` / `limits.requestTimeoutMs` / `limits.headersTimeoutMs`, `configVersion` itself) and the 1.2.0 Reliability-Hardening keys (`filters.minConfidence` / `filters.allowlist`, `proxy.tls` / `proxy.trustForwardedProto`) all default to prior behavior. The 1.3.0 additions are new *values*, not new keys — `target.type` `anthropic`/`gemini`, additional detection types, and the `asia-pdpa`/`jp-appi` `privacy.profile` values. The 1.4.0 plugin-signing CLI (`plugin-keygen`/`plugin-sign`/`plugin-verify`) is **CLI surface, not config** — it adds no config keys. The 1.5.0 store seams (`createAuditSink`/`createTokenVault` + the file-store defaults) are an **injected-provider** surface (wired via `createRuntime(config, providers)`), not config keys. So the config schema (and `configVersion`) is unchanged. No migration needed. |
|
|
38
38
|
|
|
39
39
|
## Upgrading
|
|
40
40
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Haechi 설정 레퍼런스
|
|
2
2
|
|
|
3
|
-
- 문서 상태: Living document(core 1.
|
|
3
|
+
- 문서 상태: Living document(core 1.5.x 추적)
|
|
4
4
|
|
|
5
5
|
`haechi init`은 `haechi.config.json`을 생성하며, 비밀 정보를 포함하지 않는 템플릿은 `haechi.config.example.json`에 있습니다. 모든 커맨드는 `--config <path>`로 설정 파일을 읽습니다(기본값: `haechi.config.json`). 설정은 **fail-closed 방식으로 검증**됩니다. 알 수 없는 provider, 범위를 벗어난 숫자, 잘못된 형식의 값은 자동으로 무시되지 않고 로드 시점에 오류를 발생시킵니다. `haechi config`는 이 레퍼런스를 출력하며, `haechi status`는 특정 설정 파일의 *실제 적용* 상태를 출력합니다.
|
|
6
6
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Haechi Configuration Reference
|
|
2
2
|
|
|
3
|
-
- Status: Living document (tracks core 1.
|
|
3
|
+
- Status: Living document (tracks core 1.5.x)
|
|
4
4
|
|
|
5
5
|
`haechi init` writes `haechi.config.json`; a non-secret template is at `haechi.config.example.json`. Every command reads it with `--config <path>` (default `haechi.config.json`). Configuration is **validated fail-closed**: unknown providers, out-of-range numbers, and malformed values throw at load time rather than degrading silently. `haechi config` prints this reference; `haechi status` prints the *effective* state of a given config.
|
|
6
6
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Haechi Operations Runbook (Day-2)
|
|
2
2
|
|
|
3
|
-
- Status: Living document (tracks core 1.
|
|
3
|
+
- Status: Living document (tracks core 1.5.x)
|
|
4
4
|
|
|
5
5
|
A practical guide to running Haechi in production: deploy, configure via the
|
|
6
6
|
env-var overlay, monitor with health/readiness/metrics, shut down gracefully,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Haechi 리스크 레지스터 및 릴리스 게이트
|
|
2
2
|
|
|
3
|
-
- 문서 상태: Living document(core 1.
|
|
3
|
+
- 문서 상태: Living document(core 1.5.x 추적)
|
|
4
4
|
- 작성일: 2026-06-16
|
|
5
|
-
- 기준 버전: 1.
|
|
5
|
+
- 기준 버전: 1.5.x
|
|
6
6
|
- 기준 브랜치: `main`
|
|
7
7
|
|
|
8
8
|
## 1. 현재 판단
|
|
@@ -14,9 +14,9 @@ Haechi는 `1.x` stable 라인을 출시했습니다. developer preview 게이트
|
|
|
14
14
|
| 구분 | 판단 | 이유 |
|
|
15
15
|
|---|---|---|
|
|
16
16
|
| GitHub public | 허용 | 보안 한계, threat model, shared responsibility가 문서화됨 |
|
|
17
|
-
| GitHub release/tag | 허용 (`v1.
|
|
18
|
-
| npm stable | `haechi@1.
|
|
19
|
-
| production use | 운영자 게이트; `1.
|
|
17
|
+
| GitHub release/tag | 허용 (`v1.5.0` 릴리스됨) | `v1.5.0`이 현재 릴리스(additive minor — 수평 확장을 위한 주입 가능한 audit/token-vault 저장소 시임); §5.7 / §5.8 항목은 모두 Resolved 유지, G9–G12는 Pass |
|
|
18
|
+
| npm stable | `haechi@1.5.0` publish됨 | `1.5.0`은 `1.4.x` 기준 위에 `createAuditSink`/`createTokenVault` 저장소 시임(파일 기본값 바이트 동일)을 더한 attested OIDC publish; config/API 파괴 없음(`configVersion`은 `1` 유지) |
|
|
19
|
+
| production use | 운영자 게이트; `1.5.0`로 업그레이드 | 운영자 네트워크 통제, 인가/인증, key custody가 있을 때만 지원; 여러 replica를 운영하는 운영자는 공유 저장소(`haechi-store-redis` 위성)를 주입해 audit 해시 체인과 token vault가 플릿 전체에서 유지되도록 해야 함 |
|
|
20
20
|
|
|
21
21
|
## 2. 릴리스 게이트
|
|
22
22
|
|
|
@@ -34,6 +34,7 @@ Haechi는 `1.x` stable 라인을 출시했습니다. developer preview 게이트
|
|
|
34
34
|
| G9 | 2026-06-16 전체 코드리뷰 보완 게이트 (1.3.1로 발행) | `P0-CR-001` 및 `P1-CR-002`부터 `P1-CR-005`까지 해결 또는 책임자 명시 수용; P2 항목은 해결 또는 명시적 non-blocking 근거와 일정 기록; 연결된 등록부 갱신. **13개 `P*-CR-*` 항목이 모두 Resolved이며(§5.7) `haechi@1.3.1`(2026-06-16, attested OIDC publish)로 발행되었습니다; core가 1.3.0 → 1.3.1로 bump(patch, 보완 전용 — API/config 표면 변경 없음, `configVersion`은 `1` 유지)되었습니다.** | Pass (`haechi@1.3.1`, 2026-06-16) |
|
|
35
35
|
| G10 | 2026-06-16 코드리뷰 round 2 (CR2) 보완 게이트 | CR2 등록부(`code-review-risk-register-2026-06-16-round2.md`, §5.8)는 **P0/P1을 발견하지 못했습니다**; 세 개의 P2(`CR2-001` 프록시 upstream-cancel, `CR2-002` token-vault audit hygiene, `CR2-003` plugin IPC reply 경계)와 P3 묶음(`CR2-004..008`)이 모두 **Resolved이며 `haechi@1.3.2`로 발행되었고**(`CR2-009` won't-fix, `CR2-010` accepted) 연결된 등록부가 갱신되었습니다. | Pass (`haechi@1.3.2`, 2026-06-16) |
|
|
36
36
|
| G11 | 1.4.0 signed-plugin 저작 CLI | 1.0 Ed25519 trust gate를 위한 1차 저작 CLI — `plugin-keygen`(개인키 `0600`, 공개키 = trust anchor), `plugin-sign`(정확한 entry 바이트 바인딩), `plugin-verify`(런타임 동등 검증, fail-closed, `--allow-capability`); 개인키가 stdout/audit로 유출되지 않음; 적대적 검증 완료; `plugin-signing-and-trust.md` 큐레이션 런북이 P1-SEC-025 "운영자가 앵커를 큐레이션해야 함" 잔여를 해소. additive CLI 표면(config/API 파괴 없음, `configVersion`은 `1` 유지); `tests/api-contract.test.mjs` green; 코어는 zero runtime dependency 유지; 코어 1.3.3 → 1.4.0(additive minor)로 bump. | Pass (`haechi@1.4.0`, 2026-06-17) |
|
|
37
|
+
| G12 | 1.5.0 수평 확장 저장소 시임 | audit sink와 token vault가 주입 가능한 **store**를 갖게 되어, 공유 저장소가 sha256 해시 체인 + token vault를 replica 전반에서 뒷받침할 수 있음(프로세스별 / 단일 writer 플릿 한계를 해소). `createAuditSink({store})` / `createTokenVault({store})` + 기본 `createFileAuditStore`/`createFileTokenStore`; 보안에 결정적인 chaining / `sanitizeAudit` / reveal governance / retention은 코어에 남고, store는 배타적 read-previous+persist(audit) / mutate+read(vault) 프리미티브만 추상화. 적대적 검증 완료: 파일 기본값 바이트 동일, chain 연산은 이전과 diff 동일, 비파일 store에서도 시임 동작, 동시 append/tokenize가 비분기·무손실 유지, CR2-002 audit-no-plaintext 유지. 새 export는 `api-stability.md` + `tests/api-contract.test.mjs`에 frozen; `createJsonlAuditSink`/`createLocalTokenVault`는 하위호환 래퍼; 코어는 zero runtime dependency 유지; 코어 1.4.0 → 1.5.0(additive minor)로 bump. `haechi-store-redis` 위성이 운영 소비자. | Pass (`haechi@1.5.0`, 2026-06-17) |
|
|
37
38
|
|
|
38
39
|
## 3. P0 배포 차단 리스크 상태
|
|
39
40
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Haechi Risk Register and Release Gates
|
|
2
2
|
|
|
3
|
-
- Status: Living document (tracks core 1.
|
|
3
|
+
- Status: Living document (tracks core 1.5.x)
|
|
4
4
|
- Date: 2026-06-16
|
|
5
5
|
- Target version: 1.3.x
|
|
6
6
|
- Branch: `main`
|
|
@@ -14,9 +14,9 @@ Haechi has shipped its `1.x` stable line. The developer-preview gate (G2, `haech
|
|
|
14
14
|
| Category | Judgment | Rationale |
|
|
15
15
|
|---|---|---|
|
|
16
16
|
| GitHub public | Allowed | Security limitations, threat model, and shared responsibility are documented |
|
|
17
|
-
| GitHub release/tag | Allowed (`v1.
|
|
18
|
-
| npm stable | `haechi@1.
|
|
19
|
-
| Production use | Operator-gated; upgrade to `1.
|
|
17
|
+
| GitHub release/tag | Allowed (`v1.5.0` released) | `v1.5.0` is the current release (additive minor — injectable audit/token-vault store seams for horizontal scale); all §5.7 / §5.8 findings remain Resolved and G9–G12 are Pass |
|
|
18
|
+
| npm stable | `haechi@1.5.0` published | `1.5.0` is an attested OIDC publish adding `createAuditSink`/`createTokenVault` store seams (file defaults byte-identical) over the `1.4.x` baseline; no config/API break (`configVersion` stays `1`) |
|
|
19
|
+
| Production use | Operator-gated; upgrade to `1.5.0` | Supported only with operator network controls, authz/authn, and key custody; operators running multiple replicas should inject a shared store (the `haechi-store-redis` satellite) so the audit hash chain and token vault hold across the fleet |
|
|
20
20
|
|
|
21
21
|
## 2. Release Gates
|
|
22
22
|
|
|
@@ -34,6 +34,7 @@ Haechi has shipped its `1.x` stable line. The developer-preview gate (G2, `haech
|
|
|
34
34
|
| G9 | 2026-06-16 full code-review remediation gate (shipped in 1.3.1) | `P0-CR-001` and `P1-CR-002` through `P1-CR-005` resolved or formally accepted; P2 items either resolved or scheduled with explicit non-blocking rationale; linked register updated. **All 13 `P*-CR-*` findings are Resolved (§5.7) and shipped in `haechi@1.3.1` (2026-06-16, attested OIDC publish); core bumped 1.3.0 → 1.3.1 (patch, remediation-only — no API/config surface change, `configVersion` stays `1`).** | Pass (`haechi@1.3.1`, 2026-06-16) |
|
|
35
35
|
| G10 | 2026-06-16 code-review round 2 (CR2) remediation gate | The CR2 register (`code-review-risk-register-2026-06-16-round2.md`, §5.8) found **no P0/P1**; its three P2s (`CR2-001` proxy upstream-cancel, `CR2-002` token-vault audit hygiene, `CR2-003` plugin IPC reply bound) plus the P3 cluster (`CR2-004..008`) are all **Resolved and shipped in `haechi@1.3.2`** (`CR2-009` won't-fix, `CR2-010` accepted) and the linked register is updated. | Pass (`haechi@1.3.2`, 2026-06-16) |
|
|
36
36
|
| G11 | 1.4.0 signed-plugin authoring CLI | First-party CLI for the 1.0 Ed25519 trust gate — `plugin-keygen` (private key `0600`, public key = trust anchor), `plugin-sign` (binds the exact entry bytes), `plugin-verify` (runtime-equivalent verification, fail-closed, `--allow-capability`); no private-key leak to stdout/audit; adversarially verified; the `plugin-signing-and-trust.md` curation runbook closes the P1-SEC-025 "operator must curate anchors" residual. Additive CLI surface (no config/API break, `configVersion` stays `1`); `tests/api-contract.test.mjs` green; core stays zero runtime dependency; core bumped 1.3.3 → 1.4.0 (additive minor). | Pass (`haechi@1.4.0`, 2026-06-17) |
|
|
37
|
+
| G12 | 1.5.0 horizontal-scale store seams | The audit sink and token vault gain an injectable **store** so a shared store can back the sha256 hash chain + token vault across replicas (closes the per-process / single-writer fleet limitations). `createAuditSink({store})` / `createTokenVault({store})` + the default `createFileAuditStore`/`createFileTokenStore`; the security-critical chaining / `sanitizeAudit` / reveal governance / retention stay in core, the store only abstracts the exclusive read-previous+persist (audit) / mutate+read (vault) primitives. Adversarially verified: file defaults byte-identical, chain math diff-identical to prior, the seam works for a non-file store, concurrent appends/tokenize stay non-forked/lossless, CR2-002 audit-no-plaintext intact. New exports frozen in `api-stability.md` + `tests/api-contract.test.mjs`; `createJsonlAuditSink`/`createLocalTokenVault` are back-compat wrappers; core stays zero runtime dependency; core bumped 1.4.0 → 1.5.0 (additive minor). The `haechi-store-redis` satellite is the production consumer. | Pass (`haechi@1.5.0`, 2026-06-17) |
|
|
37
38
|
|
|
38
39
|
## 3. P0 Distribution-Blocking Risk Status
|
|
39
40
|
|
package/package.json
CHANGED
package/packages/audit/index.mjs
CHANGED
|
@@ -30,9 +30,73 @@ const FORBIDDEN_KEYS = new Set([
|
|
|
30
30
|
"scopes", "labels"
|
|
31
31
|
]);
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
// An audit STORE abstracts the exclusive "read-previous + persist" primitive so
|
|
34
|
+
// the SAME core-owned sha256 hash chain can sit on top of a file today and a
|
|
35
|
+
// shared store (e.g. Redis) in a future satellite. The contract is:
|
|
36
|
+
//
|
|
37
|
+
// async transaction(fn) — runs `fn` inside an EXCLUSIVE critical section that
|
|
38
|
+
// serializes concurrent appends. `fn` receives { readLastIntegrity, persist }
|
|
39
|
+
// where readLastIntegrity() -> the last record's auditIntegrity (or null) and
|
|
40
|
+
// persist(record) durably appends the built record. transaction() returns
|
|
41
|
+
// fn's return value.
|
|
42
|
+
// async ready() — OPTIONAL health/writability probe returning { ok, reason? };
|
|
43
|
+
// the sink falls back to { ok: true } when the store omits it.
|
|
44
|
+
//
|
|
45
|
+
// The store deliberately knows NOTHING about anchoring, sanitization, or the
|
|
46
|
+
// chain math — those stay core-owned in createAuditSink so a non-core store can
|
|
47
|
+
// never fork or weaken the chain.
|
|
48
|
+
|
|
49
|
+
// createFileAuditStore implements the store contract over the CURRENT JSONL
|
|
50
|
+
// mechanism: a `${path}.lock` exclusive section wrapping mkdir + the critical
|
|
51
|
+
// section, a tail-read for the previous integrity, and an appendFile persist.
|
|
52
|
+
// The on-disk bytes are identical to the pre-seam sink.
|
|
53
|
+
export function createFileAuditStore({ path }) {
|
|
34
54
|
if (!path) {
|
|
35
|
-
throw new Error("
|
|
55
|
+
throw new Error("file audit store requires path");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
async transaction(fn) {
|
|
60
|
+
await mkdir(dirname(path), { recursive: true });
|
|
61
|
+
return withFileLock(`${path}.lock`, () => fn({
|
|
62
|
+
readLastIntegrity: () => readLastIntegrity(path),
|
|
63
|
+
persist: (record) => appendFile(path, `${JSON.stringify(record)}\n`, "utf8")
|
|
64
|
+
}));
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
// WS4-A readiness probe: a CHEAP writability check used by /__haechi/ready.
|
|
68
|
+
// A security gateway that cannot append to its audit log is NOT ready
|
|
69
|
+
// (fail-closed), so this confirms the audit directory exists and is writable
|
|
70
|
+
// WITHOUT writing an event (no audit-chain side effect). It returns the bare
|
|
71
|
+
// boolean and an enum reason — never a path value or any payload/PII.
|
|
72
|
+
async ready() {
|
|
73
|
+
try {
|
|
74
|
+
const dir = dirname(path);
|
|
75
|
+
await mkdir(dir, { recursive: true });
|
|
76
|
+
await access(dir, fsConstants.W_OK);
|
|
77
|
+
// If the audit file already exists, confirm it is writable too.
|
|
78
|
+
try {
|
|
79
|
+
await access(path, fsConstants.W_OK);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error.code !== "ENOENT") {
|
|
82
|
+
return { ok: false, reason: "audit_file_not_writable" };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { ok: true };
|
|
86
|
+
} catch {
|
|
87
|
+
return { ok: false, reason: "audit_dir_not_writable" };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// createAuditSink holds the SECURITY-CRITICAL, core-owned logic: writeQueue
|
|
94
|
+
// serialization, sanitizeAudit, the sha256 chain build, the anchor stream, and
|
|
95
|
+
// the capabilities object. The store only supplies the exclusive
|
|
96
|
+
// read-previous + persist primitive; anchor config never leaks into it.
|
|
97
|
+
export function createAuditSink({ store, anchor = null }) {
|
|
98
|
+
if (!store || typeof store.transaction !== "function") {
|
|
99
|
+
throw new Error("audit sink requires a store with a transaction(fn) method");
|
|
36
100
|
}
|
|
37
101
|
const anchorMode = anchor?.mode ?? "none";
|
|
38
102
|
const anchorPath = anchor?.path ?? null;
|
|
@@ -81,44 +145,41 @@ export function createJsonlAuditSink({ path, anchor = null }) {
|
|
|
81
145
|
integrity: anchorMode === "none" ? "sha256-hash-chain" : "sha256-hash-chain+anchor"
|
|
82
146
|
},
|
|
83
147
|
async record(event) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
148
|
+
// The writeQueue serializes record() calls on this sink, and the store's
|
|
149
|
+
// transaction() adds the exclusive critical section; together they keep
|
|
150
|
+
// the chain strictly sequential and never forked under concurrency.
|
|
151
|
+
const write = writeQueue.then(() => store.transaction(async ({ readLastIntegrity, persist }) => {
|
|
152
|
+
const record = buildIntegrityRecord(await readLastIntegrity(), sanitizeAudit(event));
|
|
153
|
+
await persist(record);
|
|
154
|
+
await writeAnchor(record);
|
|
155
|
+
return record;
|
|
156
|
+
}));
|
|
92
157
|
writeQueue = write.catch(() => {});
|
|
93
158
|
await write;
|
|
94
159
|
},
|
|
95
160
|
|
|
96
|
-
// WS4-A readiness probe: a CHEAP writability check used by /__haechi/ready.
|
|
97
|
-
// A security gateway that cannot append to its audit log is NOT ready
|
|
98
|
-
// (fail-closed), so this confirms the audit directory exists and is writable
|
|
99
|
-
// WITHOUT writing an event (no audit-chain side effect). It returns the bare
|
|
100
|
-
// boolean and an enum reason — never a path value or any payload/PII.
|
|
101
161
|
async ready() {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
// If the audit file already exists, confirm it is writable too.
|
|
107
|
-
try {
|
|
108
|
-
await access(path, fsConstants.W_OK);
|
|
109
|
-
} catch (error) {
|
|
110
|
-
if (error.code !== "ENOENT") {
|
|
111
|
-
return { ok: false, reason: "audit_file_not_writable" };
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
return { ok: true };
|
|
115
|
-
} catch {
|
|
116
|
-
return { ok: false, reason: "audit_dir_not_writable" };
|
|
162
|
+
// Delegate to the store's writability probe; a store that omits it is
|
|
163
|
+
// treated as ready (the chain math has no readiness side effect of its own).
|
|
164
|
+
if (typeof store.ready === "function") {
|
|
165
|
+
return store.ready();
|
|
117
166
|
}
|
|
167
|
+
return { ok: true };
|
|
118
168
|
}
|
|
119
169
|
};
|
|
120
170
|
}
|
|
121
171
|
|
|
172
|
+
// Thin back-compat wrapper: the original file-backed sink is now createAuditSink
|
|
173
|
+
// over createFileAuditStore. Its returned shape (id, version, capabilities,
|
|
174
|
+
// record, ready) and on-disk bytes are unchanged, so existing call sites
|
|
175
|
+
// (runtime.mjs injection, tests) keep working untouched.
|
|
176
|
+
export function createJsonlAuditSink({ path, anchor = null }) {
|
|
177
|
+
if (!path) {
|
|
178
|
+
throw new Error("JSONL audit sink requires path");
|
|
179
|
+
}
|
|
180
|
+
return createAuditSink({ store: createFileAuditStore({ path }), anchor });
|
|
181
|
+
}
|
|
182
|
+
|
|
122
183
|
export async function readAuditSummary(path) {
|
|
123
184
|
const summary = {
|
|
124
185
|
events: 0,
|
|
@@ -272,8 +333,13 @@ async function readAnchors(anchorPath) {
|
|
|
272
333
|
return { bySequence, lastSequence };
|
|
273
334
|
}
|
|
274
335
|
|
|
275
|
-
|
|
276
|
-
|
|
336
|
+
// PURE chain math: given the previous record's auditIntegrity (or null) and a
|
|
337
|
+
// sanitized event, deterministically computes the next chained record. No fs,
|
|
338
|
+
// no IO — the store supplies `previousIntegrity` (via its read-previous
|
|
339
|
+
// primitive) so the SAME computation backs a file or a shared store. Exported
|
|
340
|
+
// for store/satellite tests.
|
|
341
|
+
export function buildIntegrityRecord(previousIntegrity, event) {
|
|
342
|
+
const previous = previousIntegrity ?? null;
|
|
277
343
|
const sequence = previous ? previous.sequence + 1 : 1;
|
|
278
344
|
const unsigned = {
|
|
279
345
|
...event,
|
|
@@ -11,8 +11,89 @@ const AUDIT_ID_DOMAIN = "haechi:token-vault:audit-id:v1";
|
|
|
11
11
|
// this shape is treated as a misused raw value and never written verbatim.
|
|
12
12
|
const VAULT_TOKEN_SHAPE = /^tok_[a-z0-9_]+_[a-f0-9]{16,}$/;
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
// A token STORE abstracts the token-record map + the exclusive mutation section
|
|
15
|
+
// so the SAME core-owned tokenization can sit on a whole-file vault today and a
|
|
16
|
+
// shared store (e.g. Redis) in a future satellite — the current whole-file
|
|
17
|
+
// rewrite is not safe with multiple writers, so a shared store needs its own
|
|
18
|
+
// exclusive critical section. The contract is:
|
|
19
|
+
//
|
|
20
|
+
// async mutate(fn) — runs `fn` inside an EXCLUSIVE critical section that
|
|
21
|
+
// serializes concurrent mutations. `fn` receives a MUTABLE view
|
|
22
|
+
// { get(token), set(token, record), delete(token), entries() } over the
|
|
23
|
+
// token-record map, and the store persists the changes ATOMICALLY when `fn`
|
|
24
|
+
// resolves. mutate() returns `fn`'s return value. This is the
|
|
25
|
+
// multi-writer-safety primitive.
|
|
26
|
+
// async read(fn) — read-only access. `fn` receives { get(token), entries() }
|
|
27
|
+
// over a FRESH snapshot (no lock, matching how reveal/detokenize/export read
|
|
28
|
+
// today). read() returns `fn`'s value.
|
|
29
|
+
//
|
|
30
|
+
// The store deliberately knows NOTHING about crypto, reveal governance,
|
|
31
|
+
// retention, or audit — those stay core-owned in createTokenVault so a non-core
|
|
32
|
+
// store can never fork or weaken them. Prune-on-mutation is also core-owned: the
|
|
33
|
+
// core deletes expired entries from the view before each operation, so the file
|
|
34
|
+
// store persists the pruning on the trailing writeVault (no store cooperation
|
|
35
|
+
// needed) and the in-memory store sees the same deletions.
|
|
36
|
+
|
|
37
|
+
// createFileTokenStore implements the store contract over the CURRENT vault
|
|
38
|
+
// mechanism: a `${path}.lock` exclusive section wrapping mkdir + readVault +
|
|
39
|
+
// writeVault, with the view operating on vault.tokens in memory. The on-disk
|
|
40
|
+
// vault JSON format (version/createdAt/tokens, 2-space, trailing newline,
|
|
41
|
+
// temp+rename, 0600) stays byte-identical to the pre-seam vault.
|
|
42
|
+
export function createFileTokenStore({ path }) {
|
|
43
|
+
if (!path) {
|
|
44
|
+
throw new Error("file token store requires path");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
async mutate(fn) {
|
|
49
|
+
await mkdir(dirname(path), { recursive: true });
|
|
50
|
+
return withFileLock(`${path}.lock`, async () => {
|
|
51
|
+
const vault = await readVault(path);
|
|
52
|
+
const result = await fn(mutableView(vault.tokens));
|
|
53
|
+
await writeVault(path, vault);
|
|
54
|
+
return result;
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
async read(fn) {
|
|
59
|
+
const vault = await readVault(path);
|
|
60
|
+
return fn(readView(vault.tokens));
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A mutable view over a token-record map (the file store backs this with
|
|
66
|
+
// vault.tokens; the in-memory store with a Map). get/set/delete operate on the
|
|
67
|
+
// live map so the store persists whatever the mutation left behind.
|
|
68
|
+
function mutableView(tokens) {
|
|
69
|
+
return {
|
|
70
|
+
get: (token) => tokens[token],
|
|
71
|
+
set: (token, record) => {
|
|
72
|
+
tokens[token] = record;
|
|
73
|
+
},
|
|
74
|
+
delete: (token) => {
|
|
75
|
+
delete tokens[token];
|
|
76
|
+
},
|
|
77
|
+
entries: () => Object.entries(tokens)
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function readView(tokens) {
|
|
82
|
+
return {
|
|
83
|
+
get: (token) => tokens[token],
|
|
84
|
+
entries: () => Object.entries(tokens)
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// createTokenVault holds ALL the SECURITY-CRITICAL, core-owned logic:
|
|
89
|
+
// mutationQueue serialization (cross-call), deterministic-vs-random token id
|
|
90
|
+
// derivation, encrypt/decrypt, reveal governance (revealPolicy gate +
|
|
91
|
+
// reasonCodes + safeAuditToken + recordVaultEvent), retention
|
|
92
|
+
// (expiresAt/prune-on-mutation), detokenize, purge/purgeExpired,
|
|
93
|
+
// exportMetadata, and capabilities. The store only supplies the exclusive
|
|
94
|
+
// mutate/read primitive over the token-record map.
|
|
95
|
+
export function createTokenVault({
|
|
96
|
+
store,
|
|
16
97
|
cryptoProvider,
|
|
17
98
|
revealPolicy = "disabled",
|
|
18
99
|
retentionDays = 30,
|
|
@@ -20,8 +101,8 @@ export function createLocalTokenVault({
|
|
|
20
101
|
deterministic = false,
|
|
21
102
|
deterministicTypes = null
|
|
22
103
|
}) {
|
|
23
|
-
if (!
|
|
24
|
-
throw new Error("
|
|
104
|
+
if (!store || typeof store.mutate !== "function" || typeof store.read !== "function") {
|
|
105
|
+
throw new Error("token vault requires a store with mutate(fn) and read(fn) methods");
|
|
25
106
|
}
|
|
26
107
|
if (!cryptoProvider) {
|
|
27
108
|
throw new Error("Local token vault requires cryptoProvider");
|
|
@@ -37,16 +118,31 @@ export function createLocalTokenVault({
|
|
|
37
118
|
return !deterministicTypes || deterministicTypes.includes(type);
|
|
38
119
|
}
|
|
39
120
|
|
|
121
|
+
// The mutationQueue (cross-call serialization) stays in core, wrapping
|
|
122
|
+
// store.mutate. Together with the store's own exclusive critical section this
|
|
123
|
+
// keeps concurrent tokenize/purge from corrupting or losing tokens.
|
|
40
124
|
let mutationQueue = Promise.resolve();
|
|
41
125
|
async function enqueueMutation(operation) {
|
|
42
|
-
const mutation = mutationQueue.then(
|
|
43
|
-
await mkdir(dirname(path), { recursive: true });
|
|
44
|
-
return withFileLock(`${path}.lock`, operation);
|
|
45
|
-
});
|
|
126
|
+
const mutation = mutationQueue.then(() => store.mutate(operation));
|
|
46
127
|
mutationQueue = mutation.catch(() => {});
|
|
47
128
|
return mutation;
|
|
48
129
|
}
|
|
49
130
|
|
|
131
|
+
// Prune expired entries from the mutable view before each operation. For the
|
|
132
|
+
// file store this deletes from the in-memory map so they are gone after the
|
|
133
|
+
// trailing writeVault; for any store the deletions are persisted by mutate().
|
|
134
|
+
// Returns the number pruned (purgeExpired counts on this).
|
|
135
|
+
function pruneExpiredView(view, now = Date.now()) {
|
|
136
|
+
let purged = 0;
|
|
137
|
+
for (const [token, record] of view.entries()) {
|
|
138
|
+
if (record.expiresAt && Date.parse(record.expiresAt) < now) {
|
|
139
|
+
view.delete(token);
|
|
140
|
+
purged += 1;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return purged;
|
|
144
|
+
}
|
|
145
|
+
|
|
50
146
|
// The audit `token` field must never carry a raw secret. A legitimate token
|
|
51
147
|
// id is a non-sensitive opaque `tok_<type>_<hexhash>` — recorded verbatim for
|
|
52
148
|
// correlation. A caller who misuses the API and passes a raw value where a
|
|
@@ -122,14 +218,13 @@ export function createLocalTokenVault({
|
|
|
122
218
|
})).slice(0, 32)}`
|
|
123
219
|
: `tok_${type}_${shortHash(`${plaintext}:${randomBytes(16).toString("hex")}`)}`;
|
|
124
220
|
|
|
125
|
-
return enqueueMutation(async () => {
|
|
126
|
-
|
|
127
|
-
pruneExpiredTokens(vault);
|
|
221
|
+
return enqueueMutation(async (view) => {
|
|
222
|
+
pruneExpiredView(view);
|
|
128
223
|
|
|
129
|
-
const existing =
|
|
224
|
+
const existing = view.get(token);
|
|
130
225
|
if (existing) {
|
|
131
226
|
existing.expiresAt = addDays(new Date(), retentionDays).toISOString();
|
|
132
|
-
|
|
227
|
+
view.set(token, existing);
|
|
133
228
|
return { token, type, reused: true };
|
|
134
229
|
}
|
|
135
230
|
|
|
@@ -140,15 +235,14 @@ export function createLocalTokenVault({
|
|
|
140
235
|
type,
|
|
141
236
|
context
|
|
142
237
|
};
|
|
143
|
-
|
|
238
|
+
view.set(token, {
|
|
144
239
|
type,
|
|
145
240
|
createdAt: createdAt.toISOString(),
|
|
146
241
|
expiresAt: addDays(createdAt, retentionDays).toISOString(),
|
|
147
242
|
metadata: sanitizeMetadata(metadata),
|
|
148
243
|
envelope: await cryptoProvider.encrypt({ plaintext, aad }),
|
|
149
244
|
aad
|
|
150
|
-
};
|
|
151
|
-
await writeVault(path, vault);
|
|
245
|
+
});
|
|
152
246
|
return { token, type };
|
|
153
247
|
});
|
|
154
248
|
},
|
|
@@ -166,8 +260,7 @@ export function createLocalTokenVault({
|
|
|
166
260
|
// token); the message itself never interpolates the token argument.
|
|
167
261
|
let reasonCode = "reveal_error";
|
|
168
262
|
try {
|
|
169
|
-
const
|
|
170
|
-
const record = vault.tokens[token];
|
|
263
|
+
const record = await store.read((view) => view.get(token));
|
|
171
264
|
if (!record) {
|
|
172
265
|
reasonCode = "unknown_token";
|
|
173
266
|
throw new Error("Unknown token");
|
|
@@ -210,12 +303,19 @@ export function createLocalTokenVault({
|
|
|
210
303
|
// reachable through the proxy's explicit detokenizeResponses opt-in and is
|
|
211
304
|
// limited to the caller-supplied token set. Audited by count, no plaintext.
|
|
212
305
|
async detokenize({ tokens }) {
|
|
213
|
-
const
|
|
306
|
+
const records = await store.read((view) => {
|
|
307
|
+
const found = new Map();
|
|
308
|
+
for (const token of tokens) {
|
|
309
|
+
found.set(token, view.get(token));
|
|
310
|
+
}
|
|
311
|
+
return found;
|
|
312
|
+
});
|
|
313
|
+
|
|
214
314
|
const values = new Map();
|
|
215
315
|
let skipped = 0;
|
|
216
316
|
|
|
217
317
|
for (const token of tokens) {
|
|
218
|
-
const record =
|
|
318
|
+
const record = records.get(token);
|
|
219
319
|
if (!record || (record.expiresAt && Date.parse(record.expiresAt) < Date.now())) {
|
|
220
320
|
skipped += 1;
|
|
221
321
|
continue;
|
|
@@ -237,36 +337,30 @@ export function createLocalTokenVault({
|
|
|
237
337
|
return values;
|
|
238
338
|
},
|
|
239
339
|
async purge({ token }) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
});
|
|
251
|
-
return { token, purged: existed, purgedAt: new Date().toISOString() };
|
|
340
|
+
const existed = await enqueueMutation(async (view) => {
|
|
341
|
+
pruneExpiredView(view);
|
|
342
|
+
const present = Boolean(view.get(token));
|
|
343
|
+
view.delete(token);
|
|
344
|
+
return present;
|
|
345
|
+
});
|
|
346
|
+
await recordVaultEvent({
|
|
347
|
+
operation: "token-vault:purge",
|
|
348
|
+
decision: "purge",
|
|
349
|
+
token
|
|
252
350
|
});
|
|
351
|
+
return { token, purged: existed, purgedAt: new Date().toISOString() };
|
|
253
352
|
},
|
|
254
353
|
async purgeExpired() {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
operation: "token-vault:purge-expired",
|
|
261
|
-
decision: "purge_expired",
|
|
262
|
-
count: purged
|
|
263
|
-
});
|
|
264
|
-
return { purged, purgedAt: new Date().toISOString() };
|
|
354
|
+
const purged = await enqueueMutation(async (view) => pruneExpiredView(view));
|
|
355
|
+
await recordVaultEvent({
|
|
356
|
+
operation: "token-vault:purge-expired",
|
|
357
|
+
decision: "purge_expired",
|
|
358
|
+
count: purged
|
|
265
359
|
});
|
|
360
|
+
return { purged, purgedAt: new Date().toISOString() };
|
|
266
361
|
},
|
|
267
362
|
async exportMetadata({ type = null } = {}) {
|
|
268
|
-
|
|
269
|
-
return Object.entries(vault.tokens)
|
|
363
|
+
return store.read((view) => view.entries()
|
|
270
364
|
.filter(([, record]) => !type || record.type === type)
|
|
271
365
|
.map(([token, record]) => ({
|
|
272
366
|
token,
|
|
@@ -274,11 +368,39 @@ export function createLocalTokenVault({
|
|
|
274
368
|
createdAt: record.createdAt,
|
|
275
369
|
expiresAt: record.expiresAt,
|
|
276
370
|
metadata: sanitizeMetadata(record.metadata ?? {})
|
|
277
|
-
}));
|
|
371
|
+
})));
|
|
278
372
|
}
|
|
279
373
|
};
|
|
280
374
|
}
|
|
281
375
|
|
|
376
|
+
// Thin back-compat wrapper: the original file-backed vault is now
|
|
377
|
+
// createTokenVault over createFileTokenStore. Its returned shape (id, version,
|
|
378
|
+
// capabilities, tokenize, reveal, detokenize, purge, purgeExpired,
|
|
379
|
+
// exportMetadata) and on-disk bytes are unchanged, so existing call sites
|
|
380
|
+
// (runtime.mjs injection, tests) keep working untouched.
|
|
381
|
+
export function createLocalTokenVault({
|
|
382
|
+
path,
|
|
383
|
+
cryptoProvider,
|
|
384
|
+
revealPolicy = "disabled",
|
|
385
|
+
retentionDays = 30,
|
|
386
|
+
auditSink = null,
|
|
387
|
+
deterministic = false,
|
|
388
|
+
deterministicTypes = null
|
|
389
|
+
}) {
|
|
390
|
+
if (!path) {
|
|
391
|
+
throw new Error("Local token vault requires path");
|
|
392
|
+
}
|
|
393
|
+
return createTokenVault({
|
|
394
|
+
store: createFileTokenStore({ path }),
|
|
395
|
+
cryptoProvider,
|
|
396
|
+
revealPolicy,
|
|
397
|
+
retentionDays,
|
|
398
|
+
auditSink,
|
|
399
|
+
deterministic,
|
|
400
|
+
deterministicTypes
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
282
404
|
export async function readVault(path) {
|
|
283
405
|
try {
|
|
284
406
|
return JSON.parse(await readFile(path, "utf8"));
|
|
@@ -301,17 +423,6 @@ async function writeVault(path, vault) {
|
|
|
301
423
|
await rename(tempPath, path);
|
|
302
424
|
}
|
|
303
425
|
|
|
304
|
-
function pruneExpiredTokens(vault, now = Date.now()) {
|
|
305
|
-
let purged = 0;
|
|
306
|
-
for (const [token, record] of Object.entries(vault.tokens)) {
|
|
307
|
-
if (record.expiresAt && Date.parse(record.expiresAt) < now) {
|
|
308
|
-
delete vault.tokens[token];
|
|
309
|
-
purged += 1;
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
return purged;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
426
|
function sanitizeMetadata(metadata) {
|
|
316
427
|
return Object.fromEntries(Object.entries(metadata).filter(([key]) => !["value", "plaintext", "payload"].includes(key)));
|
|
317
428
|
}
|