@fuzdev/fuz_app 0.65.0 → 0.66.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.
Files changed (159) hide show
  1. package/dist/actions/CLAUDE.md +65 -86
  2. package/dist/actions/action_codegen.d.ts +1 -1
  3. package/dist/actions/action_codegen.js +1 -1
  4. package/dist/actions/action_event_data.d.ts +1 -1
  5. package/dist/auth/CLAUDE.md +83 -104
  6. package/dist/auth/audit_log_schema.js +2 -2
  7. package/dist/auth/daemon_token_middleware.d.ts +15 -5
  8. package/dist/auth/daemon_token_middleware.d.ts.map +1 -1
  9. package/dist/auth/daemon_token_middleware.js +24 -15
  10. package/dist/auth/invite_queries.d.ts +17 -7
  11. package/dist/auth/invite_queries.d.ts.map +1 -1
  12. package/dist/auth/invite_queries.js +19 -8
  13. package/dist/auth/signup_routes.d.ts +47 -1
  14. package/dist/auth/signup_routes.d.ts.map +1 -1
  15. package/dist/auth/signup_routes.js +103 -52
  16. package/dist/env/resolve.d.ts +44 -7
  17. package/dist/env/resolve.d.ts.map +1 -1
  18. package/dist/env/resolve.js +94 -27
  19. package/dist/http/CLAUDE.md +47 -52
  20. package/dist/http/jsonrpc.d.ts +23 -7
  21. package/dist/http/jsonrpc.d.ts.map +1 -1
  22. package/dist/http/jsonrpc.js +19 -3
  23. package/dist/http/surface.d.ts +9 -2
  24. package/dist/http/surface.d.ts.map +1 -1
  25. package/dist/runtime/mock.d.ts +1 -1
  26. package/dist/runtime/mock.js +1 -1
  27. package/dist/testing/CLAUDE.md +659 -511
  28. package/dist/testing/admin_integration.d.ts +5 -5
  29. package/dist/testing/admin_integration.d.ts.map +1 -1
  30. package/dist/testing/admin_integration.js +95 -39
  31. package/dist/testing/app_server.d.ts +16 -1
  32. package/dist/testing/app_server.d.ts.map +1 -1
  33. package/dist/testing/app_server.js +18 -3
  34. package/dist/testing/audit_completeness.d.ts +7 -5
  35. package/dist/testing/audit_completeness.d.ts.map +1 -1
  36. package/dist/testing/audit_completeness.js +5 -9
  37. package/dist/testing/bootstrap_success.js +2 -2
  38. package/dist/testing/cross_backend/backend_config.d.ts +113 -0
  39. package/dist/testing/cross_backend/backend_config.d.ts.map +1 -0
  40. package/dist/testing/cross_backend/backend_config.js +1 -0
  41. package/dist/testing/cross_backend/bench/bench_report.d.ts +46 -0
  42. package/dist/testing/cross_backend/bench/bench_report.d.ts.map +1 -0
  43. package/dist/testing/cross_backend/bench/bench_report.js +83 -0
  44. package/dist/testing/cross_backend/bench/run_cross_impl_bench.d.ts +44 -0
  45. package/dist/testing/cross_backend/bench/run_cross_impl_bench.d.ts.map +1 -0
  46. package/dist/testing/cross_backend/bench/run_cross_impl_bench.js +38 -0
  47. package/dist/testing/cross_backend/bench/scenario.d.ts +57 -0
  48. package/dist/testing/cross_backend/bench/scenario.d.ts.map +1 -0
  49. package/dist/testing/cross_backend/bench/scenario.js +28 -0
  50. package/dist/testing/cross_backend/bootstrap_backend.d.ts +41 -0
  51. package/dist/testing/cross_backend/bootstrap_backend.d.ts.map +1 -0
  52. package/dist/testing/cross_backend/bootstrap_backend.js +34 -0
  53. package/dist/testing/cross_backend/build_test_backend_paths.d.ts +24 -0
  54. package/dist/testing/cross_backend/build_test_backend_paths.d.ts.map +1 -0
  55. package/dist/testing/cross_backend/build_test_backend_paths.js +33 -0
  56. package/dist/testing/cross_backend/capabilities.d.ts +3 -2
  57. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  58. package/dist/testing/cross_backend/default_backend_configs.d.ts +122 -0
  59. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -0
  60. package/dist/testing/cross_backend/default_backend_configs.js +111 -0
  61. package/dist/testing/cross_backend/default_secrets.d.ts +40 -0
  62. package/dist/testing/cross_backend/default_secrets.d.ts.map +1 -0
  63. package/dist/testing/cross_backend/default_secrets.js +39 -0
  64. package/dist/testing/cross_backend/default_spine_surface.d.ts +64 -0
  65. package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -0
  66. package/dist/testing/cross_backend/default_spine_surface.js +121 -0
  67. package/dist/testing/cross_backend/setup.d.ts +270 -34
  68. package/dist/testing/cross_backend/setup.d.ts.map +1 -1
  69. package/dist/testing/cross_backend/setup.js +495 -15
  70. package/dist/testing/cross_backend/spawn_backend.d.ts +58 -0
  71. package/dist/testing/cross_backend/spawn_backend.d.ts.map +1 -0
  72. package/dist/testing/cross_backend/spawn_backend.js +229 -0
  73. package/dist/testing/cross_backend/spine_stub_backend_config.d.ts +66 -0
  74. package/dist/testing/cross_backend/spine_stub_backend_config.d.ts.map +1 -0
  75. package/dist/testing/cross_backend/spine_stub_backend_config.js +49 -0
  76. package/dist/testing/cross_backend/sse_round_trip.d.ts +37 -0
  77. package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -0
  78. package/dist/testing/cross_backend/sse_round_trip.js +137 -0
  79. package/dist/testing/cross_backend/standard.d.ts +96 -0
  80. package/dist/testing/cross_backend/standard.d.ts.map +1 -0
  81. package/dist/testing/cross_backend/standard.js +49 -0
  82. package/dist/testing/cross_backend/testing_reset_actions.d.ts +171 -0
  83. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -0
  84. package/dist/testing/cross_backend/testing_reset_actions.js +213 -0
  85. package/dist/testing/cross_backend/testing_server_bun.d.ts +5 -0
  86. package/dist/testing/cross_backend/testing_server_bun.d.ts.map +1 -0
  87. package/dist/testing/cross_backend/testing_server_bun.js +59 -0
  88. package/dist/testing/cross_backend/testing_server_core.d.ts +140 -0
  89. package/dist/testing/cross_backend/testing_server_core.d.ts.map +1 -0
  90. package/dist/testing/cross_backend/testing_server_core.js +68 -0
  91. package/dist/testing/cross_backend/testing_server_deno.d.ts +5 -0
  92. package/dist/testing/cross_backend/testing_server_deno.d.ts.map +1 -0
  93. package/dist/testing/cross_backend/testing_server_deno.js +37 -0
  94. package/dist/testing/cross_backend/testing_server_node.d.ts +5 -0
  95. package/dist/testing/cross_backend/testing_server_node.d.ts.map +1 -0
  96. package/dist/testing/cross_backend/testing_server_node.js +50 -0
  97. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts +72 -0
  98. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -0
  99. package/dist/testing/cross_backend/ts_spine_backend_config.js +112 -0
  100. package/dist/testing/cross_backend/ws_round_trip.d.ts +35 -0
  101. package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -0
  102. package/dist/testing/cross_backend/ws_round_trip.js +113 -0
  103. package/dist/testing/data_exposure.d.ts +4 -6
  104. package/dist/testing/data_exposure.d.ts.map +1 -1
  105. package/dist/testing/data_exposure.js +1 -5
  106. package/dist/testing/db_entities.d.ts +18 -7
  107. package/dist/testing/db_entities.d.ts.map +1 -1
  108. package/dist/testing/db_entities.js +18 -7
  109. package/dist/testing/integration.d.ts +27 -6
  110. package/dist/testing/integration.d.ts.map +1 -1
  111. package/dist/testing/integration.js +93 -58
  112. package/dist/testing/round_trip.d.ts +4 -5
  113. package/dist/testing/round_trip.d.ts.map +1 -1
  114. package/dist/testing/round_trip.js +1 -5
  115. package/dist/testing/rpc_helpers.d.ts +10 -4
  116. package/dist/testing/rpc_helpers.d.ts.map +1 -1
  117. package/dist/testing/rpc_helpers.js +1 -1
  118. package/dist/testing/rpc_round_trip.d.ts +5 -5
  119. package/dist/testing/rpc_round_trip.d.ts.map +1 -1
  120. package/dist/testing/rpc_round_trip.js +1 -5
  121. package/dist/testing/sse_round_trip.d.ts.map +1 -1
  122. package/dist/testing/sse_round_trip.js +1 -68
  123. package/dist/testing/standard.d.ts +4 -5
  124. package/dist/testing/standard.d.ts.map +1 -1
  125. package/dist/testing/stubs.d.ts +10 -3
  126. package/dist/testing/stubs.d.ts.map +1 -1
  127. package/dist/testing/stubs.js +9 -2
  128. package/dist/testing/testing_rate_limiter.d.ts +59 -0
  129. package/dist/testing/testing_rate_limiter.d.ts.map +1 -0
  130. package/dist/testing/testing_rate_limiter.js +74 -0
  131. package/dist/testing/transports/bootstrap.d.ts +52 -0
  132. package/dist/testing/transports/bootstrap.d.ts.map +1 -0
  133. package/dist/testing/transports/bootstrap.js +70 -0
  134. package/dist/testing/transports/fetch_transport.d.ts +81 -0
  135. package/dist/testing/transports/fetch_transport.d.ts.map +1 -0
  136. package/dist/testing/transports/fetch_transport.js +74 -0
  137. package/dist/testing/transports/sse_frame_reader.d.ts +41 -0
  138. package/dist/testing/transports/sse_frame_reader.d.ts.map +1 -0
  139. package/dist/testing/transports/sse_frame_reader.js +84 -0
  140. package/dist/testing/transports/sse_transport.d.ts +54 -0
  141. package/dist/testing/transports/sse_transport.d.ts.map +1 -0
  142. package/dist/testing/transports/sse_transport.js +51 -0
  143. package/dist/testing/transports/ws_client.d.ts +108 -0
  144. package/dist/testing/transports/ws_client.d.ts.map +1 -0
  145. package/dist/testing/transports/ws_client.js +56 -0
  146. package/dist/testing/transports/ws_transport.d.ts +43 -0
  147. package/dist/testing/transports/ws_transport.d.ts.map +1 -0
  148. package/dist/testing/transports/ws_transport.js +169 -0
  149. package/dist/testing/ws_round_trip.d.ts +21 -103
  150. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  151. package/dist/testing/ws_round_trip.js +42 -40
  152. package/dist/ui/CLAUDE.md +5 -3
  153. package/dist/ui/MenuLink.svelte +16 -16
  154. package/dist/ui/MenuLink.svelte.d.ts +13 -4
  155. package/dist/ui/MenuLink.svelte.d.ts.map +1 -1
  156. package/package.json +7 -1
  157. package/dist/testing/transports/surface_source.d.ts +0 -51
  158. package/dist/testing/transports/surface_source.d.ts.map +0 -1
  159. package/dist/testing/transports/surface_source.js +0 -19
