@fuzdev/fuz_app 0.109.0 → 0.110.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/CLAUDE.md +6 -5
- package/dist/auth/account_action_specs.d.ts +18 -0
- package/dist/auth/account_action_specs.d.ts.map +1 -1
- package/dist/auth/account_action_specs.js +14 -1
- package/dist/auth/account_actions.d.ts.map +1 -1
- package/dist/auth/account_actions.js +6 -2
- package/dist/auth/bootstrap_account.d.ts +7 -2
- package/dist/auth/bootstrap_account.d.ts.map +1 -1
- package/dist/auth/bootstrap_account.js +9 -4
- package/dist/auth/bootstrap_routes.d.ts +12 -4
- package/dist/auth/bootstrap_routes.d.ts.map +1 -1
- package/dist/auth/bootstrap_routes.js +15 -6
- package/dist/auth/daemon_token_middleware.d.ts +7 -69
- package/dist/auth/daemon_token_middleware.d.ts.map +1 -1
- package/dist/auth/daemon_token_middleware.js +8 -104
- package/dist/auth/deps.d.ts +9 -5
- package/dist/auth/deps.d.ts.map +1 -1
- package/dist/auth/middleware.js +1 -1
- package/dist/auth/request_context.d.ts +1 -3
- package/dist/auth/request_context.d.ts.map +1 -1
- package/dist/auth/request_context.js +2 -5
- package/dist/auth/session_cookie.d.ts +18 -36
- package/dist/auth/session_cookie.d.ts.map +1 -1
- package/dist/auth/session_cookie.js +16 -26
- package/dist/auth/session_middleware.d.ts +1 -1
- package/dist/auth/session_middleware.d.ts.map +1 -1
- package/dist/auth/session_middleware.js +1 -4
- package/dist/auth/session_queries.d.ts +14 -30
- package/dist/auth/session_queries.d.ts.map +1 -1
- package/dist/auth/session_queries.js +15 -45
- package/dist/auth/token_lifetime.d.ts +51 -0
- package/dist/auth/token_lifetime.d.ts.map +1 -0
- package/dist/auth/token_lifetime.js +54 -0
- package/dist/dev/setup.d.ts +5 -9
- package/dist/dev/setup.d.ts.map +1 -1
- package/dist/dev/setup.js +18 -21
- package/dist/runtime/deno.d.ts.map +1 -1
- package/dist/runtime/deno.js +27 -1
- package/dist/runtime/deps.d.ts +38 -4
- package/dist/runtime/deps.d.ts.map +1 -1
- package/dist/runtime/fs.d.ts +24 -5
- package/dist/runtime/fs.d.ts.map +1 -1
- package/dist/runtime/fs.js +38 -7
- package/dist/runtime/mock.d.ts +7 -0
- package/dist/runtime/mock.d.ts.map +1 -1
- package/dist/runtime/mock.js +41 -1
- package/dist/runtime/node.d.ts.map +1 -1
- package/dist/runtime/node.js +7 -1
- package/dist/runtime/secure_file.d.ts +71 -0
- package/dist/runtime/secure_file.d.ts.map +1 -0
- package/dist/runtime/secure_file.js +125 -0
- package/dist/server/app_backend.d.ts +6 -5
- package/dist/server/app_backend.d.ts.map +1 -1
- package/dist/server/app_backend.js +2 -3
- package/dist/testing/CLAUDE.md +15 -1
- package/dist/testing/admin_integration.d.ts.map +1 -1
- package/dist/testing/admin_integration.js +10 -2
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +13 -8
- package/dist/testing/audit_completeness.d.ts.map +1 -1
- package/dist/testing/audit_completeness.js +5 -1
- package/dist/testing/cross_backend/cell_grant_role.d.ts.map +1 -1
- package/dist/testing/cross_backend/cell_grant_role.js +34 -1
- package/dist/testing/cross_backend/conformance_table.d.ts.map +1 -1
- package/dist/testing/cross_backend/conformance_table.js +2 -1
- package/dist/testing/cross_backend/setup.js +1 -1
- package/dist/testing/cross_backend/token_lifetime.d.ts +15 -0
- package/dist/testing/cross_backend/token_lifetime.d.ts.map +1 -0
- package/dist/testing/cross_backend/token_lifetime.js +99 -0
- package/dist/testing/cross_backend/token_scope_surface.js +1 -1
- package/dist/testing/daemon_token_rotation.d.ts +83 -0
- package/dist/testing/daemon_token_rotation.d.ts.map +1 -0
- package/dist/testing/daemon_token_rotation.js +99 -0
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +15 -3
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +4 -4
- package/dist/ui/AccountSessions.svelte +0 -5
- package/dist/ui/AccountSessions.svelte.d.ts.map +1 -1
- package/dist/ui/AdminOverview.svelte +7 -4
- package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
- package/dist/ui/AdminSessions.svelte +0 -5
- package/dist/ui/AdminSessions.svelte.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/auth/CLAUDE.md
CHANGED
|
@@ -16,7 +16,7 @@ documents the cross-cutting invariants that don't fit on any single symbol.
|
|
|
16
16
|
|
|
17
17
|
## AppDeps split
|
|
18
18
|
|
|
19
|
-
- **Capabilities** — `AppDeps` — stateless, injectable per env: `
|
|
19
|
+
- **Capabilities** — `AppDeps` — stateless, injectable per env: `read_secure_file` (hardened bootstrap-token read), `delete_file`, `keyring`, `password`, `db`, `log`, `audit`.
|
|
20
20
|
- **Route caps** — `RouteFactoryDeps` — `Omit<AppDeps, 'db'>`; handlers get `db` via `RouteContext`.
|
|
21
21
|
- **Action caps** — `ActionFactoryDeps` (`auth/deps.ts`) — the `{log, audit}` shape action factories take; `RouteFactoryDeps`/`AppDeps` satisfy it structurally (role-grant-offer adds `notification_sender?`). The two pure-read actor factories (`actor_lookup`/`actor_search`) take only `{log: Logger}`.
|
|
22
22
|
- **Parameters** — `*Options` — static startup values, per-factory.
|
|
@@ -32,7 +32,7 @@ over the pool so rows persist when request transactions roll back. See root
|
|
|
32
32
|
### Crypto primitives (pure, I/O-free)
|
|
33
33
|
|
|
34
34
|
- `auth/keyring.ts` — `Keyring`, `create_keyring`, `validate_keyring`, `create_validated_keyring`.
|
|
35
|
-
- `auth/session_cookie.ts` — `SessionOptions<T>`, `parse_session`, `process_session_cookie
|
|
35
|
+
- `auth/session_cookie.ts` — `SessionOptions<T>`, `parse_session`, `process_session_cookie` (`'none' | 'clear'` — no refresh action; the 30-day lifetime is an absolute cap signed once at mint), `create_session_config`, `fuz_session_config`, `SESSION_AGE_MAX`.
|
|
36
36
|
- `auth/password.ts` — `Password`, `PasswordProvided`, `PasswordHashDeps`, `PASSWORD_LENGTH_MIN` (12, OWASP), `PASSWORD_LENGTH_MAX` (300).
|
|
37
37
|
- `auth/password_argon2.ts` — `hash_password`, `verify_password`, `verify_dummy`, `argon2_password_deps`.
|
|
38
38
|
- `auth/api_token.ts` — `API_TOKEN_PREFIX` (`secret_fuz_token_`), `hash_api_token`, `generate_api_token`.
|
|
@@ -61,6 +61,7 @@ Convention — `*_schema.ts` is Zod-only; `*_ddl.ts` holds DDL strings.
|
|
|
61
61
|
- `auth/scope_kind_schema.ts` — `ScopeKindName`, `create_scope_kind_schema` (open registry, no builtins).
|
|
62
62
|
- `auth/credential_type_schema.ts` — `CredentialTypeName`, `CREDENTIAL_TYPE_SESSION` / `_API_TOKEN` / `_DAEMON_TOKEN`, `create_credential_type_schema`.
|
|
63
63
|
- `auth/grant_path_schema.ts` — `GrantPathName`, `GRANT_PATH_ADMIN` / `_SELF_SERVICE` / `_SYSTEM` / `_BOOTSTRAP`, `create_grant_path_schema`.
|
|
64
|
+
- `auth/token_lifetime.ts` — the temporal axis of a minted token: `TokenLifetimeInput` (required union on `account_token_create` — `{kind:'eternal'}` or `{kind:'ttl', days}`), `token_lifetime_to_expires_at`, `TOKEN_TTL_DAYS_MAX`. No default — `expires_at IS NULL` always means "deliberately eternal".
|
|
64
65
|
- `auth/token_scope.ts` — per-credential authority narrowing stored on `api_token.scope`: `TokenScope` / `TokenScopeInput` + constructors, `parse_stored_token_scope` (fail-closed), `token_scope_admits_method` / `_non_rpc`, and the `<section>:<id>` capability vocabulary `RouteAuth.required_scope` is written in (`parse_token_scope_capability`, `token_scope_admits_capability`, `token_scope_denied_body`, `TOKEN_SURFACES`). Not an open registry — see ../../../docs/security.md §Token scoping for why the identifier half is deliberately unregistered.
|
|
65
66
|
- `auth/auth_ddl.ts` — `CREATE TABLE` / index / seed strings for the core identity tables.
|
|
66
67
|
- `auth/audit_log_schema.ts` — `AUDIT_EVENT_TYPES` (27 builtins), `AuditEventType` / `AuditEventTypeName`, `audit_metadata_schemas`, `AuditLogEvent`, `AuditLogInput`, `AuditLogConfig`, `create_audit_log_config`.
|
|
@@ -80,7 +81,7 @@ All take `deps: QueryDeps = {db}` first; `query_validate_api_token` adds `log`.
|
|
|
80
81
|
- `auth/actor_search_queries.ts` — case-insensitive prefix search on `actor.name`, scope-filtered when not admin.
|
|
81
82
|
- `auth/role_grant_queries.ts` — idempotent create, IDOR-guarded revoke (with in-tx supersede), scope-aware lookup, role/account predicates, `query_role_grant_revoke_for_scope` parent-scope cascade.
|
|
82
83
|
- `auth/role_grant_offer_queries.ts` — offer create/decline/retract/list/history/sweep, atomic `query_accept_offer` with sibling supersede; error classes `RoleGrantOfferSelfTargetError` / `_AlreadyTerminalError` / `_ExpiredError` / `_NotFoundError` / `_ActorAccountMismatchError` / `_ActorMismatchError`.
|
|
83
|
-
- `auth/session_queries.ts` — server-side sessions (blake3-hashed), `query_session_revoke_by_hash_unscoped` (logout only), `query_session_enforce_limit` (transaction-required).
|
|
84
|
+
- `auth/session_queries.ts` — server-side sessions (blake3-hashed), `query_session_revoke_by_hash_unscoped` (logout only), `query_session_enforce_limit` (transaction-required). No touch/renewal query — `expires_at` is an absolute cap set at mint (`AUTH_SESSION_LIFETIME_MS`), and `last_seen_at` is decorative pending its own removal migration.
|
|
84
85
|
- `auth/api_token_queries.ts` — token validation with fire-and-forget usage tracking, IDOR-guarded revoke, `query_api_token_enforce_limit` (transaction-required).
|
|
85
86
|
- `auth/invite_queries.ts` — invite create/find/claim/list/delete; `query_invite_claim_unscoped` (scoping enforced upstream by `_find_unclaimed_match_for_update`, which runs inside the signup tx with `FOR UPDATE` so find + claim are atomic).
|
|
86
87
|
- `auth/app_settings_queries.ts` — load/update for the single-row settings table.
|
|
@@ -162,7 +163,7 @@ Everything else listed under §RPC action surfaces.
|
|
|
162
163
|
- `auth/request_context.ts` — `RequestContext`, `resolve_acting_actor`, `build_request_context`, predicates (`has_role`, `has_scoped_role`, `has_any_scoped_role`), guards (`require_auth`, `require_role`, `require_credential_types`, `require_token_scope`), `token_scope_surface_denial` (the direct-call form, for surfaces that aren't route specs), `refresh_role_grants`.
|
|
163
164
|
- `auth/session_middleware.ts` — `process_session_cookie` integration, `create_session_and_set_cookie` (shared by login / signup / bootstrap).
|
|
164
165
|
- `auth/bearer_auth.ts` — soft-fail bearer middleware; rejects when `Origin` or `Referer` present (browser context).
|
|
165
|
-
- `auth/daemon_token_middleware.ts` — `
|
|
166
|
+
- `auth/daemon_token_middleware.ts` — `create_daemon_token_middleware(state, deps, log)` — the credential **consumer** only (soft-fail validation, keeper account resolution). The producer (`write_daemon_token` / `start_daemon_token_rotation`) lives in `testing/daemon_token_rotation.ts` behind the dev-env guard — no production assembly mints daemon tokens, mirroring the Rust spine's `fuz_testing`-confined producer. Soft-fails — discards the credential (pass-through, no own 401/503) on **every** non-success path: browser context (`Origin`/`Referer` present), malformed/invalid token, and valid-token-but-no-keeper all `next()` through to the dispatcher's `credential_type_required` (403) gate, mirroring the bearer guard and the Rust spine's `resolve.rs`. Daemon tokens are loopback-only, so browser context never arises in practice — the discard is defense-in-depth.
|
|
166
167
|
|
|
167
168
|
See root ../../../CLAUDE.md §Middleware Ordering for canonical assembly
|
|
168
169
|
order. The auth-specific invariants are described below in §Cross-cutting
|
|
@@ -638,7 +639,7 @@ Options: `roles?: RoleSchemaResult` (drives admin-grant-path lookup),
|
|
|
638
639
|
- `account_session_list_action_spec` — read; input `z.void()`; output `{sessions}`.
|
|
639
640
|
- `account_session_revoke_action_spec` — mutation; input `{session_id}`; output `{ok, revoked}`.
|
|
640
641
|
- `account_session_revoke_all_action_spec` — mutation; input `z.void()`; output `{ok, count}`.
|
|
641
|
-
- `account_token_create_action_spec` — mutation; input `{name
|
|
642
|
+
- `account_token_create_action_spec` — mutation; input `{name?, scope, lifetime}` (both `scope` and `lifetime` required — default-deny at mint on both axes); output `{ok, token, id, name, expires_at}`.
|
|
642
643
|
- `account_token_list_action_spec` — read; input `z.void()`; output `{tokens}`.
|
|
643
644
|
- `account_token_revoke_action_spec` — mutation; input `{token_id}`; output `{ok, revoked}`.
|
|
644
645
|
|
|
@@ -55,6 +55,10 @@ export type SessionRevokeAllOutput = z.infer<typeof SessionRevokeAllOutput>;
|
|
|
55
55
|
* reversal of the 2026-02 design, where the field existed but nothing depended
|
|
56
56
|
* on it. Because `scope` has no default, the object as a whole no longer
|
|
57
57
|
* carries `.prefault({})`.
|
|
58
|
+
*
|
|
59
|
+
* `lifetime` follows the same rule — a never-expiring token is
|
|
60
|
+
* `{kind: 'eternal'}`, spelled out, so `expires_at IS NULL` always means
|
|
61
|
+
* "deliberately eternal" (see `auth/token_lifetime.ts`).
|
|
58
62
|
*/
|
|
59
63
|
export declare const TokenCreateInput: z.ZodObject<{
|
|
60
64
|
name: z.ZodDefault<z.ZodString>;
|
|
@@ -64,6 +68,12 @@ export declare const TokenCreateInput: z.ZodObject<{
|
|
|
64
68
|
kind: z.ZodLiteral<"methods">;
|
|
65
69
|
methods: z.ZodArray<z.ZodString>;
|
|
66
70
|
}, z.core.$strict>], "kind">;
|
|
71
|
+
lifetime: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
72
|
+
kind: z.ZodLiteral<"eternal">;
|
|
73
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
74
|
+
kind: z.ZodLiteral<"ttl">;
|
|
75
|
+
days: z.ZodNumber;
|
|
76
|
+
}, z.core.$strict>], "kind">;
|
|
67
77
|
}, z.core.$strict>;
|
|
68
78
|
export type TokenCreateInput = z.infer<typeof TokenCreateInput>;
|
|
69
79
|
/** Output for `account_token_create`. `token` is returned exactly once. */
|
|
@@ -72,6 +82,7 @@ export declare const TokenCreateOutput: z.ZodObject<{
|
|
|
72
82
|
token: z.ZodString;
|
|
73
83
|
id: z.ZodString;
|
|
74
84
|
name: z.ZodString;
|
|
85
|
+
expires_at: z.ZodNullable<z.ZodString>;
|
|
75
86
|
}, z.core.$strict>;
|
|
76
87
|
export type TokenCreateOutput = z.infer<typeof TokenCreateOutput>;
|
|
77
88
|
/** Input for `account_token_list`. No parameters. */
|
|
@@ -210,12 +221,19 @@ export declare const account_token_create_action_spec: {
|
|
|
210
221
|
kind: z.ZodLiteral<"methods">;
|
|
211
222
|
methods: z.ZodArray<z.ZodString>;
|
|
212
223
|
}, z.core.$strict>], "kind">;
|
|
224
|
+
lifetime: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
225
|
+
kind: z.ZodLiteral<"eternal">;
|
|
226
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
227
|
+
kind: z.ZodLiteral<"ttl">;
|
|
228
|
+
days: z.ZodNumber;
|
|
229
|
+
}, z.core.$strict>], "kind">;
|
|
213
230
|
}, z.core.$strict>;
|
|
214
231
|
output: z.ZodObject<{
|
|
215
232
|
ok: z.ZodLiteral<true>;
|
|
216
233
|
token: z.ZodString;
|
|
217
234
|
id: z.ZodString;
|
|
218
235
|
name: z.ZodString;
|
|
236
|
+
expires_at: z.ZodNullable<z.ZodString>;
|
|
219
237
|
}, z.core.$strict>;
|
|
220
238
|
async: true;
|
|
221
239
|
description: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"account_action_specs.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_action_specs.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;
|
|
1
|
+
{"version":3,"file":"account_action_specs.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_action_specs.ts"],"names":[],"mappings":"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"}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { z } from 'zod';
|
|
11
11
|
import { TokenScopeInput } from './token_scope.js';
|
|
12
|
+
import { TokenLifetimeInput } from './token_lifetime.js';
|
|
12
13
|
import { AuthSessionJson, ClientApiTokenJson, SessionAccountJson, SessionId } from './account_schema.js';
|
|
13
14
|
import { ApiTokenId } from './api_token.js';
|
|
14
15
|
// -- Input/output schemas ---------------------------------------------------
|
|
@@ -45,6 +46,10 @@ export const SessionRevokeAllOutput = z.strictObject({
|
|
|
45
46
|
* reversal of the 2026-02 design, where the field existed but nothing depended
|
|
46
47
|
* on it. Because `scope` has no default, the object as a whole no longer
|
|
47
48
|
* carries `.prefault({})`.
|
|
49
|
+
*
|
|
50
|
+
* `lifetime` follows the same rule — a never-expiring token is
|
|
51
|
+
* `{kind: 'eternal'}`, spelled out, so `expires_at IS NULL` always means
|
|
52
|
+
* "deliberately eternal" (see `auth/token_lifetime.ts`).
|
|
48
53
|
*/
|
|
49
54
|
export const TokenCreateInput = z.strictObject({
|
|
50
55
|
name: z
|
|
@@ -55,6 +60,10 @@ export const TokenCreateInput = z.strictObject({
|
|
|
55
60
|
description: "What the token may do. `{kind:'full'}` for full account authority, or " +
|
|
56
61
|
"`{kind:'methods',methods:[…]}` to narrow it to named RPC methods — a " +
|
|
57
62
|
'narrowed token is RPC-only and reaches no non-RPC surface.'
|
|
63
|
+
}),
|
|
64
|
+
lifetime: TokenLifetimeInput.meta({
|
|
65
|
+
description: "How long the token lives. `{kind:'eternal'}` never expires; " +
|
|
66
|
+
"`{kind:'ttl',days:N}` expires N days from mint."
|
|
58
67
|
})
|
|
59
68
|
});
|
|
60
69
|
/** Output for `account_token_create`. `token` is returned exactly once. */
|
|
@@ -62,7 +71,11 @@ export const TokenCreateOutput = z.strictObject({
|
|
|
62
71
|
ok: z.literal(true),
|
|
63
72
|
token: z.string().meta({ description: 'Raw token — shown once, store securely.' }),
|
|
64
73
|
id: ApiTokenId,
|
|
65
|
-
name: z.string()
|
|
74
|
+
name: z.string(),
|
|
75
|
+
expires_at: z
|
|
76
|
+
.string()
|
|
77
|
+
.nullable()
|
|
78
|
+
.meta({ description: 'ISO 8601 expiry, or `null` for an eternal token.' })
|
|
66
79
|
});
|
|
67
80
|
/** Input for `account_token_list`. No parameters. */
|
|
68
81
|
export const TokenListInput = z.void();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"account_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAAsC,KAAK,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAC9F,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;
|
|
1
|
+
{"version":3,"file":"account_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAAsC,KAAK,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAC9F,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;AAqBxE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAwBnD,4CAA4C;AAC5C,MAAM,WAAW,oBAAoB;IACpC;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B;;;;;;;;;;OAUG;IACH,iBAAiB,CAAC,EAAE,gBAAgB,GAAG,IAAI,CAAC;CAC5C;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB,GAClC,MAAM,iBAAiB,EACvB,UAAS,oBAAyB,KAChC,KAAK,CAAC,SAAS,CAgKjB,CAAC"}
|
|
@@ -27,6 +27,7 @@ import { to_session_account } from './account_schema.js';
|
|
|
27
27
|
import { query_session_list_for_account, query_session_revoke_for_account, query_session_revoke_all_for_account } from './session_queries.js';
|
|
28
28
|
import { query_api_token_enforce_limit, query_api_token_list_for_account, query_create_api_token, query_revoke_api_token_for_account } from './api_token_queries.js';
|
|
29
29
|
import { parse_stored_token_scope, token_scope_from_input, token_scope_label } from './token_scope.js';
|
|
30
|
+
import { token_lifetime_to_expires_at } from './token_lifetime.js';
|
|
30
31
|
import { generate_api_token } from './api_token.js';
|
|
31
32
|
import { DEFAULT_MAX_TOKENS } from './account_route_schema.js';
|
|
32
33
|
import { account_verify_action_spec, account_session_list_action_spec, account_session_revoke_action_spec, account_session_revoke_all_action_spec, account_token_create_action_spec, account_token_list_action_spec, account_token_revoke_action_spec } from './account_action_specs.js';
|
|
@@ -102,7 +103,10 @@ export const create_account_actions = (deps, options = {}) => {
|
|
|
102
103
|
// There is deliberately no permissive fallback here: an omitted scope is
|
|
103
104
|
// a validation error upstream, not a full-authority token.
|
|
104
105
|
const scope = token_scope_from_input(input.scope);
|
|
105
|
-
|
|
106
|
+
// `input.lifetime` is required for the same reason — an omitted lifetime
|
|
107
|
+
// is a validation error, never an eternal token.
|
|
108
|
+
const expires_at = token_lifetime_to_expires_at(input.lifetime);
|
|
109
|
+
await query_create_api_token(ctx, id, ctx.auth.account.id, input.name, token_hash, scope, expires_at);
|
|
106
110
|
if (max_tokens != null) {
|
|
107
111
|
await query_api_token_enforce_limit(ctx, ctx.auth.account.id, max_tokens);
|
|
108
112
|
}
|
|
@@ -116,7 +120,7 @@ export const create_account_actions = (deps, options = {}) => {
|
|
|
116
120
|
credential_type: ctx.credential_type ?? undefined
|
|
117
121
|
}
|
|
118
122
|
});
|
|
119
|
-
return { ok: true, token, id, name: input.name };
|
|
123
|
+
return { ok: true, token, id, name: input.name, expires_at: expires_at?.toISOString() ?? null };
|
|
120
124
|
};
|
|
121
125
|
const token_list_handler = async (_input, ctx) => {
|
|
122
126
|
const rows = await query_api_token_list_for_account(ctx, ctx.auth.account.id);
|
|
@@ -51,8 +51,13 @@ export interface BootstrapAccountDeps {
|
|
|
51
51
|
db: Db;
|
|
52
52
|
/** Path to the bootstrap token file on disk. */
|
|
53
53
|
token_path: string;
|
|
54
|
-
/**
|
|
55
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Hardened secret-file read (see `FsSecureReadDeps.read_secure_file`) —
|
|
56
|
+
* the token mints the keeper account, so a symlinked, group/other-readable,
|
|
57
|
+
* or oversized file must refuse rather than be honored. Throws on refusal;
|
|
58
|
+
* every throw reads as `TOKEN_FILE_MISSING` upstream.
|
|
59
|
+
*/
|
|
60
|
+
read_secure_file: (path: string) => Promise<Uint8Array>;
|
|
56
61
|
/** Delete a file. */
|
|
57
62
|
delete_file: (path: string) => Promise<void>;
|
|
58
63
|
/** Only hashing is needed — verification happens separately during login. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bootstrap_account.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/bootstrap_account.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;
|
|
1
|
+
{"version":3,"file":"bootstrap_account.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/bootstrap_account.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAGtD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACtD,OAAO,EACN,mBAAmB,EACnB,0BAA0B,EAC1B,wBAAwB,EACxB,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAGrE,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AAEtC,gDAAgD;AAChD,MAAM,WAAW,qBAAqB;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,6DAA6D;AAC7D,MAAM,WAAW,uBAAuB;IACvC,EAAE,EAAE,IAAI,CAAC;IACT,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,KAAK,CAAC;IACb,WAAW,EAAE;QAAE,MAAM,EAAE,SAAS,CAAC;QAAC,KAAK,EAAE,SAAS,CAAA;KAAE,CAAC;IACrD,wFAAwF;IACxF,kBAAkB,EAAE,OAAO,CAAC;CAC5B;AAED,gCAAgC;AAChC,MAAM,MAAM,uBAAuB,GAChC;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,OAAO,0BAA0B,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,GACpE;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,OAAO,wBAAwB,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,GAClE;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,OAAO,mBAAmB,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,CAAC;AAEjE,qFAAqF;AACrF,MAAM,MAAM,sBAAsB,GAAG,uBAAuB,GAAG,uBAAuB,CAAC;AAEvF;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACpC,EAAE,EAAE,EAAE,CAAC;IACP,gDAAgD;IAChD,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;OAKG;IACH,gBAAgB,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;IACxD,qBAAqB;IACrB,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7C,6EAA6E;IAC7E,QAAQ,EAAE,IAAI,CAAC,gBAAgB,EAAE,eAAe,CAAC,CAAC;IAClD,kCAAkC;IAClC,GAAG,EAAE,MAAM,CAAC;CACZ;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,iBAAiB,GAC7B,MAAM,oBAAoB,EAC1B,gBAAgB,MAAM,EACtB,OAAO,qBAAqB,KAC1B,OAAO,CAAC,sBAAsB,CA2EhC,CAAC"}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* @module
|
|
8
8
|
*/
|
|
9
9
|
import { timingSafeEqual } from 'node:crypto';
|
|
10
|
+
import { to_error_message } from '@fuzdev/fuz_util/error.ts';
|
|
10
11
|
import { ERROR_INVALID_TOKEN, ERROR_ALREADY_BOOTSTRAPPED, ERROR_TOKEN_FILE_MISSING } from '../http/error_schemas.js';
|
|
11
12
|
import { ROLE_ADMIN, ROLE_KEEPER } from './role_schema.js';
|
|
12
13
|
import { query_create_account_with_actor } from './account_queries.js';
|
|
@@ -33,13 +34,17 @@ import { query_create_role_grant } from './role_grant_queries.js';
|
|
|
33
34
|
* @mutates filesystem - deletes the bootstrap token file after commit (reported via `token_file_deleted`)
|
|
34
35
|
*/
|
|
35
36
|
export const bootstrap_account = async (deps, provided_token, input) => {
|
|
36
|
-
const { db, token_path,
|
|
37
|
-
// 1. Read and verify token (non-destructive, before transaction)
|
|
37
|
+
const { db, token_path, read_secure_file, delete_file, password, log } = deps;
|
|
38
|
+
// 1. Read and verify token (non-destructive, before transaction). The
|
|
39
|
+
// secure read fails loud on a symlink / permissive mode / oversized file —
|
|
40
|
+
// every refusal masks as the same TOKEN_FILE_MISSING the wire always
|
|
41
|
+
// carried, with the specific reason logged for the operator.
|
|
38
42
|
let expected_token;
|
|
39
43
|
try {
|
|
40
|
-
expected_token = (await
|
|
44
|
+
expected_token = new TextDecoder().decode(await read_secure_file(token_path)).trim();
|
|
41
45
|
}
|
|
42
|
-
catch {
|
|
46
|
+
catch (err) {
|
|
47
|
+
log.warn(`bootstrap token file rejected: ${to_error_message(err)}`);
|
|
43
48
|
return { ok: false, error: ERROR_TOKEN_FILE_MISSING, status: 404 };
|
|
44
49
|
}
|
|
45
50
|
// Defense-in-depth: no .trim() on provided_token — tokens must match exactly.
|
|
@@ -14,7 +14,6 @@ import type { Db } from '../db/db.js';
|
|
|
14
14
|
import { type RouteSpec } from '../http/route_spec.js';
|
|
15
15
|
import { type RateLimiter } from '../rate_limiter.js';
|
|
16
16
|
import type { RouteFactoryDeps } from './deps.js';
|
|
17
|
-
import type { StatResult } from '../runtime/deps.js';
|
|
18
17
|
/**
|
|
19
18
|
* Bootstrap status — runtime state computed once at startup.
|
|
20
19
|
*/
|
|
@@ -54,8 +53,10 @@ export interface BootstrapRouteOptions {
|
|
|
54
53
|
* Dependencies for checking bootstrap status at startup.
|
|
55
54
|
*/
|
|
56
55
|
export interface CheckBootstrapStatusDeps {
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
/** Hardened secret-file read — the same capability the request-time read uses. */
|
|
57
|
+
read_secure_file: (path: string) => Promise<Uint8Array>;
|
|
58
|
+
/** Only the single-row `bootstrap_lock` read — narrower than the full `Db`. */
|
|
59
|
+
db: Pick<Db, 'query_one'>;
|
|
59
60
|
log: Logger;
|
|
60
61
|
}
|
|
61
62
|
/**
|
|
@@ -63,9 +64,16 @@ export interface CheckBootstrapStatusDeps {
|
|
|
63
64
|
*
|
|
64
65
|
* Bootstrap is available when:
|
|
65
66
|
* 1. A token path is configured
|
|
66
|
-
* 2. The token file exists
|
|
67
|
+
* 2. The token file passes the secure read (exists, not a symlink, mode
|
|
68
|
+
* `0600`/`0400`, within the size cap)
|
|
67
69
|
* 3. The `bootstrap_lock` table shows `bootstrapped = false`
|
|
68
70
|
*
|
|
71
|
+
* The probe **reads through the same `read_secure_file` the request-time
|
|
72
|
+
* read uses**, so "bootstrap is available" and "the token file can actually
|
|
73
|
+
* be read" can't drift apart — an availability check laxer than the read it
|
|
74
|
+
* gates is the misconfiguration that reports green at boot and fails at
|
|
75
|
+
* request time. Twin of the Rust spine's `is_bootstrap_available`.
|
|
76
|
+
*
|
|
69
77
|
* @param deps - filesystem and database access for the check
|
|
70
78
|
* @param options - static configuration including `token_path`
|
|
71
79
|
* @returns an object with `available` (boolean) and `token_path` (string | null)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bootstrap_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/bootstrap_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAEpC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAE1D,OAAO,EAAqB,KAAK,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AAEzF,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AACtC,OAAO,EAAmB,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAExE,OAAO,EAAgC,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACpF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"bootstrap_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/bootstrap_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAEpC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAE1D,OAAO,EAAqB,KAAK,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AAEzF,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AACtC,OAAO,EAAmB,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAExE,OAAO,EAAgC,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACpF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAGlD;;GAEG;AACH,MAAM,WAAW,eAAe;IAC/B,SAAS,EAAE,OAAO,CAAC;IACnB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED;;;;;GAKG;AACH,MAAM,WAAW,qBAAqB;IACrC,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,8EAA8E;IAC9E,gBAAgB,EAAE,eAAe,CAAC;IAClC;;;OAGG;IACH,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,uBAAuB,EAAE,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9E;;;;;;;;;;OAUG;IACH,yBAAyB,EAAE,WAAW,GAAG,IAAI,CAAC;CAC9C;AAED;;GAEG;AACH,MAAM,WAAW,wBAAwB;IACxC,kFAAkF;IAClF,gBAAgB,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;IACxD,+EAA+E;IAC/E,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE,WAAW,CAAC,CAAC;IAC1B,GAAG,EAAE,MAAM,CAAC;CACZ;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,sBAAsB,GAClC,MAAM,wBAAwB,EAC9B,SAAS;IAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,KACpC,OAAO,CAAC,eAAe,CAyBzB,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,GACxC,MAAM,gBAAgB,EACtB,SAAS,qBAAqB,KAC5B,KAAK,CAAC,SAAS,CAmHjB,CAAC"}
|
|
@@ -19,22 +19,31 @@ import { ERROR_ALREADY_BOOTSTRAPPED, ERROR_TOKEN_FILE_MISSING } from '../http/er
|
|
|
19
19
|
*
|
|
20
20
|
* Bootstrap is available when:
|
|
21
21
|
* 1. A token path is configured
|
|
22
|
-
* 2. The token file exists
|
|
22
|
+
* 2. The token file passes the secure read (exists, not a symlink, mode
|
|
23
|
+
* `0600`/`0400`, within the size cap)
|
|
23
24
|
* 3. The `bootstrap_lock` table shows `bootstrapped = false`
|
|
24
25
|
*
|
|
26
|
+
* The probe **reads through the same `read_secure_file` the request-time
|
|
27
|
+
* read uses**, so "bootstrap is available" and "the token file can actually
|
|
28
|
+
* be read" can't drift apart — an availability check laxer than the read it
|
|
29
|
+
* gates is the misconfiguration that reports green at boot and fails at
|
|
30
|
+
* request time. Twin of the Rust spine's `is_bootstrap_available`.
|
|
31
|
+
*
|
|
25
32
|
* @param deps - filesystem and database access for the check
|
|
26
33
|
* @param options - static configuration including `token_path`
|
|
27
34
|
* @returns an object with `available` (boolean) and `token_path` (string | null)
|
|
28
35
|
*/
|
|
29
36
|
export const check_bootstrap_status = async (deps, options) => {
|
|
30
|
-
const {
|
|
37
|
+
const { read_secure_file, db, log } = deps;
|
|
31
38
|
const { token_path } = options;
|
|
32
39
|
if (!token_path) {
|
|
33
40
|
return { available: false, token_path: null };
|
|
34
41
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
42
|
+
try {
|
|
43
|
+
await read_secure_file(token_path);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
log.info(`Bootstrap unavailable: ${to_error_message(err)}`);
|
|
38
47
|
return { available: false, token_path };
|
|
39
48
|
}
|
|
40
49
|
const lock_row = await db.query_one('SELECT bootstrapped FROM bootstrap_lock WHERE id = 1');
|
|
@@ -81,7 +90,7 @@ export const create_bootstrap_route_specs = (deps, options) => {
|
|
|
81
90
|
const result = await bootstrap_account({
|
|
82
91
|
db: route.db,
|
|
83
92
|
token_path,
|
|
84
|
-
|
|
93
|
+
read_secure_file: deps.read_secure_file,
|
|
85
94
|
delete_file: deps.delete_file,
|
|
86
95
|
password: deps.password,
|
|
87
96
|
log: deps.log
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Daemon token
|
|
2
|
+
* Daemon token middleware — the credential **consumer**.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Validates a presented `X-Daemon-Token` and resolves the keeper account.
|
|
5
|
+
* The producer half (rotation + file persistence) lives in
|
|
6
|
+
* `testing/daemon_token_rotation.ts` behind the dev-env guard: no production
|
|
7
|
+
* assembly mints daemon tokens — the credential's only remaining role is the
|
|
8
|
+
* cross-process test harness's keeper channel — mirroring the Rust spine,
|
|
9
|
+
* whose producer is confined to `fuz_testing`.
|
|
6
10
|
*
|
|
7
11
|
* Pure token primitives (schema, generation, validation) live in `auth/daemon_token.ts`.
|
|
8
12
|
* See docs/identity.md for design rationale.
|
|
@@ -11,40 +15,8 @@
|
|
|
11
15
|
*/
|
|
12
16
|
import type { MiddlewareHandler } from 'hono';
|
|
13
17
|
import type { Logger } from '@fuzdev/fuz_util/log.ts';
|
|
14
|
-
import { type FsWriteDeps, type FsRemoveDeps, type EnvDeps } from '../runtime/deps.js';
|
|
15
18
|
import type { QueryDeps } from '../db/query_deps.js';
|
|
16
19
|
import { type DaemonTokenState } from './daemon_token.js';
|
|
17
|
-
/** Default rotation interval in milliseconds (30 seconds). */
|
|
18
|
-
export declare const DEFAULT_ROTATION_INTERVAL_MS = 30000;
|
|
19
|
-
/** Deps for writing the daemon token to disk. */
|
|
20
|
-
export type DaemonTokenWriteDeps = Pick<EnvDeps, 'env_get'> & Pick<FsWriteDeps, 'mkdir' | 'write_text_file' | 'rename'> & {
|
|
21
|
-
/** Set file permissions. Optional — consumers provide when available (e.g. `Deno.chmod`). */
|
|
22
|
-
chmod?: (path: string, mode: number) => Promise<void>;
|
|
23
|
-
};
|
|
24
|
-
/**
|
|
25
|
-
* Get the daemon token file path (`~/.{name}/run/daemon_token`).
|
|
26
|
-
*
|
|
27
|
-
* @param runtime - runtime with `env_get` capability
|
|
28
|
-
* @param name - application name
|
|
29
|
-
* @returns path to `daemon_token`, or `null` if `$HOME` is not set
|
|
30
|
-
*/
|
|
31
|
-
export declare const get_daemon_token_path: (runtime: Pick<EnvDeps, "env_get">, name: string) => string | null;
|
|
32
|
-
/**
|
|
33
|
-
* Write the current token to disk atomically.
|
|
34
|
-
*
|
|
35
|
-
* Uses `write_file_atomic` (temp file + rename) and optionally sets mode 0600.
|
|
36
|
-
*
|
|
37
|
-
* On-disk format is JSON `{"token": "..."}` — the wrapper leaves room for
|
|
38
|
-
* future fields (rotated_at, version) without changing every reader. Both
|
|
39
|
-
* the TS cross-backend harness reader (`spawn_backend.read_daemon_token`)
|
|
40
|
-
* and the Rust daemon-token writer match this shape.
|
|
41
|
-
*
|
|
42
|
-
* @param runtime - runtime with file write capabilities
|
|
43
|
-
* @param token_path - path to write the token
|
|
44
|
-
* @param token - the raw token string
|
|
45
|
-
* @mutates filesystem - writes `token_path` atomically and `chmod 0600` when supported
|
|
46
|
-
*/
|
|
47
|
-
export declare const write_daemon_token: (runtime: DaemonTokenWriteDeps, token_path: string, token: string) => Promise<void>;
|
|
48
20
|
/**
|
|
49
21
|
* Resolve the keeper account ID by querying for the account with an active
|
|
50
22
|
* keeper role_grant.
|
|
@@ -61,40 +33,6 @@ export declare const write_daemon_token: (runtime: DaemonTokenWriteDeps, token_p
|
|
|
61
33
|
* @returns the keeper account ID, or `null` if no keeper exists yet (pre-bootstrap)
|
|
62
34
|
*/
|
|
63
35
|
export declare const resolve_keeper_account_id: (deps: QueryDeps) => Promise<string | null>;
|
|
64
|
-
/** Options for daemon token rotation. */
|
|
65
|
-
export interface DaemonTokenRotationOptions {
|
|
66
|
-
/**
|
|
67
|
-
* Absolute path the token file is written to. Caller computes from
|
|
68
|
-
* its own conventions — e.g. `get_daemon_token_path(runtime, app_name)`
|
|
69
|
-
* for the standard `~/.{name}/run/daemon_token` layout, or a path
|
|
70
|
-
* derived from `PUBLIC_<APP>_DIR` for cross-process test setups that
|
|
71
|
-
* isolate the app dir to a tmpdir.
|
|
72
|
-
*/
|
|
73
|
-
token_path: string;
|
|
74
|
-
/** Rotation interval in ms. Default: `30000` (30s). */
|
|
75
|
-
rotation_interval_ms?: number;
|
|
76
|
-
}
|
|
77
|
-
/** Result of starting daemon token rotation. */
|
|
78
|
-
export interface DaemonTokenRotation {
|
|
79
|
-
/** The mutable runtime state. Pass to `create_daemon_token_middleware`. */
|
|
80
|
-
state: DaemonTokenState;
|
|
81
|
-
/** Stop rotation, clean up the interval, and delete the token file. Call on graceful shutdown. */
|
|
82
|
-
stop: () => Promise<void>;
|
|
83
|
-
}
|
|
84
|
-
/**
|
|
85
|
-
* Start daemon token rotation.
|
|
86
|
-
*
|
|
87
|
-
* Generates an initial token, writes it to disk, resolves the keeper account,
|
|
88
|
-
* and sets up periodic rotation. Returns the mutable state object and a stop function.
|
|
89
|
-
*
|
|
90
|
-
* @param runtime - runtime with file and remove capabilities
|
|
91
|
-
* @param deps - query dependencies for resolving keeper account
|
|
92
|
-
* @param options - rotation configuration
|
|
93
|
-
* @param log - the logger instance
|
|
94
|
-
* @returns rotation state and stop function
|
|
95
|
-
* @mutates filesystem - writes the token file on each rotation; `stop` removes it
|
|
96
|
-
*/
|
|
97
|
-
export declare const start_daemon_token_rotation: (runtime: DaemonTokenWriteDeps & FsRemoveDeps, deps: QueryDeps, options: DaemonTokenRotationOptions, log: Logger) => Promise<DaemonTokenRotation>;
|
|
98
36
|
/**
|
|
99
37
|
* Create middleware that authenticates via daemon token.
|
|
100
38
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"daemon_token_middleware.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/daemon_token_middleware.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"daemon_token_middleware.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/daemon_token_middleware.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAC9C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAWtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAErD,OAAO,EAIN,KAAK,gBAAgB,EACrB,MAAM,mBAAmB,CAAC;AAE3B;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,yBAAyB,GAAU,MAAM,SAAS,KAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAEtF,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,eAAO,MAAM,8BAA8B,GAC1C,OAAO,gBAAgB,EACvB,MAAM,SAAS,EACf,KAAK,MAAM,KACT,iBA0EF,CAAC"}
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Daemon token
|
|
2
|
+
* Daemon token middleware — the credential **consumer**.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Validates a presented `X-Daemon-Token` and resolves the keeper account.
|
|
5
|
+
* The producer half (rotation + file persistence) lives in
|
|
6
|
+
* `testing/daemon_token_rotation.ts` behind the dev-env guard: no production
|
|
7
|
+
* assembly mints daemon tokens — the credential's only remaining role is the
|
|
8
|
+
* cross-process test harness's keeper channel — mirroring the Rust spine,
|
|
9
|
+
* whose producer is confined to `fuz_testing`.
|
|
6
10
|
*
|
|
7
11
|
* Pure token primitives (schema, generation, validation) live in `auth/daemon_token.ts`.
|
|
8
12
|
* See docs/identity.md for design rationale.
|
|
@@ -10,49 +14,12 @@
|
|
|
10
14
|
* @module
|
|
11
15
|
*/
|
|
12
16
|
import { DEV } from 'esm-env';
|
|
13
|
-
import {} from '../runtime/deps.js';
|
|
14
|
-
import { write_file_atomic } from '../runtime/fs.js';
|
|
15
|
-
import { get_app_dir } from '../cli/config.js';
|
|
16
17
|
import { ACCOUNT_ID_KEY, AUTH_API_TOKEN_ID_KEY, CREDENTIAL_TYPE_KEY, TOKEN_SCOPE_KEY } from '../hono_context.js';
|
|
17
18
|
import { token_scope_full } from './token_scope.js';
|
|
18
19
|
import { is_browser_context } from '../http/origin.js';
|
|
19
20
|
import { query_role_grant_find_account_id_for_role } from './role_grant_queries.js';
|
|
20
21
|
import { ROLE_KEEPER } from './role_schema.js';
|
|
21
|
-
import { DaemonToken, DAEMON_TOKEN_HEADER,
|
|
22
|
-
/** Default rotation interval in milliseconds (30 seconds). */
|
|
23
|
-
export const DEFAULT_ROTATION_INTERVAL_MS = 30_000;
|
|
24
|
-
/**
|
|
25
|
-
* Get the daemon token file path (`~/.{name}/run/daemon_token`).
|
|
26
|
-
*
|
|
27
|
-
* @param runtime - runtime with `env_get` capability
|
|
28
|
-
* @param name - application name
|
|
29
|
-
* @returns path to `daemon_token`, or `null` if `$HOME` is not set
|
|
30
|
-
*/
|
|
31
|
-
export const get_daemon_token_path = (runtime, name) => {
|
|
32
|
-
const app_dir = get_app_dir(runtime, name);
|
|
33
|
-
return app_dir ? `${app_dir}/run/daemon_token` : null;
|
|
34
|
-
};
|
|
35
|
-
/**
|
|
36
|
-
* Write the current token to disk atomically.
|
|
37
|
-
*
|
|
38
|
-
* Uses `write_file_atomic` (temp file + rename) and optionally sets mode 0600.
|
|
39
|
-
*
|
|
40
|
-
* On-disk format is JSON `{"token": "..."}` — the wrapper leaves room for
|
|
41
|
-
* future fields (rotated_at, version) without changing every reader. Both
|
|
42
|
-
* the TS cross-backend harness reader (`spawn_backend.read_daemon_token`)
|
|
43
|
-
* and the Rust daemon-token writer match this shape.
|
|
44
|
-
*
|
|
45
|
-
* @param runtime - runtime with file write capabilities
|
|
46
|
-
* @param token_path - path to write the token
|
|
47
|
-
* @param token - the raw token string
|
|
48
|
-
* @mutates filesystem - writes `token_path` atomically and `chmod 0600` when supported
|
|
49
|
-
*/
|
|
50
|
-
export const write_daemon_token = async (runtime, token_path, token) => {
|
|
51
|
-
await write_file_atomic(runtime, token_path, JSON.stringify({ token }) + '\n');
|
|
52
|
-
if (runtime.chmod) {
|
|
53
|
-
await runtime.chmod(token_path, 0o600);
|
|
54
|
-
}
|
|
55
|
-
};
|
|
22
|
+
import { DaemonToken, DAEMON_TOKEN_HEADER, validate_daemon_token } from './daemon_token.js';
|
|
56
23
|
/**
|
|
57
24
|
* Resolve the keeper account ID by querying for the account with an active
|
|
58
25
|
* keeper role_grant.
|
|
@@ -71,69 +38,6 @@ export const write_daemon_token = async (runtime, token_path, token) => {
|
|
|
71
38
|
export const resolve_keeper_account_id = async (deps) => {
|
|
72
39
|
return query_role_grant_find_account_id_for_role(deps, ROLE_KEEPER);
|
|
73
40
|
};
|
|
74
|
-
/**
|
|
75
|
-
* Start daemon token rotation.
|
|
76
|
-
*
|
|
77
|
-
* Generates an initial token, writes it to disk, resolves the keeper account,
|
|
78
|
-
* and sets up periodic rotation. Returns the mutable state object and a stop function.
|
|
79
|
-
*
|
|
80
|
-
* @param runtime - runtime with file and remove capabilities
|
|
81
|
-
* @param deps - query dependencies for resolving keeper account
|
|
82
|
-
* @param options - rotation configuration
|
|
83
|
-
* @param log - the logger instance
|
|
84
|
-
* @returns rotation state and stop function
|
|
85
|
-
* @mutates filesystem - writes the token file on each rotation; `stop` removes it
|
|
86
|
-
*/
|
|
87
|
-
export const start_daemon_token_rotation = async (runtime, deps, options, log) => {
|
|
88
|
-
const { token_path, rotation_interval_ms = DEFAULT_ROTATION_INTERVAL_MS } = options;
|
|
89
|
-
// ensure parent directory exists
|
|
90
|
-
const last_slash = token_path.lastIndexOf('/');
|
|
91
|
-
if (last_slash > 0) {
|
|
92
|
-
await runtime.mkdir(token_path.slice(0, last_slash), { recursive: true });
|
|
93
|
-
}
|
|
94
|
-
// resolve keeper account (may be null pre-bootstrap; the middleware
|
|
95
|
-
// lazily refreshes on the first null hit to cover the
|
|
96
|
-
// rotation-starts-before-bootstrap case)
|
|
97
|
-
const keeper_account_id = await resolve_keeper_account_id(deps);
|
|
98
|
-
// generate initial token and write to disk
|
|
99
|
-
const initial_token = generate_daemon_token();
|
|
100
|
-
await write_daemon_token(runtime, token_path, initial_token);
|
|
101
|
-
const state = {
|
|
102
|
-
current_token: initial_token,
|
|
103
|
-
previous_token: null,
|
|
104
|
-
rotated_at: new Date(),
|
|
105
|
-
keeper_account_id
|
|
106
|
-
};
|
|
107
|
-
let writing = false;
|
|
108
|
-
const interval_id = setInterval(async () => {
|
|
109
|
-
if (writing)
|
|
110
|
-
return; // skip if previous rotation write still in progress
|
|
111
|
-
writing = true;
|
|
112
|
-
try {
|
|
113
|
-
const new_token = generate_daemon_token();
|
|
114
|
-
state.previous_token = state.current_token;
|
|
115
|
-
state.current_token = new_token;
|
|
116
|
-
state.rotated_at = new Date();
|
|
117
|
-
await write_daemon_token(runtime, token_path, new_token);
|
|
118
|
-
}
|
|
119
|
-
catch (err) {
|
|
120
|
-
log.error('Failed to write rotated token:', err);
|
|
121
|
-
}
|
|
122
|
-
finally {
|
|
123
|
-
writing = false;
|
|
124
|
-
}
|
|
125
|
-
}, rotation_interval_ms);
|
|
126
|
-
const stop = async () => {
|
|
127
|
-
clearInterval(interval_id);
|
|
128
|
-
try {
|
|
129
|
-
await runtime.remove(token_path);
|
|
130
|
-
}
|
|
131
|
-
catch {
|
|
132
|
-
// already removed or never written
|
|
133
|
-
}
|
|
134
|
-
};
|
|
135
|
-
return { state, stop };
|
|
136
|
-
};
|
|
137
41
|
/**
|
|
138
42
|
* Create middleware that authenticates via daemon token.
|
|
139
43
|
*
|
package/dist/auth/deps.d.ts
CHANGED
|
@@ -12,7 +12,6 @@ import type { FactStore } from '@fuzdev/fuz_util/fact_store.ts';
|
|
|
12
12
|
import type { Keyring } from './keyring.js';
|
|
13
13
|
import type { PasswordHashDeps } from './password.js';
|
|
14
14
|
import type { Db } from '../db/db.js';
|
|
15
|
-
import type { StatResult } from '../runtime/deps.js';
|
|
16
15
|
import type { AuditEmitter } from './audit_emitter.js';
|
|
17
16
|
/**
|
|
18
17
|
* Stateless capabilities bundle for fuz_app backends.
|
|
@@ -21,10 +20,15 @@ import type { AuditEmitter } from './audit_emitter.js';
|
|
|
21
20
|
* Does not contain config (static values) or runtime state (mutable refs).
|
|
22
21
|
*/
|
|
23
22
|
export interface AppDeps {
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Hardened secret-file read — used for the bootstrap token (the file that
|
|
25
|
+
* mints the keeper account). Production wiring passes the runtime's
|
|
26
|
+
* `read_secure_file` (`FsSecureReadDeps`), which rejects symlinks,
|
|
27
|
+
* group/other-accessible modes, and oversized files; both the boot-time
|
|
28
|
+
* availability probe and the request-time read go through this one
|
|
29
|
+
* capability so the probe can never be laxer than the read it gates.
|
|
30
|
+
*/
|
|
31
|
+
read_secure_file: (path: string) => Promise<Uint8Array>;
|
|
28
32
|
/** Delete a file. */
|
|
29
33
|
delete_file: (path: string) => Promise<void>;
|
|
30
34
|
/** HMAC-SHA256 cookie signing keyring. */
|