@fuzdev/fuz_app 0.88.0 → 0.90.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 (49) hide show
  1. package/dist/auth/account_action_specs.d.ts +1 -1
  2. package/dist/auth/account_route_schema.d.ts +12 -6
  3. package/dist/auth/account_route_schema.d.ts.map +1 -1
  4. package/dist/auth/account_route_schema.js +11 -5
  5. package/dist/auth/account_routes.d.ts +18 -4
  6. package/dist/auth/account_routes.d.ts.map +1 -1
  7. package/dist/auth/account_routes.js +13 -5
  8. package/dist/auth/account_schema.d.ts +3 -3
  9. package/dist/auth/admin_action_specs.d.ts +8 -8
  10. package/dist/auth/audit_log_schema.d.ts +1 -1
  11. package/dist/auth/invite_schema.d.ts +2 -2
  12. package/dist/auth/signup_route_schema.d.ts +2 -2
  13. package/dist/auth/signup_route_schema.d.ts.map +1 -1
  14. package/dist/auth/signup_route_schema.js +2 -2
  15. package/dist/primitive_schemas.d.ts +27 -1
  16. package/dist/primitive_schemas.d.ts.map +1 -1
  17. package/dist/primitive_schemas.js +55 -2
  18. package/dist/server/env.d.ts +1 -1
  19. package/dist/server/env.d.ts.map +1 -1
  20. package/dist/server/env.js +15 -3
  21. package/dist/testing/CLAUDE.md +62 -2
  22. package/dist/testing/app_server.d.ts +16 -5
  23. package/dist/testing/app_server.d.ts.map +1 -1
  24. package/dist/testing/app_server.js +3 -1
  25. package/dist/testing/cross_backend/body_size_smuggling.d.ts +12 -0
  26. package/dist/testing/cross_backend/body_size_smuggling.d.ts.map +1 -1
  27. package/dist/testing/cross_backend/body_size_smuggling.js +68 -41
  28. package/dist/testing/cross_backend/capabilities.d.ts +29 -0
  29. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  30. package/dist/testing/cross_backend/capabilities.js +15 -0
  31. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  32. package/dist/testing/cross_backend/default_backend_configs.js +13 -0
  33. package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
  34. package/dist/testing/cross_backend/default_spine_surface.js +7 -0
  35. package/dist/testing/cross_backend/identity_parity.d.ts +13 -0
  36. package/dist/testing/cross_backend/identity_parity.d.ts.map +1 -0
  37. package/dist/testing/cross_backend/identity_parity.js +465 -0
  38. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
  39. package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +13 -6
  40. package/dist/testing/cross_backend/setup.d.ts +9 -0
  41. package/dist/testing/cross_backend/setup.d.ts.map +1 -1
  42. package/dist/testing/cross_backend/setup.js +14 -1
  43. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
  44. package/dist/testing/cross_backend/ts_spine_backend_config.js +16 -1
  45. package/dist/testing/integration.d.ts.map +1 -1
  46. package/dist/testing/integration.js +13 -16
  47. package/dist/testing/schema_generators.d.ts.map +1 -1
  48. package/dist/testing/schema_generators.js +7 -0
  49. package/package.json +3 -3