@@ -13,6 +13,27 @@ import { type RateLimiter } from '../rate_limiter.js';
13
13
  import type { RouteFactoryDeps } from './deps.js';
14
14
  import type { AppSettings } from './app_settings_schema.js';
15
15
  import type { AuthSessionRouteOptions } from './account_routes.js';
16
+ /**
17
+ * Default minimum wall-clock time (ms) for a signup denial (403 / 409) response.
18
+ *
19
+ * Parallel to login's `DEFAULT_LOGIN_FAIL_FLOOR_MS`. Without a floor, an
20
+ * attacker can distinguish `ERROR_NO_MATCHING_INVITE` (cheap — bails before
21
+ * Argon2 + tx) from `ERROR_SIGNUP_CONFLICT` (Argon2 + tx + rollback) via
22
+ * response time and use the gap as a username-enumeration oracle. Picked
23
+ * to exceed the p99 of every denial code path (Argon2id dominates at
24
+ * ~100ms, plus DB + overhead). 429 stays fast by design (same precedent
25
+ * as login) so rate-limit DoS handling stays cheap.
26
+ */
27
+ export declare const DEFAULT_SIGNUP_FAIL_FLOOR_MS = 250;
28
+ /**
29
+ * Default uniform jitter window (±ms) layered on the floor.
30
+ *
31
+ * Random jitter prevents a stable clamp point from leaking whenever a
32
+ * path occasionally exceeds the floor. `Math.random` is sufficient —
33
+ * we only need unpredictability of the exact delay, not cryptographic
34
+ * guarantees.
35
+ */
36
+ export declare const DEFAULT_SIGNUP_FAIL_JITTER_MS = 25;
16
37
  /**
17
38
  * Per-factory configuration for signup route specs.
18
39
  */
