@astrale-os/sdk 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/auth/verify.d.ts +2 -0
  2. package/dist/auth/verify.d.ts.map +1 -1
  3. package/dist/auth/verify.js +81 -26
  4. package/dist/auth/verify.js.map +1 -1
  5. package/dist/cli/bin.d.ts +7 -0
  6. package/dist/cli/bin.d.ts.map +1 -0
  7. package/dist/cli/bin.js +15 -0
  8. package/dist/cli/bin.js.map +1 -0
  9. package/dist/cli/dotenv.d.ts +13 -0
  10. package/dist/cli/dotenv.d.ts.map +1 -0
  11. package/dist/cli/dotenv.js +46 -0
  12. package/dist/cli/dotenv.js.map +1 -0
  13. package/dist/cli/index.d.ts +15 -0
  14. package/dist/cli/index.d.ts.map +1 -0
  15. package/dist/cli/index.js +15 -0
  16. package/dist/cli/index.js.map +1 -0
  17. package/dist/cli/run.d.ts +79 -0
  18. package/dist/cli/run.d.ts.map +1 -0
  19. package/dist/cli/run.js +569 -0
  20. package/dist/cli/run.js.map +1 -0
  21. package/dist/cli/spec.d.ts +19 -0
  22. package/dist/cli/spec.d.ts.map +1 -0
  23. package/dist/cli/spec.js +31 -0
  24. package/dist/cli/spec.js.map +1 -0
  25. package/dist/config/adapter.d.ts +140 -0
  26. package/dist/config/adapter.d.ts.map +1 -0
  27. package/dist/config/adapter.js +40 -0
  28. package/dist/config/adapter.js.map +1 -0
  29. package/dist/config/define-domain.d.ts +112 -0
  30. package/dist/config/define-domain.d.ts.map +1 -0
  31. package/dist/config/define-domain.js +98 -0
  32. package/dist/config/define-domain.js.map +1 -0
  33. package/dist/config/deploy.d.ts +28 -0
  34. package/dist/config/deploy.d.ts.map +1 -0
  35. package/dist/config/deploy.js +24 -0
  36. package/dist/config/deploy.js.map +1 -0
  37. package/dist/config/index.d.ts +21 -0
  38. package/dist/config/index.d.ts.map +1 -0
  39. package/dist/config/index.js +18 -0
  40. package/dist/config/index.js.map +1 -0
  41. package/dist/define/remote-function.d.ts +19 -11
  42. package/dist/define/remote-function.d.ts.map +1 -1
  43. package/dist/define/remote-function.js.map +1 -1
  44. package/dist/dispatch/call-remote.d.ts +7 -3
  45. package/dist/dispatch/call-remote.d.ts.map +1 -1
  46. package/dist/dispatch/call-remote.js.map +1 -1
  47. package/dist/dispatch/dispatcher.d.ts.map +1 -1
  48. package/dist/dispatch/dispatcher.js +8 -4
  49. package/dist/dispatch/dispatcher.js.map +1 -1
  50. package/dist/dispatch/index.d.ts +1 -1
  51. package/dist/dispatch/index.d.ts.map +1 -1
  52. package/dist/dispatch/index.js.map +1 -1
  53. package/dist/dispatch/self.d.ts +46 -10
  54. package/dist/dispatch/self.d.ts.map +1 -1
  55. package/dist/dispatch/self.js +65 -8
  56. package/dist/dispatch/self.js.map +1 -1
  57. package/dist/domain/define.d.ts +3 -3
  58. package/dist/domain/define.js +3 -3
  59. package/dist/index.d.ts +5 -4
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +8 -2
  62. package/dist/index.js.map +1 -1
  63. package/dist/method/class.d.ts.map +1 -1
  64. package/dist/method/class.js.map +1 -1
  65. package/dist/method/context.d.ts +32 -7
  66. package/dist/method/context.d.ts.map +1 -1
  67. package/dist/method/index.d.ts +1 -1
  68. package/dist/method/index.d.ts.map +1 -1
  69. package/dist/method/single.d.ts +16 -11
  70. package/dist/method/single.d.ts.map +1 -1
  71. package/dist/method/single.js.map +1 -1
  72. package/dist/server/domain-entry.d.ts +67 -0
  73. package/dist/server/domain-entry.d.ts.map +1 -0
  74. package/dist/server/domain-entry.js +58 -0
  75. package/dist/server/domain-entry.js.map +1 -0
  76. package/dist/server/index.d.ts +3 -1
  77. package/dist/server/index.d.ts.map +1 -1
  78. package/dist/server/index.js +2 -1
  79. package/dist/server/index.js.map +1 -1
  80. package/dist/server/worker-entry.d.ts +57 -5
  81. package/dist/server/worker-entry.d.ts.map +1 -1
  82. package/dist/server/worker-entry.js +108 -24
  83. package/dist/server/worker-entry.js.map +1 -1
  84. package/package.json +12 -3
  85. package/src/auth/verify.ts +89 -28
  86. package/src/cli/bin.ts +15 -0
  87. package/src/cli/dotenv.ts +45 -0
  88. package/src/cli/index.ts +15 -0
  89. package/src/cli/run.ts +675 -0
  90. package/src/cli/spec.ts +42 -0
  91. package/src/config/adapter.ts +172 -0
  92. package/src/config/define-domain.ts +218 -0
  93. package/src/config/deploy.ts +35 -0
  94. package/src/config/index.ts +31 -0
  95. package/src/define/remote-function.ts +42 -13
  96. package/src/dispatch/call-remote.ts +7 -2
  97. package/src/dispatch/dispatcher.ts +8 -4
  98. package/src/dispatch/index.ts +1 -1
  99. package/src/dispatch/self.ts +96 -10
  100. package/src/domain/define.ts +3 -3
  101. package/src/index.ts +25 -4
  102. package/src/method/class.ts +4 -3
  103. package/src/method/context.ts +38 -7
  104. package/src/method/index.ts +1 -1
  105. package/src/method/single.ts +30 -11
  106. package/src/server/domain-entry.ts +113 -0
  107. package/src/server/index.ts +3 -1
  108. package/src/server/worker-entry.ts +122 -23
