feat(ai,auth): Mission Grant endpoint + unwrap helper + audit table

Phase 1 of the Mission Key-Grant rollout. Webapp can now request a
wrapped per-mission data key; mana-ai can unwrap and (Phase 2) use it.

mana-auth:
- POST /api/v1/me/ai-mission-grant — HKDF-derives MDK from the user
  master key, RSA-OAEP-2048-wraps with the mana-ai public key, returns
  { wrappedKey, derivation, issuedAt, expiresAt }
- MissionGrantService refuses zero-knowledge users (409 ZK_ACTIVE) and
  returns 503 GRANT_NOT_CONFIGURED when MANA_AI_PUBLIC_KEY_PEM is unset
- TTL clamped to [1h, 30d]

mana-ai:
- configureMissionGrantKey + unwrapMissionGrant with structured failure
  reasons (not-configured / expired / malformed / wrap-rejected)
- mana_ai.decrypt_audit table + RLS policy scoped to
  app.current_user_id — append-only row per server-side decrypt attempt
- MANA_AI_PRIVATE_KEY_PEM env slot; absent = grants silently disabled

No existing behaviour changes: missions without a grant run exactly as
before. Grant flow is wired end-to-end but unused until Phase 2 lands
the encrypted resolver.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-15 13:41:59 +02:00
parent 6882ffb626
commit 9a3025fed8
12 changed files with 1258 additions and 207 deletions

532
pnpm-lock.yaml generated
View file