@@ -21,6 +42,18 @@ export interface SignupRouteOptions extends AuthSessionRouteOptions {
21
42
  signup_account_rate_limiter: RateLimiter | null;
22
43
  /** Mutable ref to app settings — when `open_signup` is true, invite check is skipped. */
23
44
  app_settings: AppSettings;
45
+ /**
46
+ * Minimum wall-clock time (ms) for signup denial responses (403 / 409).
47
+ * Set to `0` or a negative number to disable (e.g., in tests). Default
48
+ * `DEFAULT_SIGNUP_FAIL_FLOOR_MS`. 429 responses are not floored.
49
+ */
50
+ signup_fail_floor_ms?: number;
51
+ /**
52
+ * Uniform jitter window (±ms) layered on the floor. Set to `0` to
53
+ * disable jitter while keeping the floor. Default
54
+ * `DEFAULT_SIGNUP_FAIL_JITTER_MS`.
55
+ */
56
+ signup_fail_jitter_ms?: number;
24
57
  }
25
58
  /** Input for `POST /signup`. `email` is optional and must match any referenced invite. */
26
59
  export declare const SignupInput: z.ZodObject<{
@@ -29,9 +62,22 @@ export declare const SignupInput: z.ZodObject<{
29
62
  email: z.ZodOptional<z.ZodEmail>;
30
63
  }, z.core.$strict>;
31
64
  export type SignupInput = z.infer<typeof SignupInput>;
32
- /** Output for `POST /signup`. Session cookie is the operative side effect. */
65
+ /**
66
+ * Output for `POST /signup`.
67
+ *
68
+ * Session cookie is the operative side effect. The returned `account` and
69
+ * `actor` mirror `BootstrapOutput` so cross-process per-test setup can read
70
+ * the per-test identity straight off the signup response.
71
+ */
33
72
  export declare const SignupOutput: z.ZodObject<{
34
73
  ok: z.ZodLiteral<true>;
74
+ account: z.ZodObject<{
75
+ id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
76
+ username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
77
+ }, z.core.$strict>;
78
+ actor: z.ZodObject<{
79
+ id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
80
+ }, z.core.$strict>;
35
81
  }, z.core.$strict>;
36
82
  export type SignupOutput = z.infer<typeof SignupOutput>;
37
83
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"signup_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/signup_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAStB,OAAO,EAAkB,KAAK,SAAS,EAAC,MAAM,uBAAuB,CAAC;AAEtE,OAAO,EAA+B,KAAK,WAAW,EAAC,MAAM,oBAAoB,CAAC;AAClF,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,WAAW,CAAC;AAOhD,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,0BAA0B,CAAC;AAE1D,OAAO,KAAK,EAAC,uBAAuB,EAAC,MAAM,qBAAqB,CAAC;AAEjE;;GAEG;AACH,MAAM,WAAW,kBAAmB,SAAQ,uBAAuB;IAClE,6FAA6F;IAC7F,2BAA2B,EAAE,WAAW,GAAG,IAAI,CAAC;IAChD,yFAAyF;IACzF,YAAY,EAAE,WAAW,CAAC;CAC1B;AAID,0FAA0F;AAC1F,eAAO,MAAM,WAAW;;;;kBAItB,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,8EAA8E;AAC9E,eAAO,MAAM,YAAY;;kBAEvB,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,CAAC;AAExD;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,GACrC,MAAM,gBAAgB,EACtB,SAAS,kBAAkB,KACzB,KAAK,CAAC,SAAS,CAmJjB,CAAC"}
1
+ {"version":3,"file":"signup_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/signup_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAatB,OAAO,EAAkB,KAAK,SAAS,EAAC,MAAM,uBAAuB,CAAC;AAEtE,OAAO,EAA+B,KAAK,WAAW,EAAC,MAAM,oBAAoB,CAAC;AAClF,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,WAAW,CAAC;AAOhD,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,0BAA0B,CAAC;AAE1D,OAAO,KAAK,EAAC,uBAAuB,EAAC,MAAM,qBAAqB,CAAC;AAEjE;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,MAAM,CAAC;AAEhD;;;;;;;GAOG;AACH,eAAO,MAAM,6BAA6B,KAAK,CAAC;AAQhD;;GAEG;AACH,MAAM,WAAW,kBAAmB,SAAQ,uBAAuB;IAClE,6FAA6F;IAC7F,2BAA2B,EAAE,WAAW,GAAG,IAAI,CAAC;IAChD,yFAAyF;IACzF,YAAY,EAAE,WAAW,CAAC;IAC1B;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;OAIG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAC/B;AAID,0FAA0F;AAC1F,eAAO,MAAM,WAAW;;;;kBAItB,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD;;;;;;GAMG;AACH,eAAO,MAAM,YAAY;;;;;;;;;kBAIvB,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,CAAC;AAExD;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,GACrC,MAAM,gBAAgB,EACtB,SAAS,kBAAkB,KACzB,KAAK,CAAC,SAAS,CAmLjB,CAAC"}
@@ -8,9 +8,10 @@
8
8
  * @module
9
9
  */
10
10
  import { z } from 'zod';
11
+ import { Uuid } from '@fuzdev/fuz_util/id.js';
11
12
  import { create_session_and_set_cookie } from './session_middleware.js';
12
13
  import { query_create_account_with_actor } from './account_queries.js';
13
- import { query_invite_find_unclaimed_match, query_invite_claim_unscoped } from './invite_queries.js';
14
+ import { query_invite_find_unclaimed_match_for_update, query_invite_claim_unscoped, } from './invite_queries.js';
14
15
  import { Username, Email } from '../primitive_schemas.js';
15
16
  import { Password } from './password.js';
16
17
  import { get_route_input } from '../http/route_spec.js';
@@ -18,6 +19,33 @@ import { get_client_ip } from '../http/proxy.js';
18
19
  import { rate_limit_exceeded_response } from '../rate_limiter.js';
19
20
  import { ERROR_NO_MATCHING_INVITE, ERROR_SIGNUP_CONFLICT, ERROR_INVALID_JSON_BODY, ERROR_INVALID_REQUEST_BODY, } from '../http/error_schemas.js';
20
21
  import { is_pg_unique_violation } from '../db/pg_error.js';