@@ -99,7 +99,7 @@ export declare const account_verify_action_spec: {
99
99
  output: z.ZodObject<{
100
100
  id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
101
101
  username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
102
- email: z.ZodNullable<z.ZodEmail>;
102
+ email: z.ZodNullable<z.ZodString>;
103
103
  email_verified: z.ZodBoolean;
104
104
  created_at: z.ZodString;
105
105
  }, z.core.$strict>;
@@ -21,7 +21,7 @@ export declare const AccountStatusOutput: z.ZodObject<{
21
21
  account: z.ZodObject<{
22
22
  id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
23
23
  username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
24
- email: z.ZodNullable<z.ZodEmail>;
24
+ email: z.ZodNullable<z.ZodString>;
25
25
  email_verified: z.ZodBoolean;
26
26
  created_at: z.ZodString;
27
27
  }, z.core.$strict>;
@@ -84,10 +84,16 @@ export declare const DEFAULT_MAX_SESSIONS = 5;
84
84
  /** Default maximum API tokens per account. */
85
85
  export declare const DEFAULT_MAX_TOKENS = 10;
86
86
  /**
87
- * The `GET /api/account/status` route shape minus its handler — pure
88
- * hono-free data. `create_account_status_route_spec` spreads this and
89
- * attaches the live handler (which reads the account id off the request
90
- * context); surface generation spreads it with a stub handler.
87
+ * The `GET /status` route shape minus its handler — pure hono-free data.
88
+ * `create_account_status_route_spec` spreads this and attaches the live handler
89
+ * (which reads the account id off the request context); surface generation
90
+ * spreads it with a stub handler.
91
+ *
92
+ * The path is **relative** like the sibling account shapes (`/login`,
93
+ * `/verify`), so it composes under `prefix_route_specs('/api/account', …)` into
94
+ * `/api/account/status`. `create_account_route_specs` bundles it (so every
95
+ * account surface serves `/status`, matching the Rust `account_router`);
96
+ * mirror Rust by mounting it as part of the account family, not separately.
91
97
  */
92
98
  export declare const account_status_route_shape: {
93
99
  method: "GET";
@@ -102,7 +108,7 @@ export declare const account_status_route_shape: {
102
108
  account: z.ZodObject<{
103
109
  id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
104
110
  username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
105
- email: z.ZodNullable<z.ZodEmail>;
111
+ email: z.ZodNullable<z.ZodString>;
106
112
  email_verified: z.ZodBoolean;
107
113
  created_at: z.ZodString;
108
114
  }, z.core.$strict>;
@@ -1 +1 @@
1
- {"version":3,"file":"account_route_schema.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_route_schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAKtB,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,uBAAuB,CAAC;AAQrD,kFAAkF;AAClF,eAAO,MAAM,kBAAkB,WAAW,CAAC;AAC3C,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAEpE,4CAA4C;AAC5C,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;kBAI9B,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,4EAA4E;AAC5E,eAAO,MAAM,iCAAiC;;;iBAG5C,CAAC;AACH,MAAM,MAAM,iCAAiC,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iCAAiC,CAAC,CAAC;AAElG,oFAAoF;AACpF,eAAO,MAAM,UAAU;;;kBAGrB,CAAC;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD,6EAA6E;AAC7E,eAAO,MAAM,WAAW;;kBAEtB,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,2EAA2E;AAC3E,eAAO,MAAM,WAAW,WAAW,CAAC;AACpC,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,wFAAwF;AACxF,eAAO,MAAM,YAAY;;;kBAGvB,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,CAAC;AAExD,sHAAsH;AACtH,eAAO,MAAM,mBAAmB;;;kBAG9B,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,uGAAuG;AACvG,eAAO,MAAM,oBAAoB;;;;kBAI/B,CAAC;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAExE,4CAA4C;AAC5C,eAAO,MAAM,oBAAoB,IAAI,CAAC;AAEtC,8CAA8C;AAC9C,eAAO,MAAM,kBAAkB,KAAK,CAAC;AAErC;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAUD,CAAC;AAEvC,8EAA8E;AAC9E,MAAM,WAAW,wBAAwB;IACxC,8FAA8F;IAC9F,0BAA0B,EAAE,OAAO,CAAC;CACpC;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,GACvC,SAAS,wBAAwB,KAC/B,CACF,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,EAC1B,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,EAC1B,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,EAC1B,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,CAoD1B,CAAC"}
1
+ {"version":3,"file":"account_route_schema.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_route_schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAKtB,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,uBAAuB,CAAC;AAQrD,kFAAkF;AAClF,eAAO,MAAM,kBAAkB,WAAW,CAAC;AAC3C,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAEpE,4CAA4C;AAC5C,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;kBAI9B,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,4EAA4E;AAC5E,eAAO,MAAM,iCAAiC;;;iBAG5C,CAAC;AACH,MAAM,MAAM,iCAAiC,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iCAAiC,CAAC,CAAC;AAElG,oFAAoF;AACpF,eAAO,MAAM,UAAU;;;kBAGrB,CAAC;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD,6EAA6E;AAC7E,eAAO,MAAM,WAAW;;kBAEtB,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,2EAA2E;AAC3E,eAAO,MAAM,WAAW,WAAW,CAAC;AACpC,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,wFAAwF;AACxF,eAAO,MAAM,YAAY;;;kBAGvB,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,CAAC;AAExD,sHAAsH;AACtH,eAAO,MAAM,mBAAmB;;;kBAG9B,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,uGAAuG;AACvG,eAAO,MAAM,oBAAoB;;;;kBAI/B,CAAC;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAExE,4CAA4C;AAC5C,eAAO,MAAM,oBAAoB,IAAI,CAAC;AAEtC,8CAA8C;AAC9C,eAAO,MAAM,kBAAkB,KAAK,CAAC;AAErC;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAUD,CAAC;AAEvC,8EAA8E;AAC9E,MAAM,WAAW,wBAAwB;IACxC,8FAA8F;IAC9F,0BAA0B,EAAE,OAAO,CAAC;CACpC;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,GACvC,SAAS,wBAAwB,KAC/B,CACF,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,EAC1B,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,EAC1B,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,EAC1B,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,CAoD1B,CAAC"}
@@ -61,14 +61,20 @@ export const DEFAULT_MAX_SESSIONS = 5;
61
61
  /** Default maximum API tokens per account. */
62
62
  export const DEFAULT_MAX_TOKENS = 10;
63
63
  /**
64
- * The `GET /api/account/status` route shape minus its handler — pure
65
- * hono-free data. `create_account_status_route_spec` spreads this and
66
- * attaches the live handler (which reads the account id off the request
67
- * context); surface generation spreads it with a stub handler.
64
+ * The `GET /status` route shape minus its handler — pure hono-free data.
65
+ * `create_account_status_route_spec` spreads this and attaches the live handler
66
+ * (which reads the account id off the request context); surface generation
67
+ * spreads it with a stub handler.
68
+ *
69
+ * The path is **relative** like the sibling account shapes (`/login`,
70
+ * `/verify`), so it composes under `prefix_route_specs('/api/account', …)` into
71
+ * `/api/account/status`. `create_account_route_specs` bundles it (so every
72
+ * account surface serves `/status`, matching the Rust `account_router`);
73
+ * mirror Rust by mounting it as part of the account family, not separately.
68
74
  */
69
75
  export const account_status_route_shape = {
70
76
  method: 'GET',
71
- path: '/api/account/status',
77
+ path: '/status',
72
78
  auth: { account: 'none', actor: 'none' },
73
79
  description: 'Current account info (unauthenticated: 401 with bootstrap status)',
74
80
  input: AccountStatusInput,
@@ -107,16 +107,30 @@ export interface AccountRouteOptions extends AuthSessionRouteOptions {
107
107
  * `audit.on_event_chain`) runs.
108
108
  */
109
109
  connection_closer?: ConnectionCloser | null;
110
+ /**
111
+ * Runtime bootstrap status for the bundled `GET /status` route — when
112
+ * `available`, its unauthenticated 401 carries `bootstrap_available: true`
113
+ * so a fresh frontend can route to the bootstrap flow. Pass
114
+ * `ctx.bootstrap_status` (the live `BootstrapStatus` ref) so the flag tracks
115
+ * the one-shot bootstrap completing. Omit when no bootstrap flow is wired —
116
+ * `/status` is still served, just without the flag.
117
+ */
118
+ bootstrap_status?: {
119
+ available: boolean;
120
+ };
110
121
  }
111
122
  /**
112
123
  * Create account route specs for session-based auth.
113
124
  *
114
- * The returned specs cover the three flows that stay REST after the RPC
115
- * migration (login, logout, password change). Self-service session/token
116
- * management and verify are on `auth/account_actions.ts`.
125
+ * The returned specs cover the REST flows that stay after the RPC migration:
126
+ * `/status` (account info + bootstrap availability), `/verify` (nginx
127
+ * `auth_request` shim), `/login`, `/logout`, `/password`. `/status` is bundled
128
+ * here (relative path, prefixed to `/api/account/status` by the caller) so
129
+ * every account surface serves it, matching the Rust `account_router`.
130
+ * Self-service session/token management is on `auth/account_actions.ts`.
117
131
  *
118
132
  * @param deps - stateless capabilities (keyring, password, log)
119
- * @param options - per-factory configuration (session_options, ip_rate_limiter, login_account_rate_limiter)
133
+ * @param options - per-factory configuration (session_options, ip_rate_limiter, login_account_rate_limiter, bootstrap_status)
120
134
  * @returns route specs (not yet applied to Hono)
121
135
  */
122
136
  export declare const create_account_route_specs: (deps: RouteFactoryDeps, options: AccountRouteOptions) => Array<RouteSpec>;
@@ -1 +1 @@
1
- {"version":3,"file":"account_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,qBAAqB,CAAC;AA4BxD,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;AAChD,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,iCAAiC,CAAC;AAGtE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gCAAgC,GAAI,UAAU,oBAAoB,KAAG,SA4EhF,CAAC;AAEH,iDAAiD;AACjD,MAAM,WAAW,oBAAoB;IACpC,yDAAyD;IACzD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,8FAA8F;IAC9F,gBAAgB,CAAC,EAAE;QAAC,SAAS,EAAE,OAAO,CAAA;KAAC,CAAC;CACxC;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,MAAM,CAAC;AAE/C;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,KAAK,CAAC;AAQ/C;;;;;GAKG;AACH,MAAM,WAAW,uBAAuB;IACvC,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,kFAAkF;IAClF,eAAe,EAAE,WAAW,GAAG,IAAI,CAAC;CACpC;AAED;;GAEG;AACH,MAAM,WAAW,mBAAoB,SAAQ,uBAAuB;IACnE,4FAA4F;IAC5F,0BAA0B,EAAE,WAAW,GAAG,IAAI,CAAC;IAC/C,2FAA2F;IAC3F,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,gBAAgB,GAAG,IAAI,CAAC;CAC5C;AAID;;;;;;;;;;GAUG;AACH,eAAO,MAAM,0BAA0B,GACtC,MAAM,gBAAgB,EACtB,SAAS,mBAAmB,KAC1B,KAAK,CAAC,SAAS,CAkRjB,CAAC"}
1
+ {"version":3,"file":"account_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,qBAAqB,CAAC;AA4BxD,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;AAChD,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,iCAAiC,CAAC;AAGtE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gCAAgC,GAAI,UAAU,oBAAoB,KAAG,SA4EhF,CAAC;AAEH,iDAAiD;AACjD,MAAM,WAAW,oBAAoB;IACpC,yDAAyD;IACzD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,8FAA8F;IAC9F,gBAAgB,CAAC,EAAE;QAAC,SAAS,EAAE,OAAO,CAAA;KAAC,CAAC;CACxC;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,MAAM,CAAC;AAE/C;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,KAAK,CAAC;AAQ/C;;;;;GAKG;AACH,MAAM,WAAW,uBAAuB;IACvC,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,kFAAkF;IAClF,eAAe,EAAE,WAAW,GAAG,IAAI,CAAC;CACpC;AAED;;GAEG;AACH,MAAM,WAAW,mBAAoB,SAAQ,uBAAuB;IACnE,4FAA4F;IAC5F,0BAA0B,EAAE,WAAW,GAAG,IAAI,CAAC;IAC/C,2FAA2F;IAC3F,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,gBAAgB,GAAG,IAAI,CAAC;IAC5C;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE;QAAC,SAAS,EAAE,OAAO,CAAA;KAAC,CAAC;CACxC;AAID;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,0BAA0B,GACtC,MAAM,gBAAgB,EACtB,SAAS,mBAAmB,KAC1B,KAAK,CAAC,SAAS,CAwRjB,CAAC"}
@@ -147,21 +147,29 @@ const login_fail_delay = (floor_ms, jitter_ms) => {
147
147
  /**
148
148
  * Create account route specs for session-based auth.
149
149
  *
150
- * The returned specs cover the three flows that stay REST after the RPC
151
- * migration (login, logout, password change). Self-service session/token
152
- * management and verify are on `auth/account_actions.ts`.
150
+ * The returned specs cover the REST flows that stay after the RPC migration:
151
+ * `/status` (account info + bootstrap availability), `/verify` (nginx
152
+ * `auth_request` shim), `/login`, `/logout`, `/password`. `/status` is bundled
153
+ * here (relative path, prefixed to `/api/account/status` by the caller) so
154
+ * every account surface serves it, matching the Rust `account_router`.
155
+ * Self-service session/token management is on `auth/account_actions.ts`.
153
156
  *
154
157
  * @param deps - stateless capabilities (keyring, password, log)
155
- * @param options - per-factory configuration (session_options, ip_rate_limiter, login_account_rate_limiter)
158
+ * @param options - per-factory configuration (session_options, ip_rate_limiter, login_account_rate_limiter, bootstrap_status)
156
159
  * @returns route specs (not yet applied to Hono)
157
160
  */
158
161
  export const create_account_route_specs = (deps, options) => {
159
162
  const { keyring, password } = deps;
160
- const { session_options, ip_rate_limiter, login_account_rate_limiter, max_sessions = DEFAULT_MAX_SESSIONS, login_fail_floor_ms = DEFAULT_LOGIN_FAIL_FLOOR_MS, login_fail_jitter_ms = DEFAULT_LOGIN_FAIL_JITTER_MS, connection_closer = null, } = options;
163
+ const { session_options, ip_rate_limiter, login_account_rate_limiter, max_sessions = DEFAULT_MAX_SESSIONS, login_fail_floor_ms = DEFAULT_LOGIN_FAIL_FLOOR_MS, login_fail_jitter_ms = DEFAULT_LOGIN_FAIL_JITTER_MS, connection_closer = null, bootstrap_status, } = options;
161
164
  const [verify_shape, login_shape, logout_shape, password_shape] = create_account_route_shapes({
162
165
  login_account_rate_limited: login_account_rate_limiter !== null,
163
166
  });
164
167
  return [
168
+ // `/status` is bundled into the account family (relative path, prefixed
169
+ // to `/api/account/status` by the caller) so every account surface serves
170
+ // it — matching the Rust `account_router`. The standalone
171
+ // `create_account_status_route_spec` stays the building block.
172
+ create_account_status_route_spec({ bootstrap_status }),
165
173
  {
166
174
  ...verify_shape,
167
175
  handler: (c) => {
@@ -124,7 +124,7 @@ export interface ApiToken {
124
124
  export declare const SessionAccountJson: z.ZodObject<{
125
125
  id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
126
126
  username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
127
- email: z.ZodNullable<z.ZodEmail>;
127
+ email: z.ZodNullable<z.ZodString>;
128
128
  email_verified: z.ZodBoolean;
129
129
  created_at: z.ZodString;
130
130
  }, z.core.$strict>;
@@ -170,7 +170,7 @@ export type ActorSummaryJson = z.infer<typeof ActorSummaryJson>;
170
170
  export declare const AdminAccountJson: z.ZodObject<{
171
171
  id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
172
172
  username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
173
- email: z.ZodNullable<z.ZodEmail>;
173
+ email: z.ZodNullable<z.ZodString>;
174
174
  email_verified: z.ZodBoolean;
175
175
  created_at: z.ZodString;
176
176
  updated_at: z.ZodString;
@@ -207,7 +207,7 @@ export declare const AdminAccountEntryJson: z.ZodObject<{
207
207
  account: z.ZodObject<{
208
208
  id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
209
209
  username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
210
- email: z.ZodNullable<z.ZodEmail>;
210
+ email: z.ZodNullable<z.ZodString>;
211
211
  email_verified: z.ZodBoolean;
212
212
  created_at: z.ZodString;
213
213
  updated_at: z.ZodString;
@@ -37,7 +37,7 @@ export declare const AdminAccountListOutput: z.ZodObject<{
37
37
  account: z.ZodObject<{
38
38
  id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
39
39
  username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
40
- email: z.ZodNullable<z.ZodEmail>;
40
+ email: z.ZodNullable<z.ZodString>;
41
41
  email_verified: z.ZodBoolean;
42
42
  created_at: z.ZodString;
43
43
  updated_at: z.ZodString;
@@ -184,7 +184,7 @@ export declare const AuditLogRoleGrantHistoryOutput: z.ZodObject<{
184
184
  export type AuditLogRoleGrantHistoryOutput = z.infer<typeof AuditLogRoleGrantHistoryOutput>;
185
185
  /** Input for `invite_create`. At least one of `email` / `username` must be provided. */
186
186
  export declare const InviteCreateInput: z.ZodObject<{
187
- email: z.ZodOptional<z.ZodNullable<z.ZodEmail>>;
187
+ email: z.ZodOptional<z.ZodNullable<z.ZodString>>;
188
188
  username: z.ZodOptional<z.ZodNullable<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>>;
189
189
  acting: z.ZodOptional<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
190
190
  }, z.core.$strict>;
@@ -194,7 +194,7 @@ export declare const InviteCreateOutput: z.ZodObject<{
194
194
  ok: z.ZodLiteral<true>;
195
195
  invite: z.ZodObject<{
196
196
  id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
197
- email: z.ZodNullable<z.ZodEmail>;
197
+ email: z.ZodNullable<z.ZodString>;
198
198
  username: z.ZodNullable<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
199
199
  claimed_by: z.ZodNullable<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
200
200
  claimed_at: z.ZodNullable<z.ZodString>;
@@ -212,7 +212,7 @@ export type InviteListInput = z.infer<typeof InviteListInput>;
212
212
  export declare const InviteListOutput: z.ZodObject<{
213
213
  invites: z.ZodArray<z.ZodObject<{
214
214
  id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
215
- email: z.ZodNullable<z.ZodEmail>;
215
+ email: z.ZodNullable<z.ZodString>;
216
216
  username: z.ZodNullable<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
217
217
  claimed_by: z.ZodNullable<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
218
218
  claimed_at: z.ZodNullable<z.ZodString>;
@@ -368,7 +368,7 @@ export declare const admin_account_list_action_spec: {
368
368
  account: z.ZodObject<{
369
369
  id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
370
370
  username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
371
- email: z.ZodNullable<z.ZodEmail>;
371
+ email: z.ZodNullable<z.ZodString>;
372
372
  email_verified: z.ZodBoolean;
373
373
  created_at: z.ZodString;
374
374
  updated_at: z.ZodString;
@@ -590,7 +590,7 @@ export declare const invite_create_action_spec: {
590
590
  };
591
591
  side_effects: true;
592
592
  input: z.ZodObject<{
593
- email: z.ZodOptional<z.ZodNullable<z.ZodEmail>>;
593
+ email: z.ZodOptional<z.ZodNullable<z.ZodString>>;
594
594
  username: z.ZodOptional<z.ZodNullable<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>>;
595
595
  acting: z.ZodOptional<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
596
596
  }, z.core.$strict>;
@@ -598,7 +598,7 @@ export declare const invite_create_action_spec: {
598
598
  ok: z.ZodLiteral<true>;
599
599
  invite: z.ZodObject<{
600
600
  id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
601
- email: z.ZodNullable<z.ZodEmail>;
601
+ email: z.ZodNullable<z.ZodString>;
602
602
  username: z.ZodNullable<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
603
603
  claimed_by: z.ZodNullable<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
604
604
  claimed_at: z.ZodNullable<z.ZodString>;
@@ -632,7 +632,7 @@ export declare const invite_list_action_spec: {
632
632
  output: z.ZodObject<{
633
633
  invites: z.ZodArray<z.ZodObject<{
634
634
  id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
635
- email: z.ZodNullable<z.ZodEmail>;
635
+ email: z.ZodNullable<z.ZodString>;
636
636
  username: z.ZodNullable<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
637
637
  claimed_by: z.ZodNullable<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
638
638
  claimed_at: z.ZodNullable<z.ZodString>;
@@ -83,7 +83,7 @@ export declare const audit_metadata_schemas: Readonly<{
83
83
  invite_id: z.ZodOptional<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
84
84
  open_signup: z.ZodOptional<z.ZodBoolean>;
85
85
  reason: z.ZodOptional<z.ZodString>;
86
- email: z.ZodOptional<z.ZodEmail>;
86
+ email: z.ZodOptional<z.ZodString>;
87
87
  }, z.core.$loose>;
88
88
  password_change: z.ZodNullable<z.ZodObject<{
89
89
  sessions_revoked: z.ZodOptional<z.ZodNumber>;
@@ -22,7 +22,7 @@ export interface Invite {
22
22
  /** Zod schema for client-safe invite data. */
23
23
  export declare const InviteJson: z.ZodObject<{
24
24
  id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
25
- email: z.ZodNullable<z.ZodEmail>;
25
+ email: z.ZodNullable<z.ZodString>;
26
26
  username: z.ZodNullable<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
27
27
  claimed_by: z.ZodNullable<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
28
28
  claimed_at: z.ZodNullable<z.ZodString>;
@@ -33,7 +33,7 @@ export type InviteJson = z.infer<typeof InviteJson>;
33
33
  /** Zod schema for invite data with resolved creator/claimer usernames. */
34
34
  export declare const InviteWithUsernamesJson: z.ZodObject<{
35
35
  id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
36
- email: z.ZodNullable<z.ZodEmail>;
36
+ email: z.ZodNullable<z.ZodString>;
37
37
  username: z.ZodNullable<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
38
38
  claimed_by: z.ZodNullable<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
39
39
  claimed_at: z.ZodNullable<z.ZodString>;
@@ -13,11 +13,11 @@
13
13
  */
14
14
  import { z } from 'zod';
15
15
  import type { RouteSpec } from '../http/route_spec.js';
16
- /** Input for `POST /signup`. `email` is optional and must match any referenced invite. */
16
+ /** Input for `POST /signup`. `email` is optional (absent or `null` = no email) and must match any referenced invite. */
17
17
  export declare const SignupInput: z.ZodObject<{
18
18
  username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
19
19
  password: z.ZodString;
20
- email: z.ZodOptional<z.ZodEmail>;
20
+ email: z.ZodOptional<z.ZodNullable<z.ZodString>>;
21
21
  }, z.core.$strict>;
22
22
  export type SignupInput = z.infer<typeof SignupInput>;
23
23
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"signup_route_schema.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/signup_route_schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAKtB,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,uBAAuB,CAAC;AAQrD,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,4EAA4E;AAC5E,MAAM,WAAW,uBAAuB;IACvC,iFAAiF;IACjF,2BAA2B,EAAE,OAAO,CAAC;CACrC;AAED;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,GACrC,SAAS,uBAAuB,KAC9B,IAAI,CAAC,SAAS,EAAE,SAAS,CAgB1B,CAAC"}
1
+ {"version":3,"file":"signup_route_schema.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/signup_route_schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAKtB,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,uBAAuB,CAAC;AAQrD,wHAAwH;AACxH,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,4EAA4E;AAC5E,MAAM,WAAW,uBAAuB;IACvC,iFAAiF;IACjF,2BAA2B,EAAE,OAAO,CAAC;CACrC;AAED;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,GACrC,SAAS,uBAAuB,KAC9B,IAAI,CAAC,SAAS,EAAE,SAAS,CAgB1B,CAAC"}
@@ -16,11 +16,11 @@ import { Uuid } from '@fuzdev/fuz_util/id.js';
16
16
  import { Username, Email } from '../primitive_schemas.js';
17
17
  import { Password } from './password.js';
18
18
  import { ERROR_NO_MATCHING_INVITE, ERROR_SIGNUP_CONFLICT, ERROR_INVALID_JSON_BODY, ERROR_INVALID_REQUEST_BODY, } from '../http/error_schemas.js';
19
- /** Input for `POST /signup`. `email` is optional and must match any referenced invite. */
19
+ /** Input for `POST /signup`. `email` is optional (absent or `null` = no email) and must match any referenced invite. */
20
20
  export const SignupInput = z.strictObject({
21
21
  username: Username,
22
22
  password: Password,
23
- email: Email.optional(),
23
+ email: Email.nullish(),
24
24
  });
25
25
  /**
26
26
  * Output for `POST /signup`.
@@ -40,16 +40,42 @@ export type Username = z.infer<typeof Username>;
40
40
  * per-account rate-limit key and DB lookup see a uniform value
41
41
  * regardless of casing or surrounding whitespace. Mirrors the storage
42
42
  * canonicalization on `Username` so submission and storage agree.
43
+ *
44
+ * The trailing `.refine` rejects a whitespace-only identifier: `.min(1)`
45
+ * runs on the raw string (so `" "` passes it), and without the post-trim
46
+ * check the value would canonicalize to `""` and fall through to a
47
+ * lookup-miss 401 instead of a 400 — an empty identifier is malformed
48
+ * input, not a wrong credential. Keeps the Rust spine's
49
+ * `account_login` (which rejects empty-after-trim) in parity.
43
50
  */
44
51
  export declare const UsernameProvided: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
45
52
  export type UsernameProvided = z.infer<typeof UsernameProvided>;
53
+ /**
54
+ * Maximum email length in **bytes** — RFC 5321 §4.5.3.1.3 path-length limit
55
+ * (the limit is octets). `Email` bounds the UTF-8 byte length, matching the
56
+ * Rust spine's `s.len()` check; for an all-ASCII address bytes == characters.
57
+ */
58
+ export declare const EMAIL_LENGTH_MAX = 254;
46
59
  /**
47
60
  * Email validation. Lives here rather than `@fuzdev/fuz_util` because every
48
61
  * current consumer pairs it with `Username` (signup, invites, audit log) —
49
62
  * keeping the two together avoids a cross-package import for the
50
63
  * identity-primitive bundle. Promote to fuz_util if a non-identity consumer
51
64
  * surfaces.
65
+ *
66
+ * Deliberately permissive — a structural shape check (`EMAIL_REGEX` plus the
67
+ * `EMAIL_LENGTH_MAX` byte bound), not RFC 5322 conformance or deliverability
68
+ * (real delivery is proven by a confirmation email). Replaces Zod's stricter
69
+ * `z.email()` so the rule is one explicit regex the Rust spine's
70
+ * `is_valid_email` mirrors; `z.email()`'s internal regex (2+ char TLD, no
71
+ * consecutive dots) was brittle to keep in cross-impl parity and rejected
72
+ * addresses like `a@b.c`. The length bound is the UTF-8 **byte** count (RFC
73
+ * 5321 measures octets), so a multibyte address is bounded identically to the
74
+ * Rust spine's `s.len()` rather than diverging on JS's UTF-16 `.length`. No
75
+ * transform: case is preserved and surrounding whitespace is rejected (not
76
+ * trimmed), so storage is verbatim and the case-insensitive lookup rides the
77
+ * DB-side `LOWER(email)`.
52
78
  */
53
- export declare const Email: z.ZodEmail;
79
+ export declare const Email: z.ZodString;
54
80
  export type Email = z.infer<typeof Email>;
55
81
  //# sourceMappingURL=primitive_schemas.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"primitive_schemas.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/primitive_schemas.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAItB,2EAA2E;AAC3E,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC,wDAAwD;AACxD,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAEtC,gKAAgK;AAChK,eAAO,MAAM,4BAA4B,MAAM,CAAC;AAEhD;;;;;;;;;GASG;AACH,eAAO,MAAM,QAAQ,wDAKc,CAAC;AACpC,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,QAAQ,CAAC,CAAC;AAEhD;;;;;;;GAOG;AACH,eAAO,MAAM,gBAAgB,wDAIa,CAAC;AAC3C,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEhE;;;;;;GAMG;AACH,eAAO,MAAM,KAAK,YAAY,CAAC;AAC/B,MAAM,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,KAAK,CAAC,CAAC"}
1
+ {"version":3,"file":"primitive_schemas.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/primitive_schemas.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAItB,2EAA2E;AAC3E,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC,wDAAwD;AACxD,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAEtC,gKAAgK;AAChK,eAAO,MAAM,4BAA4B,MAAM,CAAC;AAEhD;;;;;;;;;GASG;AACH,eAAO,MAAM,QAAQ,wDAKc,CAAC;AACpC,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,QAAQ,CAAC,CAAC;AAEhD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,gBAAgB,wDAKmE,CAAC;AACjG,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEhE;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,MAAM,CAAC;AAqBpC;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,KAAK,aAYhB,CAAC;AACH,MAAM,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,KAAK,CAAC,CAAC"}
@@ -45,17 +45,70 @@ export const Username = z
45
45
  * per-account rate-limit key and DB lookup see a uniform value
46
46
  * regardless of casing or surrounding whitespace. Mirrors the storage
47
47
  * canonicalization on `Username` so submission and storage agree.
48
+ *
49
+ * The trailing `.refine` rejects a whitespace-only identifier: `.min(1)`
50
+ * runs on the raw string (so `" "` passes it), and without the post-trim
51
+ * check the value would canonicalize to `""` and fall through to a
52
+ * lookup-miss 401 instead of a 400 — an empty identifier is malformed
53
+ * input, not a wrong credential. Keeps the Rust spine's
54
+ * `account_login` (which rejects empty-after-trim) in parity.
48
55
  */
49
56
  export const UsernameProvided = z
50
57
  .string()
51
58
  .min(1)
52
59
  .max(USERNAME_PROVIDED_LENGTH_MAX)
53
- .transform((s) => s.trim().toLowerCase());
60
+ .transform((s) => s.trim().toLowerCase())
61
+ .refine((s) => s.length > 0, { message: 'Username must not be empty after trimming whitespace' });
62
+ /**
63
+ * Maximum email length in **bytes** — RFC 5321 §4.5.3.1.3 path-length limit
64
+ * (the limit is octets). `Email` bounds the UTF-8 byte length, matching the
65
+ * Rust spine's `s.len()` check; for an all-ASCII address bytes == characters.
66
+ */
67
+ export const EMAIL_LENGTH_MAX = 254;
68
+ /**
69
+ * Loose email shape `local@domain.tld`: a non-empty local part, exactly one
70
+ * `@`, a domain with an interior dot (a non-empty label on each side), and
71
+ * no whitespace. Accepts `a@b.c` (single-char TLDs are fine) and
72
+ * `user+tag@a.co`; rejects `notanemail`, `@x.com`, `user@`, `user@host`, and
73
+ * any whitespace.
74
+ *
75
+ * The negated class excludes `\s` **plus** U+0085 (NEL): JS's `\s` omits
76
+ * U+0085 but Unicode `White_Space` (Rust's `char::is_whitespace`) includes
77
+ * it, so excluding it keeps the whitespace set identical across the twins.
78
+ * (The Rust side correspondingly also rejects U+FEFF, which JS `\s` treats as
79
+ * whitespace but Unicode `White_Space` does not — so both spines reject
80
+ * exactly `White_Space ∪ {U+FEFF}`.)
81
+ */
82
+ const EMAIL_REGEX = /^[^\s\u0085@]+@[^\s\u0085@]+\.[^\s\u0085@]+$/;
83
+ /** UTF-8 encoder for the `EMAIL_LENGTH_MAX` byte (octet) bound. */
84
+ const utf8_encoder = new TextEncoder();
54
85
  /**
55
86
  * Email validation. Lives here rather than `@fuzdev/fuz_util` because every
56
87
  * current consumer pairs it with `Username` (signup, invites, audit log) —
57
88
  * keeping the two together avoids a cross-package import for the
58
89
  * identity-primitive bundle. Promote to fuz_util if a non-identity consumer
59
90
  * surfaces.
91
+ *
92
+ * Deliberately permissive — a structural shape check (`EMAIL_REGEX` plus the
93
+ * `EMAIL_LENGTH_MAX` byte bound), not RFC 5322 conformance or deliverability
94
+ * (real delivery is proven by a confirmation email). Replaces Zod's stricter
95
+ * `z.email()` so the rule is one explicit regex the Rust spine's
96
+ * `is_valid_email` mirrors; `z.email()`'s internal regex (2+ char TLD, no
97
+ * consecutive dots) was brittle to keep in cross-impl parity and rejected
98
+ * addresses like `a@b.c`. The length bound is the UTF-8 **byte** count (RFC
99
+ * 5321 measures octets), so a multibyte address is bounded identically to the
100
+ * Rust spine's `s.len()` rather than diverging on JS's UTF-16 `.length`. No
101
+ * transform: case is preserved and surrounding whitespace is rejected (not
102
+ * trimmed), so storage is verbatim and the case-insensitive lookup rides the
103
+ * DB-side `LOWER(email)`.
60
104
  */
61
- export const Email = z.email();
105
+ export const Email = z
106
+ .string()
107
+ .regex(EMAIL_REGEX)
108
+ // `s.length` (UTF-16 units) is a cheap upper bound that short-circuits the
109
+ // `encode` on absurd inputs: a valid (<= 254-byte) address is necessarily
110
+ // <= 254 code units, so this never rejects one — the byte count is the real
111
+ // RFC-5321 octet bound, matching the Rust spine's `s.len()`.
112
+ .refine((s) => s.length <= EMAIL_LENGTH_MAX && utf8_encoder.encode(s).length <= EMAIL_LENGTH_MAX, {
113
+ message: `Email must be at most ${EMAIL_LENGTH_MAX} bytes`,
114
+ });
@@ -33,7 +33,7 @@ export declare const BaseServerEnv: z.ZodObject<{
33
33
  PUBLIC_FUZ_CONTACT_EMAIL: z.ZodOptional<z.ZodUnion<readonly [z.ZodEmail, z.ZodLiteral<"">]>>;
34
34
  FUZ_BOOTSTRAP_TOKEN_PATH: z.ZodOptional<z.ZodString>;
35
35
  SMTP_HOST: z.ZodOptional<z.ZodString>;
36
- SMTP_USER: z.ZodOptional<z.ZodUnion<readonly [z.ZodEmail, z.ZodLiteral<"">]>>;
36
+ SMTP_USER: z.ZodOptional<z.ZodString>;
37
37
  SMTP_PASSWORD: z.ZodOptional<z.ZodString>;
38
38
  FUZ_FACTS_DIR: z.ZodDefault<z.ZodString>;
39
39
  FUZ_FACTS_X_ACCEL_REDIRECT_PREFIX: z.ZodOptional<z.ZodString>;
@@ -1 +1 @@
1
- {"version":3,"file":"env.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/env.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAEtB,OAAO,EAA2B,KAAK,OAAO,EAAC,MAAM,oBAAoB,CAAC;AAG1E;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;kBAwCxB,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAE1D;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAChC,EAAE,EAAE,IAAI,CAAC;IACT,OAAO,EAAE,OAAO,CAAC;IACjB,eAAe,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IAC/B,oBAAoB,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACrC,EAAE,EAAE,KAAK,CAAC;IACV,KAAK,EAAE,wBAAwB,GAAG,qBAAqB,CAAC;IACxD,MAAM,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACtB;AAED,MAAM,MAAM,sBAAsB,GAAG,gBAAgB,GAAG,qBAAqB,CAAC;AAE9E;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAAI,KAAK,aAAa,KAAG,sBA4BxD,CAAC"}
1
+ {"version":3,"file":"env.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/env.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAEtB,OAAO,EAA2B,KAAK,OAAO,EAAC,MAAM,oBAAoB,CAAC;AAG1E;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;kBAoDxB,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAE1D;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAChC,EAAE,EAAE,IAAI,CAAC;IACT,OAAO,EAAE,OAAO,CAAC;IACjB,eAAe,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IAC/B,oBAAoB,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACrC,EAAE,EAAE,KAAK,CAAC;IACV,KAAK,EAAE,wBAAwB,GAAG,qBAAqB,CAAC;IACxD,MAAM,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACtB;AAED,MAAM,MAAM,sBAAsB,GAAG,gBAAgB,GAAG,qBAAqB,CAAC;AAE9E;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAAI,KAAK,aAAa,KAAG,sBA4BxD,CAAC"}
@@ -21,7 +21,13 @@ import { parse_allowed_origins } from '../http/origin.js';
21
21
  */
22
22
  export const BaseServerEnv = z.strictObject({
23
23
  NODE_ENV: z.enum(['development', 'production']).meta({ description: 'Runtime environment mode' }),
24
- PORT: z.coerce.number().default(4040).meta({ description: 'HTTP server port' }),
24
+ PORT: z.coerce
25
+ .number()
26
+ .int()
27
+ .min(1)
28
+ .max(65535)
29
+ .default(4040)
30
+ .meta({ description: 'HTTP server port' }),
25
31
  HOST: z.string().default('localhost').meta({ description: 'HTTP server bind address' }),
26
32
  DATABASE_URL: z.string().min(1).meta({
27
33
  description: 'Database URL (postgres://, file://, or memory://)',
@@ -45,10 +51,16 @@ export const BaseServerEnv = z.strictObject({
45
51
  .optional()
46
52
  .meta({ description: 'Path to one-shot admin bootstrap token', sensitivity: 'secret' }),
47
53
  SMTP_HOST: z.string().optional().meta({ description: 'SMTP server hostname' }),
54
+ // SMTP usernames are frequently not emails (SendGrid uses "apikey",
55
+ // AWS SES / Postmark use token-style credentials), so validate as a plain
56
+ // string — `z.email()` here would reject valid provider configs. Marked
57
+ // `secret`: those token-style usernames are credentials (Postmark reuses
58
+ // the server token as both user and password), so the value is masked in
59
+ // the startup summary / logs like `SMTP_PASSWORD`.
48
60
  SMTP_USER: z
49
- .union([z.email(), z.literal('')])
61
+ .string()
50
62
  .optional()
51
- .meta({ description: 'SMTP authentication username' }),
63
+ .meta({ description: 'SMTP authentication username', sensitivity: 'secret' }),
52
64
  SMTP_PASSWORD: z
53
65
  .string()
54
66
  .optional()
@@ -840,7 +840,12 @@ source of truth for wire-shape conformance.
840
840
  whose `RoleSpec.grant_paths` don't include `'admin'` reject loudly at
841
841
  offer-create time (`ERROR_ROLE_GRANT_OFFER_ROLE_NOT_GRANTABLE`); those
842
842
  roles must be seeded via `extra_accounts` at bootstrap-equivalent time
843
- instead. Caller-supplied `username`s pass through _as-is_ now that the DB
843
+ instead. `create_account({email})` threads an email onto the signup body
844
+ (the username-scoped invite matches regardless of email), so the account
845
+ lands with an email set — the hook the username-or-email login parity suite
846
+ uses; the in-process path sets it directly on the inserted row. Must be a
847
+ valid, per-test-unique `Email` (the `LOWER(email)` partial-unique index
848
+ rejects collisions). Caller-supplied `username`s pass through _as-is_ now that the DB
844
849
  wipes between tests — hardcoded names like `'user_two'` work and the
845
850
  earlier uniquification prefix is gone. Every `TestFixture` also exposes
846
851
  `fresh_transport({origin?: string | null})` — cookie-jar-free probe;
@@ -858,7 +863,7 @@ source of truth for wire-shape conformance.
858
863
  - `testing/cross_backend/capabilities.ts` — `BackendCapabilities` vocabulary
859
864
  (`bearer_auth` / `trusted_proxy` / `login_rate_limit` / `ws` / `sse` /
860
865
  `cell_crud` / `cell_relations` / `account_lifecycle` / `fact_serving` /
861
- `ready`),
866
+ `ready` / `account_status` / `oversized_reject_closes_connection`),
862
867
  `test_if(cond, name, fn)`
863
868
  for capability-gated cases, and `in_process_capabilities` preset. `cell_crud`
864
869
  gates the CRUD parity suite, `cell_relations` the relation / ACL / audit
@@ -875,6 +880,14 @@ source of truth for wire-shape conformance.
875
880
  (anonymous `GET /ready` → `200 {ready: true}` on a clean spine bootstrap);
876
881
  like cells/sse the `/ready` deploy gate stays off the declared surface, `true`
877
882
  on every spine that live-mounts it over the shared `expected_schema.json`.
883
+ `account_status` gates the integration suite's `account status response body`
884
+ case — `GET /api/account/status` is bundled into `create_account_route_specs`,
885
+ so every spine serves it (`true`); the gate fails loud if a declaring backend
886
+ doesn't mount it (replacing the old runtime 404-sniff-skip).
887
+ `oversized_reject_closes_connection` gates the strong half of the body-size
888
+ smuggling probe (`true` Node/Deno/Rust — they close on an oversized reject;
889
+ `false` Bun — `Bun.serve` drains + keepalives but frames correctly, so the
890
+ suite's no-desync half still runs).
878
891
 
879
892
  ### `cross_backend/standard.ts` — `describe_standard_cross_process_tests`
880
893
 
@@ -1239,6 +1252,53 @@ in-process spec-level gate test (`src/test/testing/testing_actions_auth.test.ts`
1239
1252
  asserting each spec declares `credential_types: ['daemon_token']`) + the
1240
1253
  `assert_no_testing_methods` surface invariant.
1241
1254
 
1255
+ ### Identity-primitive parity — `cross_backend/identity_parity.ts`
1256
+
1257
+ `describe_identity_parity_cross_tests({setup_test, login_path?, signup_path?})` —
1258
+ the imperative suite pinning the `primitive_schemas` twins (`Username`,
1259
+ `UsernameProvided`, `Email`) + login/signup input handling end-to-end over real
1260
+ HTTP (flat REST `/login` + `/signup`, so imperative, not a `conformance_table`
1261
+ row). Six facets, each a TS↔Rust reimplementation pair that the spec-derived
1262
+ round-trip / conformance suites never vary:
1263
+
1264
+ - **Login canonicalization** — case-insensitive + whitespace-trim lookup
1265
+ (`UsernameProvided` `.trim().toLowerCase()` + `LOWER(username)`), plus a
1266
+ Turkish-`İ` homograph **no-Unicode-fold-collision** negative.
1267
+ - **Username-or-email login** — an account created with an email logs in via
1268
+ that email, case-insensitively both directions; username login still works;
1269
+ non-existent email → 401. This is the corner that surfaced and closed the Rust
1270
+ spine's username-only divergence (it now mirrors TS's
1271
+ `query_account_by_username_or_email`).
1272
+ - **Login input validation** — malformed login input → 400 `invalid_request_body`
1273
+ on every spine: whitespace-only username (empty after trim), over-long username
1274
+ (> 255), empty password, unknown body key (strict object). TS runs the full
1275
+ `LoginInput` Zod schema; the Rust spine enforces the same shape in
1276
+ `account_login` (closed a cluster where three of these fell through to a
1277
+ lookup-miss 401 on Rust and a whitespace-only identifier 401'd on TS). Asserted
1278
+ by error reason, not just status.
1279
+ - **ASCII-only creation invariant** — non-ASCII username → 400 at signup.
1280
+ - **Length + format regex parity** — both `[USERNAME_LENGTH_MIN,
1281
+ USERNAME_LENGTH_MAX]` = `[3, 39]` boundary edges (just-outside → 400,
1282
+ just-inside → 403 no-matching-invite) and the creation regex shape (leading
1283
+ non-letter / trailing punctuation / embedded disallowed char → 400; mid-string
1284
+ `_`/`-` accepted), catching an off-by-one or position bug in the TS Zod
1285
+ `.min/.max/.regex` vs the Rust byte-scan `is_valid_username_for_creation`.
1286
+ - **Email format (creation)** — the optional signup `email` validated to the
1287
+ loose `local@domain.tld` shape (TS `Email` `^[^\s@]+@[^\s@]+\.[^\s@]+$` + 254-byte
1288
+ bound, Rust `is_valid_email`): malformed (no `@` / no domain dot / empty
1289
+ side / dot at a domain edge / whitespace / over-length) → 400, well-formed →
1290
+ 403 no-invite. Accepted siblings include the single-char-TLD `a@b.c` that
1291
+ Zod's `z.email()` rejects — the deliberately-looser shared rule. **Closed a
1292
+ real divergence**: the Rust spine previously length-checked the email only,
1293
+ accepting any non-empty string TS rejected.
1294
+
1295
+ Ungated (login + signup are on every spine). Runs both legs via the shared
1296
+ `{setup_test}` protocol: in-process (`identity_parity.db.test.ts`, plain
1297
+ `gro test`) and cross-process (`identity_parity.cross.test.ts`). `$lib`-free.
1298
+ The accepted-case 403s rely on `create_spine_route_specs` zeroing
1299
+ `signup_fail_floor_ms` (mirroring `login_fail_floor_ms: 0`) so they don't each
1300
+ pay the 250ms denial floor.
1301
+
1242
1302
  ### Building a TS test-server binary — `testing_server_core.ts` + adapters
1243
1303
 
1244
1304
  The reusable shape for standing up a **spawnable TS** cross-process test