harn-stdlib 0.10.30

Embedded Harn standard library source catalog
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
/**
 * std/oauth/storage — token storage backends for the OAuth client.
 *
 * Each backend factory returns a `Storage` record whose `get / set /
 * delete / with_refresh_lock` closures speak the same protocol
 * regardless of backend:
 *
 *     storage.get(key) -> TokenSet | nil
 *     storage.set(key, token_set, ttl_seconds = nil) -> nil
 *     storage.delete(key) -> nil
 *     storage.with_refresh_lock(key, { -> ... }) -> any
 *
 * The OAuth client and end users call those closures; this
 * module is responsible for dispatching them to the right backend.
 *
 * Backends:
 *   - memory()                              ephemeral, per-session
 *   - file(path, encryption_key)            local disk, AES-256-GCM
 *   - secrets({provider})                   `harness.secrets` host store
 *   - harn_cloud_session()                  cloud-managed, per-session
 *   - harn_cloud_org()                      cloud-managed, org-scoped
 *   - custom({get, set, delete, with_refresh_lock?}) user-supplied (vault, KMS)
 *
 * `storage()` returns the namespace dict matching the `OAuth.Storage.*`
 * shape from the OAuth epic: `memory` / `harn_cloud_*` as ready
 * handles, `file` / `secrets` / `custom` as factory closures.
 *
 * ## Choosing a backend: local vs cloud
 *
 * - `memory()` — tests and one-shot scripts; nothing survives the VM.
 * - `file(path, key)` — a single local process that owns its own
 *   encrypted token file (you manage the encryption key).
 * - `secrets({provider})` — a local harness that already authenticates
 *   through `harness.secrets` (OS keyring / env chain, or the same store
 *   `harn connect` writes). This is the drop-in for "I already have a
 *   secret host; stop hand-rolling a cache adapter per provider." It
 *   composes with `harn connect` by reading/writing the connector's
 *   canonical `<provider>/oauth-token` secret id.
 * - `harn_cloud_session()` / `harn_cloud_org()` — a hosted deployment
 *   where the embedder (cloud platform) owns tenant-scoped, durable,
 *   cross-worker storage and cross-process refresh locking.
 * - `custom({...})` — anything else (Vault, KMS, a bespoke DB).
 */
import { redact } from "std/oauth/redaction"

const __SECRETS_DEFAULT_TOKEN_NAME = "oauth-token"

const __SECRETS_TOKEN_FIELDS = [
  "access_token",
  "refresh_token",
  "expires_at_unix",
  "expires_in",
  "issued_at_unix",
  "token_type",
  "scopes",
  "issued_token_type",
  "token_exchange_mode",
  "subject_token_type",
  "actor_token_type",
  "requested_token_type",
]

/**
 * The canonical OAuth token envelope every storage backend serializes. It is
 * the public contract consumers annotate their own token variables with rather
 * than re-declaring the shape. Deterministic fields (`access_token`,
 * `expires_at_unix`, `token_type`, `scopes`) are named distinctly from the
 * open `metadata` bag; `...rest` carries forward-compatible and `harn connect`
 * provenance fields (e.g. `token_endpoint`, `client_id`) that a shared
 * canonical secret round-trips without this contract having to enumerate them.
 */
pub type TokenSet = {
  access_token: string,
  refresh_token?: string,
  expires_at_unix?: int,
  token_type?: string,
  scopes?: list<string>,
  metadata?: dict,
  ...rest,
}

/**
 * Storage is the handle dict returned by each backend factory. It
 * carries `kind` plus the storage closures (`get`, `set`, `delete`,
 * `with_refresh_lock`) and any backend-specific fields. Callers should
 * treat the rest as opaque.
 */
type Storage = dict

fn __wrap_host_handle(inner) {
  return inner
    + {
    _namespace: "oauth_storage",
    get: { key -> __oauth_storage_get(inner, key) },
    set: { key, token_set, ttl_seconds = nil ->
      __oauth_storage_set(inner, key, token_set, ttl_seconds)
    },
    delete: { key -> __oauth_storage_delete(inner, key) },
    with_refresh_lock: { key, body -> __oauth_storage_with_refresh_lock(inner, key, body) },
  }
}

/**
 * memory returns an ephemeral storage handle backed by an in-process
 * BTreeMap. Tokens stored here are lost when the VM exits.
 *
 * @effects: []
 * @errors: []
 * @api_stability: experimental
 */
pub fn memory() -> Storage {
  return __wrap_host_handle(__oauth_storage_memory_handle())
}