22
+ /**
23
+ * Default minimum wall-clock time (ms) for a signup denial (403 / 409) response.
24
+ *
25
+ * Parallel to login's `DEFAULT_LOGIN_FAIL_FLOOR_MS`. Without a floor, an
26
+ * attacker can distinguish `ERROR_NO_MATCHING_INVITE` (cheap — bails before
27
+ * Argon2 + tx) from `ERROR_SIGNUP_CONFLICT` (Argon2 + tx + rollback) via
28
+ * response time and use the gap as a username-enumeration oracle. Picked
29
+ * to exceed the p99 of every denial code path (Argon2id dominates at
30
+ * ~100ms, plus DB + overhead). 429 stays fast by design (same precedent
31
+ * as login) so rate-limit DoS handling stays cheap.
32
+ */
33
+ export const DEFAULT_SIGNUP_FAIL_FLOOR_MS = 250;
34
+ /**
35
+ * Default uniform jitter window (±ms) layered on the floor.
36
+ *
37
+ * Random jitter prevents a stable clamp point from leaking whenever a
38
+ * path occasionally exceeds the floor. `Math.random` is sufficient —
39
+ * we only need unpredictability of the exact delay, not cryptographic
40
+ * guarantees.
41
+ */
42
+ export const DEFAULT_SIGNUP_FAIL_JITTER_MS = 25;
43
+ const signup_fail_delay = (floor_ms, jitter_ms) => {
44
+ if (floor_ms <= 0)
45
+ return Promise.resolve();
46
+ const jitter = jitter_ms > 0 ? Math.floor(Math.random() * (jitter_ms * 2 + 1)) - jitter_ms : 0;
47
+ return new Promise((resolve) => setTimeout(resolve, floor_ms + jitter));
48
+ };
21
49
  // -- Input/output schemas ---------------------------------------------------
22
50
  /** Input for `POST /signup`. `email` is optional and must match any referenced invite. */