@@ -17,6 +17,8 @@ import type {
17
17
  import {
18
18
  CredentialMethodResolver,
19
19
  MethodRegistry,
20
+ SignatureVerificationError,
21
+ SigningKeyNotFoundError,
20
22
  verifyAudience,
21
23
  verifyCredential,
22
24
  } from '@astrale-os/kernel-core'
@@ -40,14 +42,72 @@ export type VerifiedInbound = {
40
42
 
41
43
  const methodResolver = new CredentialMethodResolver(new MethodRegistry())
42
44
 
45
+ // JWKS resolvers cached per JWKS URL (module-level, like the pools/selfIds
46
+ // Maps in kernel-client.ts). jose handles freshness WITHIN a resolver: 10min
47
+ // max-age, 30s fetch cooldown, and auto-refetch on kid-miss when not cooling
48
+ // down. The one gap — issuer restarts with new keys while the cooldown pins
49
+ // the old set (the incident that got a prior indefinite cache removed) — is
50
+ // covered by evict-and-retry-once in `verifyInboundCredential`, so indefinite
51
+ // Map residency is safe and the per-call JWKS fetch is gone.
52
+ const remoteResolvers = new Map<string, ReturnType<typeof createRemoteJWKSet>>()
53
+
54
+ // Self-issued credentials verify against the worker's own in-memory key;
55
+ // cache the local JWKS per canonical self-issuer (one key per worker) so the
56
+ // public-JWK derivation isn't redone on every request.
57
+ const localResolvers = new Map<string, ReturnType<typeof createLocalJWKSet>>()
58
+
59
+ /** Clear cached JWKS resolvers. Used in tests when keys rotate between fixtures. */
60
+ export function clearJwksCache(): void {
61
+ remoteResolvers.clear()
62
+ localResolvers.clear()
63
+ }
64
+
65
+ /**
66
+ * M-28 fix: a bare-slug iss (`mails.localhost`) makes
67
+ * `new URL('mails.localhost/.well-known/jwks.json')` throw "Invalid URL
68
+ * string". The dispatcher's identity map normalizes the iss it signs with,
69
+ * but inbound creds from older clients may still carry the slug form. Coerce
70
+ * to a URL with a default `https://` scheme (matches kernel.astrale.ai
71
+ * canonical form). If the actual receiver is on http://localhost the
72
+ * receiver-side resolver still works because both endpoints are on localhost
73
+ * — but for prod targets requiring TLS this is the right default.
74
+ */
75
+ function jwksUrlFor(issuer: string): string {
76
+ const normalized = /^https?:\/\//.test(issuer) ? issuer : `https://${issuer}`
77
+ return `${normalized}/.well-known/jwks.json`
78
+ }
79
+
80
+ function getRemoteResolver(jwksUrl: string): ReturnType<typeof createRemoteJWKSet> {
81
+ let resolver = remoteResolvers.get(jwksUrl)
82
+ if (!resolver) {
83
+ resolver = createRemoteJWKSet(new URL(jwksUrl))
84
+ remoteResolvers.set(jwksUrl, resolver)
85
+ }
86
+ return resolver
87
+ }
88
+
89
+ function getLocalResolver(
90
+ selfIssuer: string,
91
+ privateKey: RemoteIdentityConfig['privateKey'],
92
+ ): ReturnType<typeof createLocalJWKSet> {
93
+ let resolver = localResolvers.get(selfIssuer)
94
+ if (!resolver) {
95
+ resolver = createLocalJWKSet({ keys: [derivePublicJwk(privateKey) as JWK] })
96
+ localResolvers.set(selfIssuer, resolver)
97
+ }
98
+ return resolver
99
+ }
100
+
43
101
  /**
44
102
  * Build the key resolver for one verifying server. Captures `config` so it can
45
103
  * short-circuit the server's OWN issuer (`config.issuer`): a self-issued
46
104
  * credential is verified against the in-memory public key, never fetched — a
47
105
  * Worker can't fetch its own hostname, and it already holds the key. Every other
48
- * issuer is resolved live via JWKS (`createRemoteJWKSet`).
106
+ * issuer is resolved via the cached per-URL JWKS resolvers above; every JWKS
107
+ * URL touched is recorded in `resolvedJwksUrls` so the caller can evict
108
+ * exactly those resolvers if verification fails on an unknown signing key.
49
109
  */
50
- function makeResolveKeys(config: RemoteIdentityConfig) {
110
+ function makeResolveKeys(config: RemoteIdentityConfig, resolvedJwksUrls: Set<string>) {
51
111
  // The worker's own canonical iss. STRICT: `config.issuer` is the serving URL
52
112
  // by contract (both producers — buildIdentityMap / buildAuxIdentityMap — feed
53
113
  // it `canonicalizeServingUrl(config.url)`), so a value that doesn't parse is
@@ -57,13 +117,6 @@ function makeResolveKeys(config: RemoteIdentityConfig) {
57
117
  // into an opaque per-call failure.
58
118
  const selfIssuer = canonicalizeServingUrl(config.issuer)
59
119
 
60
- // TODO(cache): Re-add JWKS caching with a short TTL or kid-miss retry.
61
- // A prior implementation cached `createRemoteJWKSet` per issuer URL
62
- // indefinitely. jose's internal cache (30s cooldown, 10min max-age) caused
63
- // stale keys when the issuer restarted — the resolver served old keys and
64
- // jose refused to refetch within the cooldown window. On CF Workers the
65
- // module-level Map persisted across requests, making it worse. For now we
66
- // create a fresh resolver per call (jose deduplicates concurrent fetches).
67
120
  return async (issuer: IssuerId, _method: string, _kid?: string) => {
68
121
  const url = issuer as string
69
122
 
@@ -78,21 +131,13 @@ function makeResolveKeys(config: RemoteIdentityConfig) {
78
131
  canonical = undefined
79
132
  }
80
133
  if (canonical === selfIssuer) {
81
- return createLocalJWKSet({ keys: [derivePublicJwk(config.privateKey) as JWK] })
134
+ return getLocalResolver(selfIssuer, config.privateKey)
82
135
  }
83
136
  }
84
137
 
85
- // M-28 fix: a bare-slug iss (`mails.localhost`) makes
86
- // `new URL('mails.localhost/.well-known/jwks.json')` throw "Invalid
87
- // URL string". The dispatcher's identity map normalizes the iss it
88
- // signs with, but inbound creds from older clients may still carry
89
- // the slug form. Coerce to a URL with a default `https://` scheme
90
- // (matches kernel.astrale.ai canonical form). If the actual receiver
91
- // is on http://localhost the receiver-side resolver still works
92
- // because both endpoints are on localhost — but for prod targets
93
- // requiring TLS this is the right default.
94
- const normalized = /^https?:\/\//.test(url) ? url : `https://${url}`
95
- return createRemoteJWKSet(new URL(`${normalized}/.well-known/jwks.json`))
138
+ const jwksUrl = jwksUrlFor(url)
139
+ resolvedJwksUrls.add(jwksUrl)
140
+ return getRemoteResolver(jwksUrl)
96
141
  }
97
142
  }
98
143
 
@@ -106,13 +151,29 @@ export async function verifyInboundCredential(
106
151
  config: RemoteIdentityConfig,
107
152
  ): Promise<VerifiedInbound> {
108
153
  // Verify using kernel-core's credential verification pipeline
109
- const verified = await verifyCredential(
110
- {
111
- methodResolver,
112
- resolveKeys: makeResolveKeys(config),
113
- },
114
- credential,
115
- )
154
+ const resolvedJwksUrls = new Set<string>()
155
+ const deps = { methodResolver, resolveKeys: makeResolveKeys(config, resolvedJwksUrls) }
156
+ let verified: VerifiedCredential
157
+ try {
158
+ verified = await verifyCredential(deps, credential)
159
+ } catch (error) {
160
+ // A cached resolver can hold a stale key set after the issuer rotates:
161
+ // - new kid → jose kid-misses but its 30s fetch cooldown blocks the
162
+ // refetch (SigningKeyNotFoundError) — the incident that got a prior
163
+ // indefinite cache removed;
164
+ // - SAME kid, new key material (kernel kids derive from the subject, so
165
+ // a re-keyed issuer reuses its kid) → the signature check fails
166
+ // (SignatureVerificationError).
167
+ // Both: evict the resolver(s) this verification touched and retry ONCE
168
+ // with fresh ones. Forged-token spam thus costs at most one JWKS fetch
169
+ // per bad credential — equal to the uncached per-call baseline, never
170
+ // worse. An empty set means self-issued — refetching can't help, rethrow.
171
+ const staleKeySuspect =
172
+ error instanceof SigningKeyNotFoundError || error instanceof SignatureVerificationError
173
+ if (!staleKeySuspect || resolvedJwksUrls.size === 0) throw error
174
+ for (const url of resolvedJwksUrls) remoteResolvers.delete(url)
175
+ verified = await verifyCredential(deps, credential)
176
+ }
116
177
 
117
178
  // Validate audience matches this function's issuer (its serving URL).
118
179
  // kernel-core's verifyAudience compares canonically.
package/src/cli/bin.ts ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * `astrale-domain` bin entry. Runs under bun (native TS) so it can import the
4
+ * project's `astrale.config.ts` directly — matching the `astrale` CLI.
5
+ */
6
+
7
+ import { run } from './run'
8
+
9
+ run(process.argv.slice(2))
10
+ .then((code) => process.exit(code))
11
+ .catch((err: unknown) => {
12
+ process.stderr.write(`\x1b[31m✗\x1b[0m ${(err as Error).message ?? String(err)}\n`)
13
+ if (process.env.DEBUG) process.stderr.write(`${(err as Error).stack ?? ''}\n`)
14
+ process.exit(1)
15
+ })
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Minimal dotenv parser — the secrets-file boundary.
3
+ *
4
+ * `secrets: '.env.<env>'` in an adapter's params points a gitignored file whose
5
+ * entire contents are secrets. `loadDotenvFile` reads it into a flat
6
+ * `Record<string,string>` the CLI hands the adapter (injected into the local
7
+ * runtime in dev, pushed to a secret store in prod). No `process.env` mutation,
8
+ * no interpolation magic beyond `${VAR}` against earlier keys in the same file.
9
+ */
10
+
11
+ import { readFileSync } from 'node:fs'
12
+
13
+ export function parseDotenv(contents: string): Record<string, string> {
14
+ const out: Record<string, string> = {}
15
+ for (const raw of contents.split('\n')) {
16
+ const line = raw.trim()
17
+ if (!line || line.startsWith('#')) continue
18
+ const match = /^(?:export\s+)?([A-Za-z_]\w*)\s*=\s*(.*)$/.exec(line)
19
+ if (!match) continue
20
+ const [, key, rawValue] = match
21
+ let value = rawValue.trim()
22
+ // Single quotes mean a LITERAL value (standard dotenv): no `${VAR}`
23
+ // interpolation, so a secret that legitimately contains a literal `${...}`
24
+ // (e.g. a password) survives intact instead of being silently blanked.
25
+ const singleQuoted = value.length >= 2 && value.startsWith("'") && value.endsWith("'")
26
+ if ((value.length >= 2 && value.startsWith('"') && value.endsWith('"')) || singleQuoted) {
27
+ value = value.slice(1, -1)
28
+ }
29
+ out[key] = singleQuoted
30
+ ? value
31
+ : value.replace(/\$\{(\w+)\}/g, (_, name: string) => out[name] ?? '')
32
+ }
33
+ return out
34
+ }
35
+
36
+ /** Read + parse a dotenv file. Returns `{}` if the file is absent (CI-safe). */
37
+ export function loadDotenvFile(path: string): Record<string, string> {
38
+ let contents: string
39
+ try {
40
+ contents = readFileSync(path, 'utf-8')
41
+ } catch {
42
+ return {}
43
+ }
44
+ return parseDotenv(contents)
45
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `@astrale-os/sdk/cli` — the `astrale-domain` CLI (dev | build | deploy) and
3
+ * the dotenv secrets-file boundary, folded in from the former
4
+ * `@astrale-os/devkit` package.
5
+ *
6
+ * Node-only: the CLI imports `node:fs`/`node:module`/`node:url` and runs under
7
+ * Bun (it imports the project's `astrale.config.ts` directly). This subpath is
8
+ * deliberately NOT re-exported from the package barrel — pulling it into the
9
+ * isomorphic `.` entry would poison browser/worker bundlers that traverse every
10
+ * re-export (the same rule that keeps `./server` and `./deploy` off the barrel).
11
+ * The bin lives at `./bin`.
12
+ */
13
+
14
+ export { run, parseArgs } from './run'
15
+ export { parseDotenv, loadDotenvFile } from './dotenv'