/**
 * file returns a storage handle that persists tokens to `path`,
 * encrypted with AES-256-GCM. The `encryption_key` may be any
 * high-entropy bytes/string — the runtime derives a 32-byte key via
 * HKDF-SHA256. Pass a value from a key-management system or
 * `crypto.random_bytes(32)` rather than a user passphrase.
 *
 * @effects: [file_io]
 * @errors: [invalid_argument]
 * @api_stability: experimental
 * @example: file("/var/lib/harn/tokens.bin", env("HARN_OAUTH_KEY"))
 */
pub fn file(path, encryption_key) -> Storage {
  if type_of(path) != "string" || path == "" {
    throw "std/oauth/storage: file path must be a non-empty string"
  }
  return __wrap_host_handle(__oauth_storage_file_handle(path, encryption_key))
}

/**
 * harn_cloud_session returns a handle routing through the cloud
 * `oauth_storage.cloud_*` host capability with `scope = "session"`.
 * The embedder (e.g. a cloud platform) is responsible for tenant-scoped
 * storage and per-session isolation.
 *
 * @effects: [host_call]
 * @errors: []
 * @api_stability: experimental
 */
pub fn harn_cloud_session() -> Storage {
  return __wrap_host_handle(__oauth_storage_cloud_handle("session"))
}

/**
 * harn_cloud_org returns a handle routing through the cloud
 * `oauth_storage.cloud_*` host capability with `scope = "org"`. The
 * embedder (e.g. a cloud platform) is responsible for org-scoped storage so
 * multiple agents in the same org share the same authenticated
 * client.
 *
 * @effects: [host_call]
 * @errors: []
 * @api_stability: experimental
 */
pub fn harn_cloud_org() -> Storage {
  return __wrap_host_handle(__oauth_storage_cloud_handle("org"))
}

/**
 * custom returns a handle that dispatches `get / set / delete` to
 * caller-supplied closures. Use this to integrate vaults, KMS, or
 * platform-native secret stores. `handlers` is a dict containing:
 *   - get:    fn(key) -> TokenSet|nil
 *   - set:    fn(key, token_set, ttl_seconds|nil) -> nil
 *   - delete: fn(key) -> nil
 *   - with_refresh_lock?: fn(key, body) -> any
 * The returned handle carries an optional `id` field (defaults to
 * `"custom"`) so multiple custom backends are distinguishable in
 * diagnostics. When `with_refresh_lock` is omitted, Harn still provides
 * an in-process lock for the custom handle id; durable cross-process
 * backends should provide their own hook so rotating refresh tokens are
 * single-flight across every worker that shares that backend.
 *
 * @effects: []
 * @errors: [invalid_argument]
 * @api_stability: experimental
 * @example: custom({get: my_get, set: my_set, delete: my_delete})
 */
pub fn custom(handlers) -> Storage {
  if type_of(handlers) != "dict" {
    throw "std/oauth/storage: custom handlers must be a dict"
  }
  const get_fn = handlers?.get
  const set_fn = handlers?.set
  const delete_fn = handlers?.delete
  const lock_fn = handlers?.with_refresh_lock
  if type_of(get_fn) != "closure" && type_of(get_fn) != "builtin" {
    throw "std/oauth/storage: custom.get must be a function"
  }
  if type_of(set_fn) != "closure" && type_of(set_fn) != "builtin" {
    throw "std/oauth/storage: custom.set must be a function"
  }
  if type_of(delete_fn) != "closure" && type_of(delete_fn) != "builtin" {
    throw "std/oauth/storage: custom.delete must be a function"
  }
  if lock_fn != nil && type_of(lock_fn) != "closure" && type_of(lock_fn) != "builtin" {
    throw "std/oauth/storage: custom.with_refresh_lock must be a function"
  }
  const id = trim(to_string(handlers?.id ?? "custom"))
  const base = {
    _namespace: "oauth_storage",
    kind: "custom",
    id: id,
    get: get_fn,
    set: set_fn,
    delete: delete_fn,
  }
  const with_lock = if lock_fn == nil {
    { key, body -> __oauth_storage_with_refresh_lock(base, key, body) }
  } else {
    { key, body -> __oauth_storage_with_refresh_lock(base, key, { -> lock_fn(key, body) }) }
  }
  return base + {with_refresh_lock: with_lock}
}

fn __secrets_split_scopes(text) {
  let out = []
  for raw in split(to_string(text), " ") {
    const part = trim(to_string(raw ?? ""))
    if part != "" {
      out = out + [part]
    }
  }
  return out
}