23
51
  export const SignupInput = z.strictObject({
@@ -25,9 +53,17 @@ export const SignupInput = z.strictObject({
25
53
  password: Password,
26
54
  email: Email.optional(),
27
55
  });
28
- /** Output for `POST /signup`. Session cookie is the operative side effect. */
56
+ /**
57
+ * Output for `POST /signup`.
58
+ *
59
+ * Session cookie is the operative side effect. The returned `account` and
60
+ * `actor` mirror `BootstrapOutput` so cross-process per-test setup can read
61
+ * the per-test identity straight off the signup response.
62
+ */
29
63
  export const SignupOutput = z.strictObject({
30
64
  ok: z.literal(true),
65
+ account: z.strictObject({ id: Uuid, username: Username }),
66
+ actor: z.strictObject({ id: Uuid }),
31
67
  });
32
68
  /**
33
69
  * Create signup route specs for account creation.
@@ -38,7 +74,7 @@ export const SignupOutput = z.strictObject({
38
74
  */
39
75
  export const create_signup_route_specs = (deps, options) => {
40
76
  const { keyring, password } = deps;
41
- const { session_options, ip_rate_limiter, signup_account_rate_limiter, app_settings } = options;
77
+ const { session_options, ip_rate_limiter, signup_account_rate_limiter, app_settings, signup_fail_floor_ms = DEFAULT_SIGNUP_FAIL_FLOOR_MS, signup_fail_jitter_ms = DEFAULT_SIGNUP_FAIL_JITTER_MS, } = options;
42
78
  return [
43
79
  {
44
80
  method: 'POST',
@@ -57,7 +93,7 @@ export const create_signup_route_specs = (deps, options) => {
57
93
  409: z.looseObject({ error: z.literal(ERROR_SIGNUP_CONFLICT) }),
58
94
  },
59
95
  handler: async (c, route) => {
60
- // Per-IP rate limit check (before any work)
96
+ // Per-IP rate limit check (before any work). 429 stays fast.
61
97
  const ip = ip_rate_limiter ? get_client_ip(c) : null;
62
98
  if (ip_rate_limiter && ip) {
63
99
  const check = ip_rate_limiter.check(ip);
@@ -66,7 +102,8 @@ export const create_signup_route_specs = (deps, options) => {
66
102
  }
67
103
  }
68
104
  const { username, password: pw, email } = get_route_input(c);
69
- // Per-account rate limit check (after input parsing, before DB work)
105
+ // Per-account rate limit check (after input parsing, before DB work).
106
+ // 429 stays fast — same precedent as login.
70
107
  const account_key = username.toLowerCase();
71
108
  if (signup_account_rate_limiter) {
72
109
  const check = signup_account_rate_limiter.check(account_key);
@@ -74,13 +111,23 @@ export const create_signup_route_specs = (deps, options) => {
74
111
  return rate_limit_exceeded_response(c, check.retry_after);
75
112
  }
76
113
  }
77
- // Check for matching invite (unless open signup is enabled).
78
- // `transaction: false` makes `route.db` the pool, which is
79
- // what the pre-tx invite lookup wants.
114
+ // Start the denial-time floor concurrently with failure work.
115
+ // Observed response time for 403 / 409 is `max(work, delay)`
116
+ // so the cheap `no_match` path (no Argon2, find returns
117
+ // nothing) and the expensive `signup_conflict` path (Argon2
118
+ // + tx + rollback) converge — closes the username-enumeration
119
+ // timing oracle. Mirrors login's `login_fail_delay`. Started
120
+ // after rate-limit checks so 429 stays fast.
121
+ const delay = signup_fail_delay(signup_fail_floor_ms, signup_fail_jitter_ms);
122
+ // Hash before the transaction so the connection isn't held
123
+ // across the ~100ms Argon2id. Paid unconditionally — bounded
124
+ // by the per-IP + per-account rate limiters above.
125
+ const password_hash = await password.hash_password(pw);
126
+ // `invite` is assigned inside the tx by the FOR UPDATE find;
127
+ // captured at the outer scope so the failure-audit catch
128
+ // branch can still reference `invite.id` after the tx rolls
129
+ // back on PG unique violation.
80
130
  let invite;
81
- if (!app_settings.open_signup) {
82
- invite = await query_invite_find_unclaimed_match({ db: route.db }, email ?? null, username);
83
- }
84
131
  const emit_failure_audit = (reason) => {
85
132
  deps.audit.emit(route, {
86
133
  event_type: 'signup',
@@ -95,43 +142,34 @@ export const create_signup_route_specs = (deps, options) => {
95
142
  },
96
143
  });
97
144
  };
98
- if (!app_settings.open_signup && !invite) {
99
- if (ip_rate_limiter && ip)
100
- ip_rate_limiter.record(ip);
101
- if (signup_account_rate_limiter)
102
- signup_account_rate_limiter.record(account_key);
103
- emit_failure_audit('no_match');
104
- return c.json({ error: ERROR_NO_MATCHING_INVITE }, 403);
105
- }
106
- // Create account, optionally claim invite, and create session atomically.
107
- // Username/email uniqueness enforced by DB unique constraints.
108
- const password_hash = await password.hash_password(pw);
109
145
  let result;
110
146
  try {
111
147
  result = await route.db.transaction(async (tx) => {
112
148
  const tx_deps = { db: tx };
113
- const { account } = await query_create_account_with_actor(tx_deps, {
149
+ // Find + claim run inside the same transaction so the
150
+ // row lock makes them atomic. Concurrent signups for
151
+ // the same (username, email) tuple block on the lock
152
+ // and observe the post-commit state on retry — the
153
+ // loser's `find_for_update` returns no row (winner
154
+ // flipped `claimed_at`) and falls through to
155
+ // `ERROR_NO_MATCHING_INVITE`. No race window.
156
+ if (!app_settings.open_signup) {
157
+ invite = await query_invite_find_unclaimed_match_for_update(tx_deps, email ?? null, username);
158
+ if (!invite) {
159
+ throw new NoMatchingInviteError();
160
+ }
161
+ }
162
+ const { account, actor } = await query_create_account_with_actor(tx_deps, {
114
163
  username,
115
164
  password_hash,
116
165
  email,
117
166
  });
118
167
  if (invite) {
119
- const claimed = await query_invite_claim_unscoped(tx_deps, invite.id, account.id);
120
- if (!claimed) {
121
- // Race: invite was claimed between the find and this claim.
122
- //
123
- // SECURITY NOTE: this branch is largely shadowed by the account
124
- // unique constraints. Because `query_invite_find_unclaimed_match`
125
- // returns at most one invite for the (username, email) tuple, two
126
- // concurrent signups satisfying the same find share the same
127
- // username and/or email — and the case-insensitive partial uniques
128
- // on `account.username` / `account.email` (`ACCOUNT_USERNAME_CI_INDEX`
129
- // / `ACCOUNT_EMAIL_INDEX`) fire on the second `query_create_account_with_actor`
130
- // before the claim runs. The audit emit is kept for defense-in-depth
131
- // in case those constraints are loosened or the find query starts
132
- // returning multiple invites for a single signup tuple.
133
- throw new SignupConflictError(ERROR_NO_MATCHING_INVITE);
134
- }
168
+ // Guaranteed to succeed: FOR UPDATE held the row
169
+ // for the duration of the tx, so no concurrent
170
+ // claim could flip `claimed_at` between the find
171
+ // and this UPDATE.
172
+ await query_invite_claim_unscoped(tx_deps, invite.id, account.id);
135
173
  }
136
174
  await create_session_and_set_cookie({
137
175
  keyring,
@@ -140,17 +178,18 @@ export const create_signup_route_specs = (deps, options) => {
140
178
  account_id: account.id,
141
179
  session_options,
142
180
  });
143
- return account;
181
+ return { account, actor };
144
182
  });
145
183
  }
146
184
  catch (e) {
147
- if (e instanceof SignupConflictError) {
185
+ if (e instanceof NoMatchingInviteError) {
148
186
  if (ip_rate_limiter && ip)
149
187
  ip_rate_limiter.record(ip);
150
188
  if (signup_account_rate_limiter)
151
189
  signup_account_rate_limiter.record(account_key);
152
- emit_failure_audit('race_lost');
153
- return c.json({ error: e.error }, 403);
190
+ emit_failure_audit('no_match');
191
+ await delay;
192
+ return c.json({ error: ERROR_NO_MATCHING_INVITE }, 403);
154
193
  }
155
194
  // Unique constraint violation: username or email already exists.
156
195
  if (is_pg_unique_violation(e)) {
@@ -159,8 +198,17 @@ export const create_signup_route_specs = (deps, options) => {
159
198
  if (signup_account_rate_limiter)
160
199
  signup_account_rate_limiter.record(account_key);
161
200
  emit_failure_audit('signup_conflict');
201
+ await delay;
162
202
  return c.json({ error: ERROR_SIGNUP_CONFLICT }, 409);
163
203
  }
204
+ // Unclassified failure (e.g. session create error, Argon2
205
+ // fault on hash, DB outage mid-tx). Tx is rolled back so
206
+ // no account persists, but the *attempt* should leave a
207
+ // forensic trail — emit `outcome: 'failure'` with reason
208
+ // `internal_error` before rethrowing. 5xx responses are
209
+ // not floored: they aren't response-time-controlled
210
+ // enumeration oracles.
211
+ emit_failure_audit('internal_error');
164
212
  throw e;
165
213
  }
166
214
  // Reset rate limiters on success
@@ -170,20 +218,23 @@ export const create_signup_route_specs = (deps, options) => {
170
218
  signup_account_rate_limiter.reset(account_key);
171
219
  deps.audit.emit(route, {
172
220
  event_type: 'signup',
173
- account_id: result.id,
221
+ account_id: result.account.id,
174
222
  ip: get_client_ip(c),
175
223
  metadata: invite ? { invite_id: invite.id, username } : { open_signup: true, username },
176
224
  });
177
- return c.json({ ok: true });
225
+ return c.json({
226
+ ok: true,
227
+ account: { id: result.account.id, username: result.account.username },
228
+ actor: { id: result.actor.id },
229
+ });
178
230
  },
179
231
  },
180
232
  ];
181
233
  };
182
- /** Thrown inside the signup transaction to signal a conflict that should roll back. */
183
- class SignupConflictError extends Error {
184
- error;
185
- constructor(error) {
186
- super(error);
187
- this.error = error;
188
- }
234
+ /**
235
+ * Thrown inside the signup transaction to signal `ERROR_NO_MATCHING_INVITE`
236
+ * when the FOR UPDATE find returns no row (and `open_signup` is off).
237
+ * Caught by the handler to roll back the tx and emit the failure audit.
238
+ */
239
+ class NoMatchingInviteError extends Error {
189
240
  }
@@ -10,15 +10,32 @@
10
10
  * - Easy to grep: `grep '\$\$'`
11
11
  * - Fails loud if accidentally shell-processed (`$$`=PID in shell)
12
12
  *
13
+ * # Syntax
14
+ *
15
+ * - `$$VAR$$` — required reference. Missing or empty fails validation
16
+ * (see `validate_env_vars`).
17
+ * - `$$?VAR$$` — optional reference. Missing or empty resolves to the
18
+ * empty string and passes validation. Use for vars that exist in the
19
+ * contract but may be intentionally blank (e.g. `SMTP_PASSWORD=` on
20
+ * a deployment that hasn't configured SMTP yet).
21
+ * - `\$$VAR$$` — escape. The leading backslash is dropped at resolution
22
+ * time and the rest is emitted literally. Use this when documenting
23
+ * the syntax inside a string literal (comments in env-file templates,
24
+ * for example) where a regex scan would otherwise treat the mention
25
+ * as a real reference. Combines with `?` as `\$$?VAR$$`.
26
+ *
13
27
  * @module
14
28
  */
15
29
  import type { EnvDeps } from '../runtime/deps.js';
16
30
  /**
17
31
  * Resolve environment variable references in a string.
18
32
  *
19
- * Uses `$$VAR$$` syntax (bookended double-dollar signs).
20
- * Only resolves variables that are actually set in the environment.
21
- * Unset variables are left as-is for clear error messages.
33
+ * - `$$VAR$$` resolves from the runtime env; missing values are left as-is
34
+ * for the validation phase to report.
35
+ * - `$$?VAR$$` is the optional form missing or empty resolves to the empty
36
+ * string. Required validation skips refs marked optional.
37
+ * - `\$$VAR$$` / `\$$?VAR$$` are escapes — the leading backslash is dropped
38
+ * and the body is emitted literally (no resolution attempted).
22
39
  *
23
40
  * @param runtime - runtime with `env_get` capability
24
41
  * @param value - string that may contain `$$VAR$$` references
@@ -28,13 +45,20 @@ export declare const resolve_env_vars: (runtime: Pick<EnvDeps, "env_get">, value
28
45
  /**
29
46
  * Check if a string contains unresolved env var references.
30
47
  *
48
+ * Escaped references (`\$$VAR$$`) do not count — they're literal text once
49
+ * resolved.
50
+ *
31
51
  * @param value - string to check
32
- * @returns `true` if string contains `$$VAR$$` patterns
52
+ * @returns `true` if string contains unescaped `$$VAR$$` patterns
33
53
  */
34
54
  export declare const has_env_vars: (value: string) => boolean;
35
55
  /**
36
56
  * Get list of env var names referenced in a string.
37
57
  *
58
+ * Escaped references are skipped; optional and required references are both
59
+ * included (callers that care about the distinction should use `scan_env_vars`
60
+ * which preserves the `optional` flag per ref).
61
+ *
38
62
  * @param value - string to scan
39
63
  * @returns array of variable names (without `$$` delimiters)
40
64
  */
@@ -50,7 +74,9 @@ export declare const resolve_env_vars_in_object: <T extends Record<string, unkno
50
74
  /**
51
75
  * Resolve env vars and throw if any are missing/empty.
52
76
  *
53
- * Use this for values that must be present.
77
+ * Use this for values that must be present. `$$?VAR$$` (optional) refs
78
+ * resolve to the empty string on miss without contributing to the error.
79
+ * Escaped references (`\$$VAR$$`) emit literally and never check the env.
54
80
  *
55
81
  * @param runtime - runtime with `env_get` capability
56
82
  * @param value - string with `$$VAR$$` references
@@ -67,15 +93,24 @@ export interface EnvVarRef {
67
93
  name: string;
68
94
  /** Path where the reference was found (e.g., `"target.host"`, `"resources[3].path"`). */
69
95
  path: string;
96
+ /**
97
+ * Whether the reference is optional (`$$?VAR$$`). Optional refs resolve
98
+ * to the empty string when unset and are skipped by `validate_env_vars` —
99
+ * a var that's intentionally blank doesn't count as missing.
100
+ */
101
+ optional: boolean;
70
102
  }
71
103
  /**
72
104
  * Recursively scan an object for `$$VAR$$` env var references.
73
105
  *
74
106
  * Walks all string values in the object tree and extracts env var names
75
- * with their path context for error reporting.
107
+ * with their path context for error reporting. Escaped references
108
+ * (`\$$VAR$$`) are skipped — they're literal text, not references.
109
+ * The `optional` flag on each ref distinguishes `$$VAR$$` (required) from
110
+ * `$$?VAR$$` (optional) for downstream validation.
76
111
  *
77
112
  * @param obj - object to scan (typically a config)
78
- * @returns array of env var references with paths
113
+ * @returns array of env var references with paths and optional flags
79
114
  */
80
115
  export declare const scan_env_vars: (obj: unknown) => Array<EnvVarRef>;
81
116
  /**
@@ -97,6 +132,8 @@ export type EnvValidationResult = {
97
132
  *
98
133
  * Returns all missing refs (including duplicates by name). Grouping
99
134
  * and deduplication is handled by `format_missing_env_vars` at display time.
135
+ * Refs marked `optional: true` (from `$$?VAR$$` syntax) are skipped — a
136
+ * deliberately-blank var is contract, not a missing dependency.
100
137
  *
101
138
  * @param runtime - runtime with `env_get` capability
102
139
  * @param refs - env var references from `scan_env_vars`
@@ -1 +1 @@
1
- {"version":3,"file":"resolve.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/env/resolve.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,oBAAoB,CAAC;AAOhD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,gBAAgB,GAAI,SAAS,IAAI,CAAC,OAAO,EAAE,SAAS,CAAC,EAAE,OAAO,MAAM,KAAG,MAMnF,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,YAAY,GAAI,OAAO,MAAM,KAAG,OAG5C,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,GAAI,OAAO,MAAM,KAAG,KAAK,CAAC,MAAM,CAS7D,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,GAAI,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3E,SAAS,IAAI,CAAC,OAAO,EAAE,SAAS,CAAC,EACjC,KAAK,CAAC,KACJ,CAQF,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,yBAAyB,GACrC,SAAS,IAAI,CAAC,OAAO,EAAE,SAAS,CAAC,EACjC,OAAO,MAAM,EACb,SAAS,MAAM,KACb,MAmBF,CAAC;AAEF;;GAEG;AACH,MAAM,WAAW,SAAS;IACzB,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,yFAAyF;IACzF,IAAI,EAAE,MAAM,CAAC;CACb;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,aAAa,GAAI,KAAK,OAAO,KAAG,KAAK,CAAC,SAAS,CAI3D,CAAC;AAwBF;;;;;;GAMG;AACH,MAAM,MAAM,mBAAmB,GAC5B;IAAC,EAAE,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,IAAI,CAAA;CAAC,GACzB;IAAC,EAAE,EAAE,KAAK,CAAC;IAAC,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC,CAAA;CAAC,CAAC;AAE1C;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,GAC7B,SAAS,IAAI,CAAC,OAAO,EAAE,SAAS,CAAC,EACjC,MAAM,KAAK,CAAC,SAAS,CAAC,KACpB,mBAWF,CAAC;AAEF;;GAEG;AACH,MAAM,WAAW,2BAA2B;IAC3C,0CAA0C;IAC1C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,mDAAmD;IACnD,UAAU,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,uBAAuB,GACnC,SAAS,KAAK,CAAC,SAAS,CAAC,EACzB,UAAU,2BAA2B,KACnC,MA+BF,CAAC"}
1
+ {"version":3,"file":"resolve.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/env/resolve.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,oBAAoB,CAAC;AAiBhD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,gBAAgB,GAAI,SAAS,IAAI,CAAC,OAAO,EAAE,SAAS,CAAC,EAAE,OAAO,MAAM,KAAG,MAYnF,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,YAAY,GAAI,OAAO,MAAM,KAAG,OAQ5C,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,GAAI,OAAO,MAAM,KAAG,KAAK,CAAC,MAAM,CAS7D,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,GAAI,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3E,SAAS,IAAI,CAAC,OAAO,EAAE,SAAS,CAAC,EACjC,KAAK,CAAC,KACJ,CAQF,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,yBAAyB,GACrC,SAAS,IAAI,CAAC,OAAO,EAAE,SAAS,CAAC,EACjC,OAAO,MAAM,EACb,SAAS,MAAM,KACb,MAyBF,CAAC;AAEF;;GAEG;AACH,MAAM,WAAW,SAAS;IACzB,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,yFAAyF;IACzF,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,QAAQ,EAAE,OAAO,CAAC;CAClB;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,aAAa,GAAI,KAAK,OAAO,KAAG,KAAK,CAAC,SAAS,CAI3D,CAAC;AA2BF;;;;;;GAMG;AACH,MAAM,MAAM,mBAAmB,GAC5B;IAAC,EAAE,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,IAAI,CAAA;CAAC,GACzB;IAAC,EAAE,EAAE,KAAK,CAAC;IAAC,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC,CAAA;CAAC,CAAC;AAE1C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iBAAiB,GAC7B,SAAS,IAAI,CAAC,OAAO,EAAE,SAAS,CAAC,EACjC,MAAM,KAAK,CAAC,SAAS,CAAC,KACpB,mBAYF,CAAC;AAEF;;GAEG;AACH,MAAM,WAAW,2BAA2B;IAC3C,0CAA0C;IAC1C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,mDAAmD;IACnD,UAAU,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,uBAAuB,GACnC,SAAS,KAAK,CAAC,SAAS,CAAC,EACzB,UAAU,2BAA2B,KACnC,MA+BF,CAAC"}
@@ -10,53 +10,102 @@
10
10
  * - Easy to grep: `grep '\$\$'`
11
11
  * - Fails loud if accidentally shell-processed (`$$`=PID in shell)
12
12
  *
13
+ * # Syntax
14
+ *
15
+ * - `$$VAR$$` — required reference. Missing or empty fails validation
16
+ * (see `validate_env_vars`).
17
+ * - `$$?VAR$$` — optional reference. Missing or empty resolves to the
18
+ * empty string and passes validation. Use for vars that exist in the
19
+ * contract but may be intentionally blank (e.g. `SMTP_PASSWORD=` on
20
+ * a deployment that hasn't configured SMTP yet).
21
+ * - `\$$VAR$$` — escape. The leading backslash is dropped at resolution
22
+ * time and the rest is emitted literally. Use this when documenting
23
+ * the syntax inside a string literal (comments in env-file templates,
24
+ * for example) where a regex scan would otherwise treat the mention
25
+ * as a real reference. Combines with `?` as `\$$?VAR$$`.
26
+ *
13
27
  * @module
14
28
  */
15
29
  /**
16
- * Pattern for environment variable references: `$$VAR$$`.
30
+ * Pattern matching `$$VAR$$`, `$$?VAR$$`, and their escaped forms
31
+ * `\$$VAR$$` / `\$$?VAR$$`. Capture groups:
32
+ *
33
+ * - 1: the `$$...$$` body (without any leading escape char) — what to
34
+ * emit when the match is escaped.
35
+ * - 2: `?` if the reference is optional, empty otherwise.
36
+ * - 3: the variable name (without `$$` delimiters).
37
+ *
38
+ * The leading `\\?` (optional backslash) is the escape signal — its
39
+ * presence in `match[0]` (vs. `match[1]`) drives the literal-emission
40
+ * branch in the resolver.
17
41
  */
18
- const ENV_VAR_PATTERN = /\$\$([A-Za-z_][A-Za-z0-9_]*)\$\$/g;
42
+ const ENV_VAR_PATTERN = /\\?(\$\$(\??)([A-Za-z_][A-Za-z0-9_]*)\$\$)/g;
19
43
  /**
20
44
  * Resolve environment variable references in a string.
21
45
  *
22
- * Uses `$$VAR$$` syntax (bookended double-dollar signs).
23
- * Only resolves variables that are actually set in the environment.
24
- * Unset variables are left as-is for clear error messages.
46
+ * - `$$VAR$$` resolves from the runtime env; missing values are left as-is
47
+ * for the validation phase to report.
48
+ * - `$$?VAR$$` is the optional form missing or empty resolves to the empty
49
+ * string. Required validation skips refs marked optional.
50
+ * - `\$$VAR$$` / `\$$?VAR$$` are escapes — the leading backslash is dropped
51
+ * and the body is emitted literally (no resolution attempted).
25
52
  *
26
53
  * @param runtime - runtime with `env_get` capability
27
54
  * @param value - string that may contain `$$VAR$$` references
28
55
  * @returns string with env vars resolved
29
56
  */
30
57
  export const resolve_env_vars = (runtime, value) => {
31
- return value.replace(ENV_VAR_PATTERN, (match, name) => {
58
+ return value.replace(ENV_VAR_PATTERN, (match, body, modifier, name) => {
59
+ if (match.startsWith('\\')) {
60
+ // Escaped: drop the leading backslash, emit the body literally.
61
+ return body;
62
+ }
32
63
  const resolved = runtime.env_get(name);
33
- // leave unresolved for the validation phase to report
34
- return resolved !== undefined ? resolved : match;
64
+ if (resolved !== undefined && resolved !== '')
65
+ return resolved;
66
+ // Optional: empty-on-miss. Required: leave unresolved for validation.
67
+ if (modifier === '?')
68
+ return '';
69
+ return match;
35
70
  });
36
71
  };
37
72
  /**
38
73
  * Check if a string contains unresolved env var references.
39
74
  *
75
+ * Escaped references (`\$$VAR$$`) do not count — they're literal text once
76
+ * resolved.
77
+ *
40
78
  * @param value - string to check
41
- * @returns `true` if string contains `$$VAR$$` patterns
79
+ * @returns `true` if string contains unescaped `$$VAR$$` patterns
42
80
  */
43
81
  export const has_env_vars = (value) => {
44
- // use a fresh regex to avoid global regex lastIndex state issues
45
- return /\$\$[A-Za-z_][A-Za-z0-9_]*\$\$/.test(value);
82
+ // Iterate the shared pattern (fresh regex; lastIndex is fine on construct).
83
+ const pattern = new RegExp(ENV_VAR_PATTERN.source, 'g');
84
+ let match;
85
+ while ((match = pattern.exec(value)) !== null) {
86
+ if (!match[0].startsWith('\\'))
87
+ return true;
88
+ }
89
+ return false;
46
90
  };
47
91
  /**
48
92
  * Get list of env var names referenced in a string.
49
93
  *
94
+ * Escaped references are skipped; optional and required references are both
95
+ * included (callers that care about the distinction should use `scan_env_vars`
96
+ * which preserves the `optional` flag per ref).
97
+ *
50
98
  * @param value - string to scan
51
99
  * @returns array of variable names (without `$$` delimiters)
52
100
  */
53
101
  export const get_env_var_names = (value) => {
54
102
  const names = [];
55
- let match;
56
- // reset regex lastIndex since it's global
57
103
  const pattern = new RegExp(ENV_VAR_PATTERN.source, 'g');
104
+ let match;
58
105
  while ((match = pattern.exec(value)) !== null) {
59
- names.push(match[1]);
106
+ if (match[0].startsWith('\\'))
107
+ continue;
108
+ names.push(match[3]);
60
109
  }
61
110
  return names;
62
111
  };
@@ -79,7 +128,9 @@ export const resolve_env_vars_in_object = (runtime, obj) => {
79
128
  /**
80
129
  * Resolve env vars and throw if any are missing/empty.
81
130
  *
82
- * Use this for values that must be present.
131
+ * Use this for values that must be present. `$$?VAR$$` (optional) refs
132
+ * resolve to the empty string on miss without contributing to the error.
133
+ * Escaped references (`\$$VAR$$`) emit literally and never check the env.
83
134
  *
84
135
  * @param runtime - runtime with `env_get` capability
85
136
  * @param value - string with `$$VAR$$` references
@@ -89,13 +140,18 @@ export const resolve_env_vars_in_object = (runtime, obj) => {
89
140
  */
90
141
  export const resolve_env_vars_required = (runtime, value, context) => {
91
142
  const missing = [];
92
- const result = value.replace(ENV_VAR_PATTERN, (match, name) => {
93
- const resolved = runtime.env_get(name);
94
- if (resolved === undefined || resolved === '') {
95
- missing.push(name);
96
- return match; // keep original for error message
143
+ const result = value.replace(ENV_VAR_PATTERN, (match, body, modifier, name) => {
144
+ if (match.startsWith('\\')) {
145
+ // Escaped emit body, no env lookup.
146
+ return body;
97
147
  }
98
- return resolved;
148
+ const resolved = runtime.env_get(name);
149
+ if (resolved !== undefined && resolved !== '')
150
+ return resolved;
151
+ if (modifier === '?')
152
+ return '';
153
+ missing.push(name);
154
+ return match; // keep original for error message
99
155
  });
100
156
  if (missing.length > 0) {
101
157
  throw new Error(`Missing required environment variable(s) for ${context}: ${missing.join(', ')}`);
@@ -106,10 +162,13 @@ export const resolve_env_vars_required = (runtime, value, context) => {
106
162
  * Recursively scan an object for `$$VAR$$` env var references.
107
163
  *
108
164
  * Walks all string values in the object tree and extracts env var names
109
- * with their path context for error reporting.
165
+ * with their path context for error reporting. Escaped references
166
+ * (`\$$VAR$$`) are skipped — they're literal text, not references.
167
+ * The `optional` flag on each ref distinguishes `$$VAR$$` (required) from
168
+ * `$$?VAR$$` (optional) for downstream validation.
110
169
  *
111
170
  * @param obj - object to scan (typically a config)
112
- * @returns array of env var references with paths
171
+ * @returns array of env var references with paths and optional flags
113
172
  */
114
173
  export const scan_env_vars = (obj) => {
115
174
  const refs = [];
@@ -117,13 +176,17 @@ export const scan_env_vars = (obj) => {
117
176
  return refs;
118
177
  };
119
178
  /**
120
- * Recursive helper for `scan_env_vars`.
179
+ * Recursive helper for `scan_env_vars`. Uses the full pattern (not
180
+ * `get_env_var_names`) to preserve the `optional` modifier on each ref.
121
181
  */
122
182
  const scan_recursive = (value, path, refs) => {
123
183
  if (typeof value === 'string') {
124
- const names = get_env_var_names(value);
125
- for (const name of names) {
126
- refs.push({ name, path });
184
+ const pattern = new RegExp(ENV_VAR_PATTERN.source, 'g');
185
+ let match;
186
+ while ((match = pattern.exec(value)) !== null) {
187
+ if (match[0].startsWith('\\'))
188
+ continue; // escaped — literal text
189
+ refs.push({ name: match[3], path, optional: match[2] === '?' });
127
190
  }
128
191
  }
129
192
  else if (Array.isArray(value)) {
@@ -144,6 +207,8 @@ const scan_recursive = (value, path, refs) => {
144
207
  *
145
208
  * Returns all missing refs (including duplicates by name). Grouping
146
209
  * and deduplication is handled by `format_missing_env_vars` at display time.
210
+ * Refs marked `optional: true` (from `$$?VAR$$` syntax) are skipped — a
211
+ * deliberately-blank var is contract, not a missing dependency.
147
212
  *
148
213
  * @param runtime - runtime with `env_get` capability
149
214
  * @param refs - env var references from `scan_env_vars`
@@ -152,6 +217,8 @@ const scan_recursive = (value, path, refs) => {
152
217
  export const validate_env_vars = (runtime, refs) => {
153
218
  let missing = null;
154
219
  for (const ref of refs) {
220
+ if (ref.optional)
221
+ continue;
155
222
  const value = runtime.env_get(ref.name);
156
223
  if (value === undefined || value === '') {
157
224
  (missing ??= []).push(ref);