@ -138,14 +138,14 @@ importers:
version: link:../../../../packages/shared-landing-ui
astro:
specifier: ^5.16.0
version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
typescript:
specifier: ^5.9.2
version: 5.9.3
devDependencies:
'@astrojs/tailwind':
specifier: ^6.0.2
version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
'@tailwindcss/typography':
specifier: ^0.5.18
version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
@ -154,13 +154,13 @@ importers:
version: 20.19.39
eslint:
specifier: ^9.0.0
version: 9.39.4(jiti@2.6.1)
version: 9.39.4(jiti@1.21.7)
eslint-config-prettier:
specifier: ^9.1.0
version: 9.1.2(eslint@9.39.4(jiti@2.6.1))
version: 9.1.2(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-astro:
specifier: ^1.0.0
version: 1.6.0(eslint@9.39.4(jiti@2.6.1))
version: 1.6.0(eslint@9.39.4(jiti@1.21.7))
prettier:
specifier: ^3.6.2
version: 3.8.1
@ -484,19 +484,19 @@ importers:
version: 19.1.17
'@typescript-eslint/eslint-plugin':
specifier: ^7.7.0
version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3)
version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3)
'@typescript-eslint/parser':
specifier: ^7.7.0
version: 7.18.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3)
version: 7.18.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3)
dotenv:
specifier: ^16.4.7
version: 16.6.1
eslint:
specifier: ^9.39.1
version: 9.39.4(jiti@1.21.7)
version: 9.39.4(jiti@2.6.1)
eslint-config-universe:
specifier: ^12.0.1
version: 12.1.0(@types/eslint@9.6.1)(eslint@9.39.4(jiti@1.21.7))(prettier@3.8.1)(typescript@5.3.3)
version: 12.1.0(@types/eslint@9.6.1)(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.1)(typescript@5.3.3)
prettier:
specifier: ^3.2.5
version: 3.8.1
@ -3340,6 +3340,9 @@ importers:
services/mana-auth:
dependencies:
'@mana/shared-ai':
specifier: workspace:*
version: link:../../packages/shared-ai
'@mana/shared-hono':
specifier: workspace:*
version: link:../../packages/shared-hono
@ -18032,6 +18035,16 @@ snapshots:
transitivePeerDependencies:
- ts-node
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
autoprefixer: 10.4.27(postcss@8.5.8)
postcss: 8.5.8
postcss-load-config: 4.0.2(postcss@8.5.8)
tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- ts-node
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
@ -20380,6 +20393,82 @@ snapshots:
- supports-color
- utf-8-validate
'@expo/cli@55.0.22(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-constants@55.0.12)(expo-font@55.0.6(expo@55.0.12)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(expo-router@55.0.11)(expo@55.0.12)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)':
dependencies:
'@expo/code-signing-certificates': 0.0.6
'@expo/config': 55.0.13(typescript@5.9.3)
'@expo/config-plugins': 55.0.8
'@expo/devcert': 1.2.1
'@expo/env': 2.1.1
'@expo/image-utils': 0.8.12
'@expo/json-file': 10.0.13
'@expo/log-box': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.12)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
'@expo/metro': 55.0.0
'@expo/metro-config': 55.0.14(expo@55.0.12)(typescript@5.9.3)
'@expo/osascript': 2.4.2
'@expo/package-manager': 1.10.4
'@expo/plist': 0.5.2
'@expo/prebuild-config': 55.0.13(expo@55.0.12)(typescript@5.9.3)
'@expo/require-utils': 55.0.3(typescript@5.9.3)
'@expo/router-server': 55.0.13(@expo/metro-runtime@55.0.9)(expo-constants@55.0.12)(expo-font@55.0.6(expo@55.0.12)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(expo-router@55.0.11)(expo-server@55.0.7)(expo@55.0.12)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@expo/schema-utils': 55.0.3
'@expo/spawn-async': 1.7.2
'@expo/ws-tunnel': 1.0.6
'@expo/xcpretty': 4.4.1
'@react-native/dev-middleware': 0.83.4
accepts: 1.3.8
arg: 5.0.2
better-opn: 3.0.2
bplist-creator: 0.1.0
bplist-parser: 0.3.2
chalk: 4.1.2
ci-info: 3.9.0
compression: 1.8.1
connect: 3.7.0
debug: 4.4.3
dnssd-advertise: 1.1.4
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-server: 55.0.7
fetch-nodeshim: 0.4.10
getenv: 2.0.0
glob: 13.0.6
lan-network: 0.2.1
multitars: 0.2.4
node-forge: 1.4.0
npm-package-arg: 11.0.3
ora: 3.4.0
picomatch: 4.0.4
pretty-format: 29.7.0
progress: 2.0.3
prompts: 2.4.2
resolve-from: 5.0.0
semver: 7.7.4
send: 0.19.2
slugify: 1.6.9
source-map-support: 0.5.21
stacktrace-parser: 0.1.11
structured-headers: 0.4.1
terminal-link: 2.1.1
toqr: 0.1.1
wrap-ansi: 7.0.0
ws: 8.20.0
zod: 3.25.76
optionalDependencies:
expo-router: 55.0.11(pdj77jcnw7u2taeri5ruj5t4mi)
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0)
transitivePeerDependencies:
- '@expo/dom-webview'
- '@expo/metro-runtime'
- bufferutil
- expo-constants
- expo-font
- react
- react-dom
- react-server-dom-webpack
- supports-color
- typescript
- utf-8-validate
'@expo/cli@55.0.22(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-constants@55.0.12)(expo-font@55.0.6)(expo-router@55.0.11)(expo@55.0.12)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3)':
dependencies:
'@expo/code-signing-certificates': 0.0.6
@ -20532,82 +20621,6 @@ snapshots:
- typescript
- utf-8-validate
'@expo/cli@55.0.22(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-constants@55.0.12)(expo-font@55.0.6)(expo-router@55.0.11)(expo@55.0.12)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)':
dependencies:
'@expo/code-signing-certificates': 0.0.6
'@expo/config': 55.0.13(typescript@5.9.3)
'@expo/config-plugins': 55.0.8
'@expo/devcert': 1.2.1
'@expo/env': 2.1.1
'@expo/image-utils': 0.8.12
'@expo/json-file': 10.0.13
'@expo/log-box': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.12)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
'@expo/metro': 55.0.0
'@expo/metro-config': 55.0.14(expo@55.0.12)(typescript@5.9.3)
'@expo/osascript': 2.4.2
'@expo/package-manager': 1.10.4
'@expo/plist': 0.5.2
'@expo/prebuild-config': 55.0.13(expo@55.0.12)(typescript@5.9.3)
'@expo/require-utils': 55.0.3(typescript@5.9.3)
'@expo/router-server': 55.0.13(@expo/metro-runtime@55.0.9)(expo-constants@55.0.12)(expo-font@55.0.6)(expo-router@55.0.11)(expo-server@55.0.7)(expo@55.0.12)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@expo/schema-utils': 55.0.3
'@expo/spawn-async': 1.7.2
'@expo/ws-tunnel': 1.0.6
'@expo/xcpretty': 4.4.1
'@react-native/dev-middleware': 0.83.4
accepts: 1.3.8
arg: 5.0.2
better-opn: 3.0.2
bplist-creator: 0.1.0
bplist-parser: 0.3.2
chalk: 4.1.2
ci-info: 3.9.0
compression: 1.8.1
connect: 3.7.0
debug: 4.4.3
dnssd-advertise: 1.1.4
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-server: 55.0.7
fetch-nodeshim: 0.4.10
getenv: 2.0.0
glob: 13.0.6
lan-network: 0.2.1
multitars: 0.2.4
node-forge: 1.4.0
npm-package-arg: 11.0.3
ora: 3.4.0
picomatch: 4.0.4
pretty-format: 29.7.0
progress: 2.0.3
prompts: 2.4.2
resolve-from: 5.0.0
semver: 7.7.4
send: 0.19.2
slugify: 1.6.9
source-map-support: 0.5.21
stacktrace-parser: 0.1.11
structured-headers: 0.4.1
terminal-link: 2.1.1
toqr: 0.1.1
wrap-ansi: 7.0.0
ws: 8.20.0
zod: 3.25.76
optionalDependencies:
expo-router: 55.0.11(eq6waxwbjtmotercssqwfwfcim)
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0)
transitivePeerDependencies:
- '@expo/dom-webview'
- '@expo/metro-runtime'
- bufferutil
- expo-constants
- expo-font
- react
- react-dom
- react-server-dom-webpack
- supports-color
- typescript
- utf-8-validate
'@expo/cli@55.0.22(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-constants@55.0.12)(expo-font@55.0.6)(expo-router@55.0.11)(expo@55.0.12)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)':
dependencies:
'@expo/code-signing-certificates': 0.0.6
@ -20835,7 +20848,7 @@ snapshots:
'@expo/dom-webview@55.0.5(expo@55.0.12)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)':
dependencies:
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3)
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0)
@ -20948,7 +20961,7 @@ snapshots:
dependencies:
'@expo/dom-webview': 55.0.5(expo@55.0.12)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
anser: 1.4.10
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3)
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0)
stacktrace-parser: 0.1.11
@ -21072,7 +21085,7 @@ snapshots:
postcss: 8.4.49
resolve-from: 5.0.0
optionalDependencies:
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
transitivePeerDependencies:
- bufferutil
- supports-color
@ -21099,7 +21112,7 @@ snapshots:
dependencies:
'@expo/log-box': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.12)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
anser: 1.4.10
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3)
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
pretty-format: 29.7.0
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0)
@ -21251,7 +21264,7 @@ snapshots:
'@expo/json-file': 10.0.13
'@react-native/normalize-colors': 0.83.4
debug: 4.4.3
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
resolve-from: 5.0.0
semver: 7.7.4
xml2js: 0.6.0
@ -21297,6 +21310,21 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@expo/router-server@55.0.13(@expo/metro-runtime@55.0.9)(expo-constants@55.0.12)(expo-font@55.0.6(expo@55.0.12)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(expo-router@55.0.11)(expo-server@55.0.7)(expo@55.0.12)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
debug: 4.4.3
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-constants: 55.0.12(expo@55.0.12)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(typescript@5.9.3)
expo-font: 55.0.6(expo@55.0.12)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
expo-server: 55.0.7
react: 19.2.0
optionalDependencies:
'@expo/metro-runtime': 55.0.9(@expo/dom-webview@55.0.5)(expo@55.0.12)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
expo-router: 55.0.11(pdj77jcnw7u2taeri5ruj5t4mi)
react-dom: 19.2.0(react@19.2.0)
transitivePeerDependencies:
- supports-color
'@expo/router-server@55.0.13(@expo/metro-runtime@55.0.9)(expo-constants@55.0.12)(expo-font@55.0.6)(expo-router@55.0.11)(expo-server@55.0.7)(expo@55.0.12)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
debug: 4.4.3
@ -25131,16 +25159,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3)':
'@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 6.21.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3)
'@typescript-eslint/parser': 6.21.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3)
'@typescript-eslint/scope-manager': 6.21.0
'@typescript-eslint/type-utils': 6.21.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3)
'@typescript-eslint/utils': 6.21.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3)
'@typescript-eslint/type-utils': 6.21.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3)
'@typescript-eslint/utils': 6.21.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3)
'@typescript-eslint/visitor-keys': 6.21.0
debug: 4.4.3
eslint: 9.39.4(jiti@1.21.7)
eslint: 9.39.4(jiti@2.6.1)
graphemer: 1.4.0
ignore: 5.3.2
natural-compare: 1.4.0
@ -25189,15 +25217,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3)':
'@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 7.18.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3)
'@typescript-eslint/parser': 7.18.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3)
'@typescript-eslint/scope-manager': 7.18.0
'@typescript-eslint/type-utils': 7.18.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3)
'@typescript-eslint/utils': 7.18.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3)
'@typescript-eslint/type-utils': 7.18.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3)
'@typescript-eslint/utils': 7.18.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3)
'@typescript-eslint/visitor-keys': 7.18.0
eslint: 9.39.4(jiti@1.21.7)
eslint: 9.39.4(jiti@2.6.1)
graphemer: 1.4.0
ignore: 5.3.2
natural-compare: 1.4.0
@ -25270,14 +25298,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@6.21.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3)':
'@typescript-eslint/parser@6.21.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3)':
dependencies:
'@typescript-eslint/scope-manager': 6.21.0
'@typescript-eslint/types': 6.21.0
'@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3)
'@typescript-eslint/visitor-keys': 6.21.0
debug: 4.4.3
eslint: 9.39.4(jiti@1.21.7)
eslint: 9.39.4(jiti@2.6.1)
optionalDependencies:
typescript: 5.3.3
transitivePeerDependencies:
@ -25309,14 +25337,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@7.18.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3)':
'@typescript-eslint/parser@7.18.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3)':
dependencies:
'@typescript-eslint/scope-manager': 7.18.0
'@typescript-eslint/types': 7.18.0
'@typescript-eslint/typescript-estree': 7.18.0(typescript@5.3.3)
'@typescript-eslint/visitor-keys': 7.18.0
debug: 4.4.3
eslint: 9.39.4(jiti@1.21.7)
eslint: 9.39.4(jiti@2.6.1)
optionalDependencies:
typescript: 5.3.3
transitivePeerDependencies:
@ -25412,12 +25440,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/type-utils@6.21.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3)':
'@typescript-eslint/type-utils@6.21.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3)':
dependencies:
'@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3)
'@typescript-eslint/utils': 6.21.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3)
'@typescript-eslint/utils': 6.21.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3)
debug: 4.4.3
eslint: 9.39.4(jiti@1.21.7)
eslint: 9.39.4(jiti@2.6.1)
ts-api-utils: 1.4.3(typescript@5.3.3)
optionalDependencies:
typescript: 5.3.3
@ -25448,12 +25476,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/type-utils@7.18.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3)':
'@typescript-eslint/type-utils@7.18.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3)':
dependencies:
'@typescript-eslint/typescript-estree': 7.18.0(typescript@5.3.3)
'@typescript-eslint/utils': 7.18.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3)
'@typescript-eslint/utils': 7.18.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3)
debug: 4.4.3
eslint: 9.39.4(jiti@1.21.7)
eslint: 9.39.4(jiti@2.6.1)
ts-api-utils: 1.4.3(typescript@5.3.3)
optionalDependencies:
typescript: 5.3.3
@ -25606,15 +25634,15 @@ snapshots:
- supports-color
- typescript
'@typescript-eslint/utils@6.21.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3)':
'@typescript-eslint/utils@6.21.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
'@types/json-schema': 7.0.15
'@types/semver': 7.7.1
'@typescript-eslint/scope-manager': 6.21.0
'@typescript-eslint/types': 6.21.0
'@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3)
eslint: 9.39.4(jiti@1.21.7)
eslint: 9.39.4(jiti@2.6.1)
semver: 7.7.4
transitivePeerDependencies:
- supports-color
@ -25645,13 +25673,13 @@ snapshots:
- supports-color
- typescript
'@typescript-eslint/utils@7.18.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3)':
'@typescript-eslint/utils@7.18.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
'@typescript-eslint/scope-manager': 7.18.0
'@typescript-eslint/types': 7.18.0
'@typescript-eslint/typescript-estree': 7.18.0(typescript@5.3.3)
eslint: 9.39.4(jiti@1.21.7)
eslint: 9.39.4(jiti@2.6.1)
transitivePeerDependencies:
- supports-color
- typescript
@ -26335,6 +26363,108 @@ snapshots:
transitivePeerDependencies:
- supports-color
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
dependencies:
'@astrojs/compiler': 2.13.1
'@astrojs/internal-helpers': 0.7.6
'@astrojs/markdown-remark': 6.3.11
'@astrojs/telemetry': 3.3.0
'@capsizecss/unpack': 4.0.0
'@oslojs/encoding': 1.1.0
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
acorn: 8.16.0
aria-query: 5.3.2
axobject-query: 4.1.0
boxen: 8.0.1
ci-info: 4.4.0
clsx: 2.1.1
common-ancestor-path: 1.0.1
cookie: 1.1.1
cssesc: 3.0.0
debug: 4.4.3
deterministic-object-hash: 2.0.2
devalue: 5.7.0
diff: 8.0.4
dlv: 1.1.3
dset: 3.1.4
es-module-lexer: 1.7.0
esbuild: 0.27.7
estree-walker: 3.0.3
flattie: 1.1.1
fontace: 0.4.1
github-slugger: 2.0.0
html-escaper: 3.0.3
http-cache-semantics: 4.2.0
import-meta-resolve: 4.2.0
js-yaml: 4.1.1
magic-string: 0.30.21
magicast: 0.5.2
mrmime: 2.0.1
neotraverse: 0.6.18
p-limit: 6.2.0
p-queue: 8.1.1
package-manager-detector: 1.6.0
piccolore: 0.1.3
picomatch: 4.0.4
prompts: 2.4.2
rehype: 13.0.2
semver: 7.7.4
shiki: 3.23.0
smol-toml: 1.6.1
svgo: 4.0.1
tinyexec: 1.0.4
tinyglobby: 0.2.15
tsconfck: 3.1.6(typescript@5.9.3)
ultrahtml: 1.6.0
unifont: 0.7.4
unist-util-visit: 5.1.0
unstorage: 1.17.5(@azure/storage-blob@12.31.0)(ioredis@5.10.1)
vfile: 6.0.3
vite: 6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu: 1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
xxhash-wasm: 1.1.0
yargs-parser: 21.1.1
yocto-spinner: 0.2.3
zod: 3.25.76
zod-to-json-schema: 3.25.2(zod@3.25.76)
zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76)
optionalDependencies:
sharp: 0.34.5
transitivePeerDependencies:
- '@azure/app-configuration'
- '@azure/cosmos'
- '@azure/data-tables'
- '@azure/identity'
- '@azure/keyvault-secrets'
- '@azure/storage-blob'
- '@capacitor/preferences'
- '@deno/kv'
- '@netlify/blobs'
- '@planetscale/database'
- '@types/node'
- '@upstash/redis'
- '@vercel/blob'
- '@vercel/functions'
- '@vercel/kv'
- aws4fetch
- db0
- idb-keyval
- ioredis
- jiti
- less
- lightningcss
- rollup
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- typescript
- uploadthing
- yaml
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
dependencies:
'@astrojs/compiler': 2.13.1
@ -26848,7 +26978,7 @@ snapshots:
resolve-from: 5.0.0
optionalDependencies:
'@babel/runtime': 7.29.2
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3)
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
transitivePeerDependencies:
- '@babel/core'
- supports-color
@ -28350,6 +28480,11 @@ snapshots:
eslint: 9.39.4(jiti@2.6.1)
semver: 7.7.4
eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@1.21.7)):
dependencies:
eslint: 9.39.4(jiti@1.21.7)
semver: 7.7.4
eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@2.6.1)):
dependencies:
eslint: 9.39.4(jiti@2.6.1)
@ -28397,14 +28532,14 @@ snapshots:
dependencies:
eslint: 8.57.1
eslint-config-prettier@8.10.2(eslint@9.39.4(jiti@1.21.7)):
dependencies:
eslint: 9.39.4(jiti@1.21.7)
eslint-config-prettier@8.10.2(eslint@9.39.4(jiti@2.6.1)):
dependencies:
eslint: 9.39.4(jiti@2.6.1)
eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@1.21.7)):
dependencies:
eslint: 9.39.4(jiti@1.21.7)
eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@2.6.1)):
dependencies:
eslint: 9.39.4(jiti@2.6.1)
@ -28429,17 +28564,17 @@ snapshots:
- supports-color
- typescript
eslint-config-universe@12.1.0(@types/eslint@9.6.1)(eslint@9.39.4(jiti@1.21.7))(prettier@3.8.1)(typescript@5.3.3):
eslint-config-universe@12.1.0(@types/eslint@9.6.1)(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.1)(typescript@5.3.3):
dependencies:
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3)
'@typescript-eslint/parser': 6.21.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3)
eslint: 9.39.4(jiti@1.21.7)
eslint-config-prettier: 8.10.2(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-node: 11.1.0(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-prettier: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7))(prettier@3.8.1)
eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-react-hooks: 4.6.2(eslint@9.39.4(jiti@1.21.7))
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3)
'@typescript-eslint/parser': 6.21.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3)
eslint: 9.39.4(jiti@2.6.1)
eslint-config-prettier: 8.10.2(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-node: 11.1.0(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-prettier: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.1)
eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-react-hooks: 4.6.2(eslint@9.39.4(jiti@2.6.1))
optionalDependencies:
prettier: 3.8.1
transitivePeerDependencies:
@ -28546,12 +28681,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4(jiti@1.21.7)):
eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4(jiti@2.6.1)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 6.21.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3)
eslint: 9.39.4(jiti@1.21.7)
'@typescript-eslint/parser': 6.21.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3)
eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.10
transitivePeerDependencies:
- supports-color
@ -28588,6 +28723,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@1.21.7)):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
'@jridgewell/sourcemap-codec': 1.5.5
'@typescript-eslint/types': 8.58.0
astro-eslint-parser: 1.4.0
eslint: 9.39.4(jiti@1.21.7)
eslint-compat-utils: 0.6.5(eslint@9.39.4(jiti@1.21.7))
globals: 16.5.0
postcss: 8.5.8
postcss-selector-parser: 7.1.1
transitivePeerDependencies:
- supports-color
eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@2.6.1)):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
@ -28615,12 +28764,6 @@ snapshots:
eslint-utils: 2.1.0
regexpp: 3.2.0
eslint-plugin-es@3.0.1(eslint@9.39.4(jiti@1.21.7)):
dependencies:
eslint: 9.39.4(jiti@1.21.7)
eslint-utils: 2.1.0
regexpp: 3.2.0
eslint-plugin-es@3.0.1(eslint@9.39.4(jiti@2.6.1)):
dependencies:
eslint: 9.39.4(jiti@2.6.1)
@ -28674,7 +28817,7 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.4(jiti@1.21.7)):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.4(jiti@2.6.1)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@ -28683,9 +28826,9 @@ snapshots:
array.prototype.flatmap: 1.3.3
debug: 3.2.7
doctrine: 2.1.0
eslint: 9.39.4(jiti@1.21.7)
eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.10
eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4(jiti@1.21.7))
eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4(jiti@2.6.1))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@ -28697,7 +28840,7 @@ snapshots:
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
optionalDependencies:
'@typescript-eslint/parser': 6.21.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.3.3)
'@typescript-eslint/parser': 6.21.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.3.3)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
@ -28815,16 +28958,6 @@ snapshots:
resolve: 1.22.11
semver: 6.3.1
eslint-plugin-node@11.1.0(eslint@9.39.4(jiti@1.21.7)):
dependencies:
eslint: 9.39.4(jiti@1.21.7)
eslint-plugin-es: 3.0.1(eslint@9.39.4(jiti@1.21.7))
eslint-utils: 2.1.0
ignore: 5.3.2
minimatch: 3.1.5
resolve: 1.22.11
semver: 6.3.1
eslint-plugin-node@11.1.0(eslint@9.39.4(jiti@2.6.1)):
dependencies:
eslint: 9.39.4(jiti@2.6.1)
@ -28855,16 +28988,6 @@ snapshots:
'@types/eslint': 9.6.1
eslint-config-prettier: 8.10.2(eslint@8.57.1)
eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7))(prettier@3.8.1):
dependencies:
eslint: 9.39.4(jiti@1.21.7)
prettier: 3.8.1
prettier-linter-helpers: 1.0.1
synckit: 0.11.12
optionalDependencies:
'@types/eslint': 9.6.1
eslint-config-prettier: 8.10.2(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.1):
dependencies:
eslint: 9.39.4(jiti@2.6.1)
@ -28889,10 +29012,6 @@ snapshots:
dependencies:
eslint: 8.57.1
eslint-plugin-react-hooks@4.6.2(eslint@9.39.4(jiti@1.21.7)):
dependencies:
eslint: 9.39.4(jiti@1.21.7)
eslint-plugin-react-hooks@4.6.2(eslint@9.39.4(jiti@2.6.1)):
dependencies:
eslint: 9.39.4(jiti@2.6.1)
@ -28923,28 +29042,6 @@ snapshots:
string.prototype.matchall: 4.0.12
string.prototype.repeat: 1.0.0
eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@1.21.7)):
dependencies:
array-includes: 3.1.9
array.prototype.findlast: 1.2.5
array.prototype.flatmap: 1.3.3
array.prototype.tosorted: 1.1.4
doctrine: 2.1.0
es-iterator-helpers: 1.3.1
eslint: 9.39.4(jiti@1.21.7)
estraverse: 5.3.0
hasown: 2.0.2
jsx-ast-utils: 3.3.5
minimatch: 3.1.5
object.entries: 1.1.9
object.fromentries: 2.0.8
object.values: 1.2.1
prop-types: 15.8.1
resolve: 2.0.0-next.6
semver: 6.3.1
string.prototype.matchall: 4.0.12
string.prototype.repeat: 1.0.0
eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.6.1)):
dependencies:
array-includes: 3.1.9
@ -29448,7 +29545,7 @@ snapshots:
expo-dev-client@6.0.20(expo@55.0.12):
dependencies:
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3)
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-dev-launcher: 6.0.20(expo@55.0.12)
expo-dev-menu: 7.0.18(expo@55.0.12)
expo-dev-menu-interface: 2.0.0(expo@55.0.12)
@ -29470,7 +29567,7 @@ snapshots:
expo-dev-launcher@6.0.20(expo@55.0.12):
dependencies:
ajv: 8.18.0
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3)
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-dev-menu: 7.0.18(expo@55.0.12)
expo-manifests: 1.0.10(expo@55.0.12)
transitivePeerDependencies:
@ -29478,7 +29575,7 @@ snapshots:
expo-dev-menu-interface@2.0.0(expo@55.0.12):
dependencies:
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3)
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-dev-menu-interface@55.0.2(expo@55.0.12):
dependencies:
@ -29491,7 +29588,7 @@ snapshots:
expo-dev-menu@7.0.18(expo@55.0.12):
dependencies:
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3)
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-dev-menu-interface: 2.0.0(expo@55.0.12)
expo-device@55.0.13(expo@55.0.12):
@ -29517,7 +29614,7 @@ snapshots:
expo-file-system@55.0.15(expo@55.0.12)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0)):
dependencies:
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3)
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0)
expo-file-system@55.0.15(expo@55.0.12)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)):
@ -29541,7 +29638,7 @@ snapshots:
expo-font@55.0.6(expo@55.0.12)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0):
dependencies:
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3)
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
fontfaceobserver: 2.3.0
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0)
@ -29562,7 +29659,7 @@ snapshots:
expo-glass-effect@55.0.10(expo@55.0.12)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0):
dependencies:
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3)
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0)
@ -29578,11 +29675,11 @@ snapshots:
expo-image-loader@55.0.0(expo@55.0.12):
dependencies:
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-image-picker@55.0.17(expo@55.0.12):
dependencies:
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-image-loader: 55.0.0(expo@55.0.12)
expo-image@55.0.8(expo@54.0.33)(react-native-web@0.21.2(react-dom@19.2.0(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0):
@ -29597,7 +29694,7 @@ snapshots:
expo-image@55.0.8(expo@55.0.12)(react-native-web@0.21.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0):
dependencies:
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3)
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0)
sf-symbols-typescript: 2.2.0
@ -29624,7 +29721,7 @@ snapshots:
expo-keep-awake@55.0.6(expo@55.0.12)(react@19.2.0):
dependencies:
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3)
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
react: 19.2.0
expo-linear-gradient@15.0.8(expo@55.0.12)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0):
@ -29720,7 +29817,7 @@ snapshots:
expo-manifests@1.0.10(expo@55.0.12):
dependencies:
'@expo/config': 12.0.13
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3)
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-json-utils: 0.15.0
transitivePeerDependencies:
- supports-color
@ -30092,7 +30189,7 @@ snapshots:
expo-secure-store@55.0.12(expo@55.0.12):
dependencies:
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-server@1.0.5: {}
@ -30187,7 +30284,7 @@ snapshots:
dependencies:
'@react-native/normalize-colors': 0.83.4
debug: 4.4.3
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3)
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0)
optionalDependencies:
react-native-web: 0.21.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@ -30213,7 +30310,7 @@ snapshots:
expo-updates-interface@2.0.0(expo@55.0.12):
dependencies:
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3)
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-updates-interface@55.1.5(expo@55.0.12):
dependencies:
@ -30243,7 +30340,7 @@ snapshots:
expo-web-browser@55.0.13(expo@55.0.12)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0)):
dependencies:
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3)
expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0)
expo@54.0.33(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0):
@ -30367,7 +30464,7 @@ snapshots:
expo@55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.29.2
'@expo/cli': 55.0.22(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-constants@55.0.12)(expo-font@55.0.6)(expo-router@55.0.11)(expo@55.0.12)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
'@expo/cli': 55.0.22(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-constants@55.0.12)(expo-font@55.0.6(expo@55.0.12)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(expo-router@55.0.11)(expo@55.0.12)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
'@expo/config': 55.0.13(typescript@5.9.3)
'@expo/config-plugins': 55.0.8
'@expo/devtools': 55.0.2(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
@ -37159,6 +37256,23 @@ snapshots:
lightningcss: 1.32.0
terser: 5.46.1
vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.8
rollup: 4.60.1
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 20.19.39
fsevents: 2.3.3
jiti: 1.21.7
lightningcss: 1.32.0
terser: 5.46.1
tsx: 4.21.0
yaml: 2.8.3
vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.25.12
@ -37210,6 +37324,10 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.3
vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies:
vite: 6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies:
vite: 6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)

View file

@ -19,6 +19,20 @@ export interface Config {
/** Flip to false to boot the HTTP surface without the background tick
* useful for local smoke-tests + Docker image build verification. */
tickEnabled: boolean;
/**
* PEM-encoded RSA-OAEP-2048 private key for unwrapping Mission Grants.
* Paired with the public key pinned in mana-auth's config. Provision
* via Docker secret / out-of-band env; never commit.
*
* Optional at boot so the service can start without grant support
* (development, legacy deployments). When absent, Missions that
* carry a Grant are skipped with state='grant-missing'.
*
* Generate with:
* openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out priv.pem
* openssl pkey -in priv.pem -pubout -out pub.pem
*/
missionGrantPrivateKeyPem?: string;
}
function requireEnv(key: string, fallback?: string): string {
@ -38,5 +52,6 @@ export function loadConfig(): Config {
serviceKey: requireEnv('MANA_SERVICE_KEY', 'dev-service-key'),
tickIntervalMs: parseInt(process.env.TICK_INTERVAL_MS ?? '60000', 10),
tickEnabled: process.env.TICK_ENABLED !== 'false',
missionGrantPrivateKeyPem: process.env.MANA_AI_PRIVATE_KEY_PEM || undefined,
};
}

View file

@ -0,0 +1,155 @@
/**
* Unwrap-grant round-trip test. Generates a fresh RSA keypair, uses the
* public half to wrap an MDK via the same shape mana-auth uses, then
* unwraps with the private half and verifies the MDK decrypts what the
* MDK-derived AES-GCM key encrypted.
*/
import { describe, it, expect, beforeEach } from 'bun:test';
import {
deriveMissionDataKeyRaw,
GRANT_DERIVATION_VERSION,
type GrantDerivation,
type MissionGrant,
} from '@mana/shared-ai';
import { configureMissionGrantKey, unwrapMissionGrant, _resetForTesting } from './unwrap-grant';
const fixedMasterKey = new Uint8Array(32).map((_, i) => i + 1);
async function genKeypair() {
const kp = await crypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256',
},
true,
['encrypt', 'decrypt']
);
const spki = new Uint8Array(await crypto.subtle.exportKey('spki', kp.publicKey));
const pkcs8 = new Uint8Array(await crypto.subtle.exportKey('pkcs8', kp.privateKey));
return {
publicKey: kp.publicKey,
privatePem: toPem(pkcs8, 'PRIVATE KEY'),
publicPem: toPem(spki, 'PUBLIC KEY'),
};
}
function toPem(der: Uint8Array, label: string): string {
const b64 = bytesToBase64(der);
const body: string[] = [];
for (let i = 0; i < b64.length; i += 64) body.push(b64.slice(i, i + 64));
return `-----BEGIN ${label}-----\n${body.join('\n')}\n-----END ${label}-----`;
}
function derivation(): GrantDerivation {
return {
version: GRANT_DERIVATION_VERSION,
missionId: 'mission-abc',
tables: ['notes'],
recordIds: ['notes:n1'],
};
}
async function mintGrant(publicKey: CryptoKey, ttlMs = 60_000): Promise<MissionGrant> {
const mdk = await deriveMissionDataKeyRaw(new Uint8Array(fixedMasterKey), derivation());
const wrapped = new Uint8Array(
await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, publicKey, toBufferSource(mdk))
);
const now = Date.now();
return {
wrappedKey: bytesToBase64(wrapped),
derivation: derivation(),
issuedAt: new Date(now).toISOString(),
expiresAt: new Date(now + ttlMs).toISOString(),
};
}
beforeEach(() => {
_resetForTesting();
});
describe('unwrapMissionGrant', () => {
it('round-trips a freshly minted grant into a usable AES-GCM key', async () => {
const { publicKey, privatePem } = await genKeypair();
configureMissionGrantKey(privatePem);
const grant = await mintGrant(publicKey);
const result = await unwrapMissionGrant(grant);
expect(result.ok).toBe(true);
if (!result.ok) return;
// The unwrapped CryptoKey must decrypt what the same-derivation
// MDK encrypted. Derive MDK separately, import as AES-GCM for
// encrypt, encrypt a known plaintext, then decrypt with the
// unwrapped key.
const mdkRaw = await deriveMissionDataKeyRaw(new Uint8Array(fixedMasterKey), grant.derivation);
const encKey = await crypto.subtle.importKey(
'raw',
toBufferSource(mdkRaw),
{ name: 'AES-GCM' },
false,
['encrypt']
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const plaintext = new TextEncoder().encode('hello mission');
const ct = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: toBufferSource(iv) },
encKey,
toBufferSource(plaintext)
);
const dec = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: toBufferSource(iv) },
result.mdk,
ct
);
expect(new TextDecoder().decode(dec)).toBe('hello mission');
});
it('returns not-configured when key was never set', async () => {
// no configureMissionGrantKey call
const { publicKey } = await genKeypair();
const grant = await mintGrant(publicKey);
const result = await unwrapMissionGrant(grant);
expect(result).toEqual({ ok: false, reason: 'not-configured' });
});
it('returns not-configured when key is explicitly unset', async () => {
const { publicKey } = await genKeypair();
configureMissionGrantKey(undefined);
const grant = await mintGrant(publicKey);
const result = await unwrapMissionGrant(grant);
expect(result).toEqual({ ok: false, reason: 'not-configured' });
});
it('returns expired for past expiresAt', async () => {
const { publicKey, privatePem } = await genKeypair();
configureMissionGrantKey(privatePem);
const grant = await mintGrant(publicKey, -1000);
const result = await unwrapMissionGrant(grant);
expect(result).toEqual({ ok: false, reason: 'expired' });
});
it('returns wrap-rejected for a wrapped key encrypted with the wrong pubkey', async () => {
const { privatePem } = await genKeypair();
const { publicKey: otherPub } = await genKeypair();
configureMissionGrantKey(privatePem);
const grant = await mintGrant(otherPub);
const result = await unwrapMissionGrant(grant);
expect(result).toEqual({ ok: false, reason: 'wrap-rejected' });
});
});
function bytesToBase64(bytes: Uint8Array): string {
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
function toBufferSource(bytes: Uint8Array): ArrayBuffer {
const buf = new ArrayBuffer(bytes.length);
new Uint8Array(buf).set(bytes);
return buf;
}

View file

@ -0,0 +1,155 @@
/**
* Unwrap side of the Mission Key-Grant.
*
* The `mana-auth` service wrapped the per-mission Data Key (MDK) with
* our RSA-OAEP-2048 public key before writing it into `Mission.grant`.
* At tick time, this module unwraps the MDK back into a CryptoKey that
* the encrypted resolver (Phase 2) can use to decrypt `sync_changes`
* rows for the mission's allowlisted records.
*
* Security notes:
* - The private key is loaded once per process from the PEM in
* `config.missionGrantPrivateKeyPem`. If missing, unwrap returns
* null so the tick loop can skip the mission without crashing.
* - Callers MUST discard the returned CryptoKey as soon as the tick
* finishes. The key itself is non-extractable, but the reference
* staying alive keeps the underlying bytes pinned in WebCrypto's
* internal tables. A per-tick scope map + finally-clear is the
* agreed pattern; see `docs/plans/ai-mission-key-grant.md`.
* - Grant expiry is checked here, not in the caller. A stale grant
* returns null + a structured `reason` the caller logs but never
* surfaces as an error (foreground runner picks up the slack).
*/
import type { MissionGrant } from '@mana/shared-ai';
export type UnwrapResult =
| { ok: true; mdk: CryptoKey }
| { ok: false; reason: UnwrapFailureReason };
export type UnwrapFailureReason = 'not-configured' | 'expired' | 'malformed' | 'wrap-rejected';
let _privateKeyPromise: Promise<CryptoKey | null> | null = null;
/** Install (or re-install) the PEM-encoded RSA private key. Call at
* service boot after `loadConfig`. Passing undefined disables grant
* unwrapping for the lifetime of the process tick loop skips any
* mission with a grant, same as for legacy deployments. */
export function configureMissionGrantKey(pem: string | undefined): void {
if (!pem) {
_privateKeyPromise = Promise.resolve(null);
return;
}
_privateKeyPromise = importRsaPrivateKey(pem);
}
/** Test-only reset so bun test can swap in different keys across
* describe blocks without holding stale state. Not exported through
* any barrel. */
export function _resetForTesting(): void {
_privateKeyPromise = null;
}
/**
* Unwraps a grant into the Mission Data Key. Never throws on the
* expected failure modes returns a structured reason instead so
* the caller can bump the right metric and skip cleanly.
*/
export async function unwrapMissionGrant(grant: MissionGrant): Promise<UnwrapResult> {
if (!_privateKeyPromise) {
// configureMissionGrantKey was never called. Treat as not
// configured rather than crashing — lets tests/local dev boot
// without setting the env var.
return { ok: false, reason: 'not-configured' };
}
const pk = await _privateKeyPromise;
if (!pk) return { ok: false, reason: 'not-configured' };
if (isExpired(grant.expiresAt)) {
return { ok: false, reason: 'expired' };
}
let wrappedBytes: Uint8Array;
try {
wrappedBytes = base64ToBytes(grant.wrappedKey);
} catch {
return { ok: false, reason: 'malformed' };
}
let mdkBytes: Uint8Array;
try {
const plain = await crypto.subtle.decrypt(
{ name: 'RSA-OAEP' },
pk,
toBufferSource(wrappedBytes)
);
mdkBytes = new Uint8Array(plain);
} catch {
// Auth-tag mismatch, wrong key, corrupted ciphertext — the
// caller can't meaningfully distinguish, and we don't want to
// leak timing details by branching here.
return { ok: false, reason: 'wrap-rejected' };
}
if (mdkBytes.length !== 32) {
mdkBytes.fill(0);
return { ok: false, reason: 'malformed' };
}
try {
const mdk = await crypto.subtle.importKey(
'raw',
toBufferSource(mdkBytes),
{ name: 'AES-GCM', length: 256 },
/* extractable */ false,
['decrypt']
);
return { ok: true, mdk };
} finally {
mdkBytes.fill(0);
}
}
// ─── Helpers ─────────────────────────────────────────────────
function isExpired(expiresAt: string): boolean {
const ts = Date.parse(expiresAt);
if (Number.isNaN(ts)) return true; // treat unparseable as expired
return ts < Date.now();
}
async function importRsaPrivateKey(pem: string): Promise<CryptoKey | null> {
try {
const body = pem
.replace(/-----BEGIN [^-]+-----/g, '')
.replace(/-----END [^-]+-----/g, '')
.replace(/\s+/g, '');
const der = base64ToBytes(body);
return await crypto.subtle.importKey(
'pkcs8',
toBufferSource(der),
{ name: 'RSA-OAEP', hash: 'SHA-256' },
false,
['decrypt']
);
} catch (err) {
console.error(
'[mana-ai:grant] failed to import MANA_AI_PRIVATE_KEY_PEM — grants disabled:',
(err as Error).message
);
return null;
}
}
function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function toBufferSource(bytes: Uint8Array): ArrayBuffer {
const buf = new ArrayBuffer(bytes.length);
new Uint8Array(buf).set(bytes);
return buf;
}

View file

@ -39,4 +39,54 @@ export async function migrate(sql: Sql): Promise<void> {
CREATE INDEX IF NOT EXISTS idx_mission_snapshots_user
ON mana_ai.mission_snapshots (user_id, last_applied_at)
`;
// ─── Mission Grant decrypt audit ─────────────────────────────
// Every server-side decrypt of an encrypted record (triggered by a
// Mission with a valid Grant) writes one row here. Surfaces in the
// webapp under "Mission → Datenzugriff" so the user can see exactly
// what the runner has read. Keep the row shape flat + append-only;
// never mutate after insert.
//
// Why in mana_ai and not mana-auth? The write originates here and
// the read is operator-scoped to a specific mission — keeping it
// adjacent to `mission_snapshots` means `withUser` transactions
// already have the right RLS set up.
await sql`
CREATE TABLE IF NOT EXISTS mana_ai.decrypt_audit (
id BIGSERIAL PRIMARY KEY,
user_id TEXT NOT NULL,
mission_id TEXT NOT NULL,
iteration_id TEXT,
table_name TEXT NOT NULL,
record_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('ok', 'failed', 'scope-violation')),
reason TEXT,
ts TIMESTAMPTZ NOT NULL DEFAULT now()
)
`;
await sql`
CREATE INDEX IF NOT EXISTS idx_decrypt_audit_mission
ON mana_ai.decrypt_audit (user_id, mission_id, ts DESC)
`;
// Mirror the RLS pattern used on sync_changes / mission_snapshots:
// every read goes through a `withUser` transaction that sets
// `app.user_id`; the policy gates row visibility to that user.
await sql`ALTER TABLE mana_ai.decrypt_audit ENABLE ROW LEVEL SECURITY`;
await sql`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname = 'mana_ai'
AND tablename = 'decrypt_audit'
AND policyname = 'decrypt_audit_user_scope'
) THEN
CREATE POLICY decrypt_audit_user_scope ON mana_ai.decrypt_audit
USING (user_id = current_setting('app.current_user_id', true));
END IF;
END $$
`;
}

View file

@ -17,6 +17,7 @@ import { migrate } from './db/migrate';
import { runTickOnce, startTick, stopTick, isTickRunning } from './cron/tick';
import { serviceAuth } from './middleware/service-auth';
import { register, httpRequestsTotal, httpRequestDuration } from './metrics';
import { configureMissionGrantKey } from './crypto/unwrap-grant';
const config = loadConfig();
@ -24,6 +25,11 @@ const config = loadConfig();
// every restart and after rolling deploys.
await migrate(getSql(config.syncDatabaseUrl));
// Install the RSA private key used to unwrap Mission Key-Grants. Absent
// env var → grants stay disabled (tick loop skips any mission carrying
// one). See docs/plans/ai-mission-key-grant.md.
configureMissionGrantKey(config.missionGrantPrivateKeyPem);
const app = new Hono();
// HTTP instrumentation — labels by method/path/status, surfaced on /metrics.

View file

@ -12,6 +12,7 @@
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@mana/shared-ai": "workspace:*",
"@mana/shared-hono": "workspace:*",
"hono": "^4.7.0",
"better-auth": "^1.4.3",

View file

@ -16,6 +16,15 @@ export interface Config {
* in development a deterministic dev KEK is auto-generated so the
* service still boots, with a loud warning. */
encryptionKek: string;
/**
* PEM-encoded RSA-OAEP-2048 public key for the mana-ai Mission
* Grant runner. The `/me/ai-mission-grant` endpoint wraps per-
* mission data keys with this public key so only mana-ai (holder
* of the paired private key) can unwrap them. Optional at boot:
* when absent, the endpoint returns 503 so the UI can degrade
* to foreground-only execution.
*/
missionGrantPublicKeyPem?: string;
}
export function loadConfig(): Config {
@ -57,5 +66,6 @@ export function loadConfig(): Config {
manaSubscriptionsUrl: env('MANA_SUBSCRIPTIONS_URL', 'http://localhost:3063'),
manaMailUrl: env('MANA_MAIL_URL', 'http://localhost:3042'),
encryptionKek,
missionGrantPublicKeyPem: env('MANA_AI_PUBLIC_KEY_PEM') || undefined,
};
}

View file

@ -18,12 +18,14 @@ import { SignupLimitService } from './services/signup-limit';
import { ApiKeysService } from './services/api-keys';
import { UserDataService } from './services/user-data';
import { EncryptionVaultService } from './services/encryption-vault';
import { MissionGrantService } from './services/encryption-vault/mission-grant';
import { loadKek } from './services/encryption-vault/kek';
import { createAuthRoutes } from './routes/auth';
import { createGuildRoutes } from './routes/guilds';
import { createApiKeyRoutes, createApiKeyValidationRoute } from './routes/api-keys';
import { createMeRoutes } from './routes/me';
import { createEncryptionVaultRoutes } from './routes/encryption-vault';
import { createAiMissionGrantRoutes } from './routes/ai-mission-grant';
import { createSettingsRoutes } from './routes/settings';
import { createAdminRoutes } from './routes/admin';
@ -45,6 +47,10 @@ const signupLimit = new SignupLimitService(db);
const apiKeysService = new ApiKeysService(db);
const userDataService = new UserDataService(db, config);
const encryptionVaultService = new EncryptionVaultService(db);
const missionGrantService = new MissionGrantService(
encryptionVaultService,
config.missionGrantPublicKeyPem
);
// ─── App ────────────────────────────────────────────────────
@ -97,6 +103,12 @@ app.route('/api/v1/me', createMeRoutes(userDataService));
// up in the same self-service surface as the GDPR endpoints.
app.route('/api/v1/me/encryption-vault', createEncryptionVaultRoutes(encryptionVaultService));
// ─── AI Mission Grant ──────────────────────────────────────
// Mints per-mission Key-Grants so the mana-ai background runner can
// decrypt scoped encrypted records. Under /me so it inherits the JWT
// middleware above. See docs/plans/ai-mission-key-grant.md.
app.route('/api/v1/me/ai-mission-grant', createAiMissionGrantRoutes(missionGrantService));
// ─── Settings ──────────────────────────────────────────────
app.use('/api/v1/settings/*', jwtAuth(config.baseUrl));

View file

@ -0,0 +1,115 @@
/**
* Mission Grant route `POST /api/v1/me/ai-mission-grant`.
*
* Mints a grant that lets the mana-ai background runner decrypt the
* allowlisted records for a specific mission without needing the user's
* browser to be open. See `docs/plans/ai-mission-key-grant.md` for the
* full flow; crypto details in `services/encryption-vault/mission-grant.ts`.
*
* The client posts `{ missionId, tables, recordIds, ttlMs? }`; the server
* derives + RSA-wraps a Mission Data Key and returns the grant blob.
* The webapp attaches this to `Mission.grant` via the normal sync path.
* The recovery / revocation side lives on the webapp revoking is just
* setting `Mission.grant = null` on the Dexie record; the server has
* nothing to remember.
*/
import { Hono, type Context } from 'hono';
import type { AuthUser } from '../middleware/jwt-auth';
import {
MissionGrantService,
MissionGrantNotConfigured,
ZeroKnowledgeGrantForbidden,
VaultNotFoundError,
} from '../services/encryption-vault/mission-grant';
import type { AuditContext } from '../services/encryption-vault';
type AppContext = Context<{ Variables: { user: AuthUser } }>;
export function createAiMissionGrantRoutes(service: MissionGrantService) {
const app = new Hono<{ Variables: { user: AuthUser } }>();
app.post('/', async (c) => {
const user = c.get('user');
const ctx = readAuditContext(c);
const body = (await c.req.json().catch(() => null)) as {
missionId?: unknown;
tables?: unknown;
recordIds?: unknown;
ttlMs?: unknown;
} | null;
if (
!body ||
typeof body.missionId !== 'string' ||
!body.missionId ||
!Array.isArray(body.tables) ||
!body.tables.every((t) => typeof t === 'string') ||
!Array.isArray(body.recordIds) ||
!body.recordIds.every((r) => typeof r === 'string')
) {
return c.json(
{
error: 'missionId (string), tables (string[]), recordIds (string[]) are required',
code: 'BAD_REQUEST',
},
400
);
}
const ttlMs = typeof body.ttlMs === 'number' ? body.ttlMs : undefined;
try {
const grant = await service.createGrant(
user.userId,
{
missionId: body.missionId,
tables: body.tables as string[],
recordIds: body.recordIds as string[],
ttlMs,
},
ctx
);
return c.json(grant);
} catch (err) {
if (err instanceof MissionGrantNotConfigured) {
return c.json(
{
error: 'mission grants are not configured on this server',
code: 'GRANT_NOT_CONFIGURED',
},
503
);
}
if (err instanceof ZeroKnowledgeGrantForbidden) {
return c.json(
{
error:
'mission grants are unavailable in zero-knowledge mode — disable ZK or use the foreground runner',
code: 'ZK_ACTIVE',
},
409
);
}
if (err instanceof VaultNotFoundError) {
return c.json({ error: 'vault not initialised', code: 'VAULT_NOT_INITIALISED' }, 404);
}
if (err instanceof Error && /required|must/.test(err.message)) {
return c.json({ error: err.message, code: 'BAD_REQUEST' }, 400);
}
throw err;
}
});
return app;
}
function readAuditContext(c: AppContext): AuditContext {
return {
ipAddress:
c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ||
c.req.header('x-real-ip') ||
undefined,
userAgent: c.req.header('user-agent') || undefined,
};
}

View file

@ -0,0 +1,207 @@
/**
* MissionGrantService unit tests.
*
* Crypto-only stubs the EncryptionVaultService so we don't need a
* real Postgres. Generates a fresh RSA-OAEP-2048 keypair per-test,
* exports the public key as SPKI PEM, feeds it into the service, then
* unwraps the returned grant with the private key and checks it matches
* the expected HKDF output.
*/
import { describe, it, expect } from 'bun:test';
import { deriveMissionDataKeyRaw, GRANT_DERIVATION_VERSION } from '@mana/shared-ai';
import {
MissionGrantService,
MissionGrantNotConfigured,
ZeroKnowledgeGrantForbidden,
} from './mission-grant';
import type { EncryptionVaultService, VaultFetchResult } from './index';
const fixedMasterKey = new Uint8Array(32).map((_, i) => i + 1);
/** The service zero-fills the returned masterKey after use, so each
* getMasterKey() call must return a fresh copy otherwise a second
* call in the same test would derive from all-zero bytes. */
function stubVault(result: VaultFetchResult): EncryptionVaultService {
return {
getMasterKey: async () => ({
...result,
masterKey: result.masterKey ? new Uint8Array(result.masterKey) : null,
}),
} as unknown as EncryptionVaultService;
}
async function genKeypair() {
const kp = await crypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256',
},
true,
['encrypt', 'decrypt']
);
const spki = new Uint8Array(await crypto.subtle.exportKey('spki', kp.publicKey));
const pem =
'-----BEGIN PUBLIC KEY-----\n' +
chunk(bytesToBase64(spki), 64).join('\n') +
'\n-----END PUBLIC KEY-----';
return { pem, privateKey: kp.privateKey };
}
describe('MissionGrantService', () => {
it('mints a grant whose wrappedKey unwraps to the derived MDK', async () => {
const { pem, privateKey } = await genKeypair();
const service = new MissionGrantService(
stubVault({ masterKey: new Uint8Array(fixedMasterKey), formatVersion: 1, kekId: 'env-v1' }),
pem
);
const grant = await service.createGrant('user-1', {
missionId: 'mission-abc',
tables: ['notes', 'tasks'],
recordIds: ['notes:n1', 'tasks:t1'],
});
expect(grant.derivation.version).toBe(GRANT_DERIVATION_VERSION);
expect(grant.derivation.missionId).toBe('mission-abc');
expect(grant.derivation.tables).toEqual(['notes', 'tasks']);
expect(grant.derivation.recordIds).toEqual(['notes:n1', 'tasks:t1']);
const wrappedBytes = base64ToBytes(grant.wrappedKey);
const plain = new Uint8Array(
await crypto.subtle.decrypt({ name: 'RSA-OAEP' }, privateKey, toBufferSource(wrappedBytes))
);
const expectedMdk = await deriveMissionDataKeyRaw(
new Uint8Array(fixedMasterKey),
grant.derivation
);
expect(Array.from(plain)).toEqual(Array.from(expectedMdk));
});
it('sorts tables and recordIds before binding into the key', async () => {
const { pem, privateKey } = await genKeypair();
const service = new MissionGrantService(
stubVault({ masterKey: new Uint8Array(fixedMasterKey), formatVersion: 1, kekId: 'env-v1' }),
pem
);
const a = await service.createGrant('u', {
missionId: 'm',
tables: ['tasks', 'notes'],
recordIds: ['tasks:t1', 'notes:n1'],
});
const b = await service.createGrant('u', {
missionId: 'm',
tables: ['notes', 'tasks'],
recordIds: ['notes:n1', 'tasks:t1'],
});
const keyA = new Uint8Array(
await crypto.subtle.decrypt(
{ name: 'RSA-OAEP' },
privateKey,
toBufferSource(base64ToBytes(a.wrappedKey))
)
);
const keyB = new Uint8Array(
await crypto.subtle.decrypt(
{ name: 'RSA-OAEP' },
privateKey,
toBufferSource(base64ToBytes(b.wrappedKey))
)
);
expect(Array.from(keyA)).toEqual(Array.from(keyB));
});
it('rejects zero-knowledge users', async () => {
const { pem } = await genKeypair();
const service = new MissionGrantService(
stubVault({
masterKey: null,
formatVersion: 1,
kekId: '',
requiresRecoveryCode: true,
recoveryWrappedMk: 'x',
recoveryIv: 'y',
}),
pem
);
await expect(
service.createGrant('u', { missionId: 'm', tables: ['notes'], recordIds: ['notes:n1'] })
).rejects.toBeInstanceOf(ZeroKnowledgeGrantForbidden);
});
it('throws MissionGrantNotConfigured when no public key is set', async () => {
const service = new MissionGrantService(
stubVault({ masterKey: new Uint8Array(fixedMasterKey), formatVersion: 1, kekId: 'env-v1' }),
undefined
);
await expect(
service.createGrant('u', { missionId: 'm', tables: ['notes'], recordIds: ['notes:n1'] })
).rejects.toBeInstanceOf(MissionGrantNotConfigured);
});
it('rejects missing tables / recordIds', async () => {
const { pem } = await genKeypair();
const service = new MissionGrantService(
stubVault({ masterKey: new Uint8Array(fixedMasterKey), formatVersion: 1, kekId: 'env-v1' }),
pem
);
await expect(
service.createGrant('u', { missionId: 'm', tables: [], recordIds: ['a'] })
).rejects.toThrow(/tables/);
await expect(
service.createGrant('u', { missionId: 'm', tables: ['notes'], recordIds: [] })
).rejects.toThrow(/recordIds/);
});
it('clamps ttl to the upper bound', async () => {
const { pem } = await genKeypair();
const service = new MissionGrantService(
stubVault({ masterKey: new Uint8Array(fixedMasterKey), formatVersion: 1, kekId: 'env-v1' }),
pem
);
const grant = await service.createGrant('u', {
missionId: 'm',
tables: ['notes'],
recordIds: ['notes:n1'],
ttlMs: 365 * 24 * 60 * 60 * 1000, // 1 year → clamped to 30d
});
const ttlMs = new Date(grant.expiresAt).getTime() - new Date(grant.issuedAt).getTime();
expect(ttlMs).toBe(30 * 24 * 60 * 60 * 1000);
});
});
// ─── helpers ─────────────────────────────────────────────
function bytesToBase64(bytes: Uint8Array): string {
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function toBufferSource(bytes: Uint8Array): ArrayBuffer {
const buf = new ArrayBuffer(bytes.length);
new Uint8Array(buf).set(bytes);
return buf;
}
function chunk(s: string, n: number): string[] {
const out: string[] = [];
for (let i = 0; i < s.length; i += n) out.push(s.slice(i, i + n));
return out;
}

View file

@ -0,0 +1,207 @@
/**
* MissionGrantService issues Key-Grants that let the `mana-ai`
* background runner decrypt scoped encrypted records without the
* user's browser being open.
*
* Flow:
* 1. Fetch the user's master key via the existing vault service.
* Zero-knowledge users return null grant is refused.
* 2. Derive a Mission Data Key (MDK) with the canonical HKDF from
* `@mana/shared-ai`. Scope (tables + recordIds) is cryptographically
* bound, so any scope change invalidates the grant automatically.
* 3. RSA-OAEP-2048-wrap the raw MDK bytes with the mana-ai public
* key. Only the paired private key (held in mana-ai's process
* memory) can unwrap.
* 4. Return the grant blob `{ wrappedKey, derivation, issuedAt,
* expiresAt }`. The route attaches it to `Mission.grant` via the
* webapp's normal sync write path.
*
* Why here and not in mana-ai?
* Only mana-auth has the KEK, the vault rows, and therefore the
* unwrapped master key. Everyone else either doesn't get the key
* at all (services) or gets it transiently on first login (webapp).
* Centralising the grant mint means one audit-logged path, not two.
*/
import {
deriveMissionDataKeyRaw,
GRANT_DERIVATION_VERSION,
type GrantDerivation,
type MissionGrant,
} from '@mana/shared-ai';
import { EncryptionVaultService, VaultNotFoundError, type AuditContext } from './index';
/** Default lifetime of a freshly-minted grant. User keeps a mission
* editing / ticking within this window grant stays fresh; long
* idle grant expires and the runner falls back to foreground. */
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
export interface CreateGrantInput {
missionId: string;
tables: string[];
recordIds: string[];
/** Override the default 7-day TTL. Upper-bounded by the service to
* stay below the rotation horizon. */
ttlMs?: number;
}
/** Thrown when the user is in zero-knowledge mode. The server has no
* usable master key cannot derive an MDK grant is impossible.
* Routes convert to 409 ZK_ACTIVE so the UI can fall back to the
* foreground runner without treating this as an error. */
export class ZeroKnowledgeGrantForbidden extends Error {
constructor(public userId: string) {
super(`cannot issue mission grant: user ${userId} is in zero-knowledge mode`);
this.name = 'ZeroKnowledgeGrantForbidden';
}
}
/** Thrown when the service boots without a configured mission-grant
* public key. Routes convert to 503 so the UI degrades cleanly to
* foreground-only execution. */
export class MissionGrantNotConfigured extends Error {
constructor() {
super('mana-auth: MANA_AI_PUBLIC_KEY_PEM is not set — grants are disabled');
this.name = 'MissionGrantNotConfigured';
}
}
export class MissionGrantService {
private pubKeyPromise: Promise<CryptoKey> | null = null;
constructor(
private vaultService: EncryptionVaultService,
private publicKeyPem: string | undefined
) {}
/** Mints a fresh grant for the given mission + scope. Idempotent in
* the sense that callers can invoke repeatedly to refresh the TTL
* each call produces a new `wrappedKey` with the same MDK (HKDF is
* deterministic) but fresh `issuedAt`/`expiresAt`. */
async createGrant(
userId: string,
input: CreateGrantInput,
ctx: AuditContext = {}
): Promise<MissionGrant> {
if (!this.publicKeyPem) {
throw new MissionGrantNotConfigured();
}
validateInput(input);
// VaultFetchResult with null masterKey means the user is in
// zero-knowledge mode. The server simply has no way to help — the
// user has to disable ZK first or stick to the foreground runner.
const vault = await this.vaultService.getMasterKey(userId, ctx);
if (!vault.masterKey) {
throw new ZeroKnowledgeGrantForbidden(userId);
}
const derivation: GrantDerivation = {
version: GRANT_DERIVATION_VERSION,
missionId: input.missionId,
tables: [...input.tables].sort(),
recordIds: [...input.recordIds].sort(),
};
let mdkBytes: Uint8Array | null = null;
try {
mdkBytes = await deriveMissionDataKeyRaw(vault.masterKey, derivation);
const pubKey = await this.loadPublicKey();
const ct = await crypto.subtle.encrypt(
{ name: 'RSA-OAEP' },
pubKey,
toBufferSource(mdkBytes)
);
const now = Date.now();
const ttl = clampTtl(input.ttlMs ?? DEFAULT_TTL_MS);
return {
wrappedKey: bytesToBase64(new Uint8Array(ct)),
derivation,
issuedAt: new Date(now).toISOString(),
expiresAt: new Date(now + ttl).toISOString(),
};
} finally {
if (mdkBytes) mdkBytes.fill(0);
vault.masterKey.fill(0);
}
}
/** Lazily parse the PEM once per process. Web Crypto doesn't speak PEM
* directly we strip the header/footer and decode the base64 DER. */
private loadPublicKey(): Promise<CryptoKey> {
if (!this.pubKeyPromise) {
this.pubKeyPromise = importRsaPublicKey(this.publicKeyPem!);
}
return this.pubKeyPromise;
}
}
// ─── Helpers ─────────────────────────────────────────────────
function validateInput(input: CreateGrantInput): void {
if (!input.missionId) throw new Error('missionId is required');
if (!Array.isArray(input.tables) || input.tables.length === 0) {
throw new Error('tables must be a non-empty array');
}
if (!Array.isArray(input.recordIds) || input.recordIds.length === 0) {
throw new Error('recordIds must be a non-empty array');
}
if (input.recordIds.length > 1000) {
// Hard cap so a pathological client can't blow up the HKDF info
// string. 1000 is ~50KB of info bytes which Web Crypto handles
// fine but we don't need more than that for any real mission.
throw new Error('recordIds must not exceed 1000 entries');
}
}
/** Clamp the requested TTL to [1h, 30d]. Below 1h is probably a bug;
* above 30d forces a re-consent eventually even for long-running
* missions. */
function clampTtl(ms: number): number {
const MIN = 60 * 60 * 1000;
const MAX = 30 * 24 * 60 * 60 * 1000;
if (ms < MIN) return MIN;
if (ms > MAX) return MAX;
return ms;
}
async function importRsaPublicKey(pem: string): Promise<CryptoKey> {
const body = pem
.replace(/-----BEGIN [^-]+-----/g, '')
.replace(/-----END [^-]+-----/g, '')
.replace(/\s+/g, '');
const der = base64ToBytes(body);
return crypto.subtle.importKey(
'spki',
toBufferSource(der),
{ name: 'RSA-OAEP', hash: 'SHA-256' },
false,
['encrypt']
);
}
function bytesToBase64(bytes: Uint8Array): string {
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function toBufferSource(bytes: Uint8Array): ArrayBuffer {
const buf = new ArrayBuffer(bytes.length);
new Uint8Array(buf).set(bytes);
return buf;
}
// Re-export VaultNotFoundError so the route can catch it from one import.
export { VaultNotFoundError };