fn __secrets_is_missing(err) {
  // A caught `harness.secrets` NotFound means the secret is simply absent —
  // the storage protocol wants `nil`, not an error. A completely unbound
  // secret host is also `not_found`-categorized but is a configuration bug
  // the caller must see, so it is deliberately NOT treated as "missing".
  if type_of(err) != "dict" {
    return false
  }
  if err?.category != "not_found" {
    return false
  }
  const message = to_string(err?.message ?? "")
  return !contains(message, "no secret provider bound")
}

fn __secrets_secret_name(cfg, key) {
  // Map a storage key to a `harness.secrets` id. Absolute connector ids
  // (`namespace/name` or `harn-secret://…`) pass through verbatim so a
  // client can be pointed straight at an existing credential. The default
  // key (the client sets `storage_key` to the provider id) and the bare
  // token name both resolve to the canonical `<provider>/<token_name>` so
  // the backend composes with `harn connect` out of the box; any other key
  // becomes a distinct `<provider>/<key_prefix><key>` identity.
  const key_text = to_string(key ?? "")
  if contains(key_text, "/") {
    return key_text
  }
  const leaf = if key_text == "" || key_text == cfg.provider || key_text == cfg.token_name {
    cfg.token_name
  } else {
    cfg.key_prefix + key_text
  }
  return cfg.provider + "/" + leaf
}

fn __secrets_read_raw(cfg, name) {
  return try {
    harness.secrets.read(name, cfg.scope)
  } catch (err) {
    if __secrets_is_missing(err) {
      nil
    } else {
      throw err
    }
  }
}

fn __secrets_parse(cfg, name, raw) {
  return try {
    json_parse(raw)
  } catch (err) {
    // Never echo the raw secret bytes; redact the parser detail as a
    // defense-in-depth reuse of the OAuth redaction catalog.
    throw "std/oauth/storage: secrets value for `" + name
      + "` is not valid TokenSet JSON: "
      + redact(to_string(err?.message ?? err))
  }
}

fn __secrets_get(cfg, key) {
  const name = __secrets_secret_name(cfg, key)
  const raw = __secrets_read_raw(cfg, name)
  if raw == nil || trim(to_string(raw)) == "" {
    return nil
  }
  const parsed = __secrets_parse(cfg, name, raw)
  if type_of(parsed) != "dict" {
    return nil
  }
  // `harn connect` stores `scopes` as a space-joined string; normalize to
  // the list<string> the OAuth client expects.
  if type_of(parsed?.scopes) == "string" {
    return parsed + {scopes: __secrets_split_scopes(parsed.scopes)}
  }
  return parsed
}

fn __secrets_merge(prev, incoming) {
  if type_of(prev) != "dict" {
    return incoming
  }
  // Preserve non-token metadata already in the secret (e.g. the
  // `harn connect` fields token_endpoint / client_id / resource) so a
  // shared canonical secret round-trips; our fresh token fields win.
  const extras = __dict_omit(prev, __SECRETS_TOKEN_FIELDS)
  let out = extras + incoming
  // Never drop a refresh token on update: keep the stored one when the
  // new set omits it; a newly-issued refresh token rotates it out.
  const new_refresh = incoming?.refresh_token
  const prev_refresh = prev?.refresh_token
  if (new_refresh == nil || to_string(new_refresh) == "")
    && prev_refresh != nil
    && to_string(prev_refresh) != "" {
    out = out + {refresh_token: to_string(prev_refresh)}
  }
  return out
}

fn __secrets_set(cfg, key, token_set) {
  if type_of(token_set) != "dict" {
    throw "std/oauth/storage: secrets backend set expects a TokenSet dict"
  }
  const name = __secrets_secret_name(cfg, key)
  const prev = __secrets_get_stored(cfg, name)
  const merged = __secrets_merge(prev, token_set)
  // Deterministic serialization: `json_stringify` emits keys in sorted
  // order (dicts are ordered maps), so equal TokenSets encode identically.
  const payload = json_stringify(merged)
  // TTL is carried inside the TokenSet (`expires_at_unix`); canonical
  // secret stores (OS keyring) reject write-level TTLs, so none is set.
  const _ = harness.secrets.write(name, payload, cfg.scope)
  return nil
}

fn __secrets_get_stored(cfg, name) {
  const raw = __secrets_read_raw(cfg, name)
  if raw == nil || trim(to_string(raw)) == "" {
    return nil
  }
  return __secrets_parse(cfg, name, raw)
}

fn __secrets_delete(cfg, key) {
  const name = __secrets_secret_name(cfg, key)
  try {
    const _ = harness.secrets.delete(name, cfg.scope)
  } catch (err) {
    // Already-absent is the desired post-delete state.
    if !__secrets_is_missing(err) {
      throw err
    }
  }
  return nil
}

/**
 * secrets returns a storage handle backed by the ambient
 * `harness.secrets` host capability (OS keyring, env chain, or a
 * cloud-provided secret host — whatever the harness is bound to). It is
 * the drop-in for local harnesses that already authenticate through
 * `harness.secrets` and would otherwise hand-roll a cache/serialization
 * adapter per provider.
 *
 * `opts`:
 *   - provider:   required connector/provider id, e.g. "github". Used as
 *                 the secret-id namespace so tokens land under the same
 *                 canonical ids `harn connect` uses (`<provider>/…`).
 *   - token_name: canonical leaf name for the default single identity;
 *                 defaults to "oauth-token" (the connector's combined
 *                 token secret), so a one-identity client composes with
 *                 `harn connect` with no extra wiring.
 *   - key_prefix: prefix applied to additional named identities
 *                 (`storage_key` other than the provider id) so several
 *                 accounts can coexist under one provider. Default "".
 *   - scope:      `harness.secrets` scope (nil | string | dict) applied to
 *                 every read/write/delete for access-control + audit.
 *                 Default nil → the ambient tenant scope.
 *
 * Semantics:
 *   - Deterministic TokenSet serialization (sorted-key JSON).
 *   - Rotating refresh tokens are preserved: an update that omits a
 *     refresh token keeps the stored one; a newly-issued one rotates it.
 *   - Non-token fields already in a shared secret (e.g. `harn connect`
 *     metadata) are preserved on write.
 *   - Token material never appears in the handle or in diagnostics.
 *   - Refreshes are single-flight per (provider, key) in-process, so
 *     concurrent TTL / 401 / explicit refreshes coalesce. Durable
 *     cross-process single-flight requires a backend with its own lock
 *     (`file` / `harn_cloud_*`); the secret host exposes no lock
 *     primitive, so this backend coalesces within the process only.
 *
 * @effects: [host_call]
 * @errors: [invalid_argument]
 * @api_stability: experimental
 * @example: secrets({provider: "github"})
 */
pub fn secrets(opts) -> Storage {
  if type_of(opts) != "dict" {
    throw "std/oauth/storage: secrets opts must be a dict"
  }
  const provider = trim(to_string(opts?.provider ?? ""))
  if provider == "" {
    throw "std/oauth/storage: secrets opts.provider is required"
  }
  const token_name = trim(to_string(opts?.token_name ?? __SECRETS_DEFAULT_TOKEN_NAME))
  if token_name == "" {
    throw "std/oauth/storage: secrets opts.token_name must be non-empty"
  }
  const cfg = {
    provider: provider,
    token_name: token_name,
    key_prefix: to_string(opts?.key_prefix ?? ""),
    scope: opts?.scope,
    // In-process refresh lock; `kind: "custom"` routes the lock builtin
    // to its process-local mutex path (no file / cloud lease).
    lock_handle: {_namespace: "oauth_storage", kind: "custom", id: "secrets:" + provider},
  }
  return {
    _namespace: "oauth_storage",
    kind: "secrets",
    provider: provider,
    get: { key -> __secrets_get(cfg, key) },
    set: { key, token_set, ttl_seconds = nil -> __secrets_set(cfg, key, token_set) },
    delete: { key -> __secrets_delete(cfg, key) },
    with_refresh_lock: { key, body ->
      __oauth_storage_with_refresh_lock(cfg.lock_handle, key, body)
    },
  }
}

/**
 * storage returns the `OAuth.Storage.*` namespace dict matching the
 * epic API surface. `memory` / `harn_cloud_session` /
 * `harn_cloud_org` fields hold ready-to-use handles; `file` and
 * `custom` are factory closures.
 *
 * @effects: [host_call]
 * @errors: []
 * @api_stability: experimental
 * @example: storage().file("/var/lib/harn/tokens.bin", key)
 */
pub fn storage() {
  return {
    memory: memory(),
    file: { path, encryption_key -> file(path, encryption_key) },
    secrets: { opts -> secrets(opts) },
    harn_cloud_session: harn_cloud_session(),
    harn_cloud_org: harn_cloud_org(),
    custom: { handlers -> custom(handlers) },
  }
}