@fuzdev/fuz_app 0.89.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.
@@ -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>;
@@ -108,7 +108,7 @@ export declare const account_status_route_shape: {
108
108
  account: z.ZodObject<{
109
109
  id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
110
110
  username: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
111
- email: z.ZodNullable<z.ZodEmail>;
111
+ email: z.ZodNullable<z.ZodString>;
112
112
  email_verified: z.ZodBoolean;
113
113
  created_at: z.ZodString;
114
114
  }, z.core.$strict>;
@@ -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;
@@ -1247,6 +1252,53 @@ in-process spec-level gate test (`src/test/testing/testing_actions_auth.test.ts`
1247
1252
  asserting each spec declares `credential_types: ['daemon_token']`) + the
1248
1253
  `assert_no_testing_methods` surface invariant.
1249
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
+
1250
1302
  ### Building a TS test-server binary — `testing_server_core.ts` + adapters
1251
1303
 
1252
1304
  The reusable shape for standing up a **spawnable TS** cross-process test
@@ -48,6 +48,8 @@ export interface CreateTestAccountWithCredentialsOptions {
48
48
  username?: string;
49
49
  password_value?: string;
50
50
  roles?: Array<string>;
51
+ /** Optional email stored on the account row — exercises the username-or-email login lookup. */
52
+ email?: string;
51
53
  }
52
54
  /** Alias for the keeper-flavored call site. Same shape. */
53
55
  export type BootstrapTestKeeperOptions = CreateTestAccountWithCredentialsOptions;
@@ -269,6 +271,19 @@ export interface TestAccount {
269
271
  /** Build request headers with this account's Bearer token. */
270
272
  create_bearer_headers: (extra?: Record<string, string>) => Record<string, string>;
271
273
  }
274
+ /**
275
+ * Args for `TestApp.create_account` — mint an additional account alongside the
276
+ * keeper. Mirrors the cross-process `CreateTestAccountOptions` (the two
277
+ * `create_account` paths share a shape so fixture call sites read identically),
278
+ * kept local to avoid a cross-module cycle with `cross_backend/setup.ts`.
279
+ */
280
+ export interface CreateTestAppAccountArgs {
281
+ username?: string;
282
+ password_value?: string;
283
+ roles?: Array<string>;
284
+ /** Optional email stored on the account row — exercises the username-or-email login lookup. */
285
+ email?: string;
286
+ }
272
287
  /**
273
288
  * A fully assembled test app — Hono app + backend + helpers.
274
289
  */
@@ -285,11 +300,7 @@ export interface TestApp {
285
300
  /** Build request headers with the daemon token (keeper auth). */
286
301
  create_daemon_token_headers: (extra?: Record<string, string>) => Record<string, string>;
287
302
  /** Create an additional account with credentials. */
288
- create_account: (options?: {
289
- username?: string;
290
- password_value?: string;
291
- roles?: Array<string>;
292
- }) => Promise<TestAccount>;
303
+ create_account: (options?: CreateTestAppAccountArgs) => Promise<TestAccount>;
293
304
  /** Cleanup resources (delegates to TestAppServer.cleanup). */
294
305
  cleanup: () => Promise<void>;
295
306
  }
@@ -1 +1 @@
1
- {"version":3,"file":"app_server.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/app_server.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAE7B;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAC,IAAI,EAAC,MAAM,MAAM,CAAC;AAG/B,OAAO,KAAK,EAAC,IAAI,EAAC,MAAM,wBAAwB,CAAC;AAIjD,OAAO,EAA2B,KAAK,OAAO,EAAC,MAAM,oBAAoB,CAAC;AAE1E,OAAO,KAAK,EAAC,EAAE,EAAE,MAAM,EAAC,MAAM,aAAa,CAAC;AAC5C,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,qBAAqB,CAAC;AAU1D,OAAO,EAA8B,KAAK,cAAc,EAAC,MAAM,2BAA2B,CAAC;AAC3F,OAAO,EAAiB,KAAK,kBAAkB,EAAC,MAAM,kBAAkB,CAAC;AAEzE,OAAO,EAAwB,KAAK,UAAU,EAAE,KAAK,YAAY,EAAC,MAAM,0BAA0B,CAAC;AACnG,OAAO,EAEN,KAAK,gBAAgB,EACrB,KAAK,sBAAsB,EAC3B,KAAK,oBAAoB,EACzB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,iCAAiC,CAAC;AACtE,OAAO,KAAK,EAAC,UAAU,EAAE,cAAc,EAAC,MAAM,oBAAoB,CAAC;AACnE,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,uBAAuB,CAAC;AAOrD,OAAO,KAAK,EAAC,uBAAuB,EAAC,MAAM,kBAAkB,CAAC;AAE9D;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,EAAE,gBAIhC,CAAC;AAEF,gFAAgF;AAChF,eAAO,MAAM,kBAAkB,QAAiB,CAAC;AA0CjD;;;;;GAKG;AACH,MAAM,WAAW,uCAAuC;IACvD,EAAE,EAAE,EAAE,CAAC;IACP,OAAO,EAAE,OAAO,CAAC;IACjB,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,KAAK,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACtB;AAED,2DAA2D;AAC3D,MAAM,MAAM,0BAA0B,GAAG,uCAAuC,CAAC;AAEjF;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,oCAAoC,GAChD,SAAS,uCAAuC,KAC9C,OAAO,CAAC;IACV,OAAO,EAAE;QAAC,EAAE,EAAE,IAAI,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAC,CAAC;IACtC,KAAK,EAAE;QAAC,EAAE,EAAE,IAAI,CAAA;KAAC,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;CACvB,CA4CA,CAAC;AAEF,uCAAuC;AACvC,MAAM,WAAW,sBAAsB;IACtC,EAAE,EAAE,EAAE,CAAC;IACP,OAAO,EAAE,OAAO,CAAC;IACjB,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,6CAA6C;IAC7C,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;;;;;OASG;IACH,kBAAkB,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iBAAiB,GAC7B,SAAS,sBAAsB,KAC7B,OAAO,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAAC,CAQlC,CAAC;AAEF;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,qBAAqB,GACjC,SAAS,0BAA0B,KACjC,OAAO,CAAC;IACV,OAAO,EAAE;QAAC,EAAE,EAAE,IAAI,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAC,CAAC;IACtC,KAAK,EAAE;QAAC,EAAE,EAAE,IAAI,CAAA;KAAC,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;CACvB,CAQA,CAAC;AAEF;;GAEG;AACH,MAAM,WAAW,aAAc,SAAQ,UAAU;IAChD,gCAAgC;IAChC,OAAO,EAAE;QAAC,EAAE,EAAE,IAAI,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAC,CAAC;IACtC,uCAAuC;IACvC,KAAK,EAAE;QAAC,EAAE,EAAE,IAAI,CAAA;KAAC,CAAC;IAClB,qCAAqC;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB,mDAAmD;IACnD,cAAc,EAAE,MAAM,CAAC;IACvB,+FAA+F;IAC/F,OAAO,EAAE,OAAO,CAAC;IACjB,4EAA4E;IAC5E,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7B;AAED;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACpC,mDAAmD;IACnD,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,kGAAkG;IAClG,EAAE,CAAC,EAAE,EAAE,CAAC;IACR,0FAA0F;IAC1F,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;;;;;OASG;IACH,oBAAoB,CAAC,EAAE,aAAa,CAAC,kBAAkB,CAAC,CAAC;IACzD,yHAAyH;IACzH,QAAQ,CAAC,EAAE,gBAAgB,CAAC;IAC5B,kEAAkE;IAClE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,+EAA+E;IAC/E,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gDAAgD;IAChD,KAAK,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IACtB;;;;;;;;;;;;;;OAcG;IACH,aAAa,CAAC,EAAE,YAAY,CAAC;CAC7B;AAuID,eAAO,MAAM,sBAAsB,GAClC,SAAS,oBAAoB,KAC3B,OAAO,CAAC,aAAa,CA2BvB,CAAC;AAEF;;GAEG;AACH,MAAM,WAAW,oBAAqB,SAAQ,oBAAoB;IACjE,yEAAyE;IACzE,kBAAkB,EAAE,CAAC,OAAO,EAAE,gBAAgB,KAAK,KAAK,CAAC,SAAS,CAAC,CAAC;IACpE;;;;;;;OAOG;IACH,aAAa,CAAC,EAAE,uBAAuB,CAAC;IACxC;;;;;;;;;;;OAWG;IACH,SAAS,CAAC,EAAE,sBAAsB,CAAC;IACnC;;;;;OAKG;IACH,WAAW,CAAC,EAAE,eAAe,CAAC;CAC9B;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,eAAe,GAAG,OAAO,CACpC,IAAI,CACH,gBAAgB,EAChB,SAAS,GAAG,iBAAiB,GAAG,oBAAoB,GAAG,eAAe,GAAG,WAAW,CACpF,CACD,CAAC;AAEF;;GAEG;AACH,MAAM,WAAW,WAAW;IAC3B,OAAO,EAAE;QAAC,EAAE,EAAE,IAAI,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAC,CAAC;IACtC,KAAK,EAAE;QAAC,EAAE,EAAE,IAAI,CAAA;KAAC,CAAC;IAClB,mCAAmC;IACnC,cAAc,EAAE,MAAM,CAAC;IACvB,qCAAqC;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB,gEAAgE;IAChE,sBAAsB,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnF,8DAA8D;IAC9D,qBAAqB,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClF;AAED;;GAEG;AACH,MAAM,WAAW,OAAO;IACvB,GAAG,EAAE,IAAI,CAAC;IACV,OAAO,EAAE,aAAa,CAAC;IACvB,YAAY,EAAE,cAAc,CAAC;IAC7B,OAAO,EAAE,UAAU,CAAC;IACpB,WAAW,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;IAC9B,kEAAkE;IAClE,sBAAsB,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnF,gEAAgE;IAChE,qBAAqB,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAClF,iEAAiE;IACjE,2BAA2B,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACxF,qDAAqD;IACrD,cAAc,EAAE,CAAC,OAAO,CAAC,EAAE;QAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,cAAc,CAAC,EAAE,MAAM,CAAC;QACxB,KAAK,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;KACtB,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC;IAC3B,8DAA8D;IAC9D,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7B;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,eAAe,GAAU,SAAS,oBAAoB,KAAG,OAAO,CAAC,OAAO,CAoGpF,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,WAAW,gCAAgC;IAChD,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,kBAAkB,EAAE,CAAC,OAAO,EAAE,gBAAgB,KAAK,KAAK,CAAC,SAAS,CAAC,CAAC;IACpE,aAAa,CAAC,EAAE,uBAAuB,CAAC;IACxC,WAAW,CAAC,EAAE,eAAe,CAAC;IAC9B,8EAA8E;IAC9E,SAAS,EAAE,oBAAoB,CAAC;IAChC;;;;OAIG;IACH,eAAe,EAAE,MAAM,CAAC;IACxB,EAAE,CAAC,EAAE,EAAE,CAAC;IACR,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,gBAAgB,CAAC;IAC5B,aAAa,CAAC,EAAE,YAAY,CAAC;CAC7B;AAED;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IACnC,GAAG,EAAE,IAAI,CAAC;IACV,OAAO,EAAE,UAAU,CAAC;IACpB,YAAY,EAAE,cAAc,CAAC;IAC7B,OAAO,EAAE,UAAU,CAAC;IACpB,WAAW,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;IAC9B,0EAA0E;IAC1E,sBAAsB,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnF,4EAA4E;IAC5E,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7B;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,6BAA6B,GACzC,SAAS,gCAAgC,KACvC,OAAO,CAAC,mBAAmB,CAuE7B,CAAC"}
1
+ {"version":3,"file":"app_server.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/app_server.ts"],"names":[],"mappings":"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"}
@@ -78,12 +78,13 @@ const resolve_fallback_factory = (migration_namespaces) => {
78
78
  * `role_grant` (one per role), `api_token`, and `auth_session`.
79
79
  */
80
80
  export const create_test_account_with_credentials = async (options) => {
81
- const { db, keyring, session_options, password, username = 'keeper', password_value = DEFAULT_TEST_PASSWORD, roles = [], } = options;
81
+ const { db, keyring, session_options, password, username = 'keeper', password_value = DEFAULT_TEST_PASSWORD, roles = [], email, } = options;
82
82
  const deps = { db };
83
83
  const password_hash = await password.hash_password(password_value);
84
84
  const { account, actor } = await query_create_account_with_actor(deps, {
85
85
  username,
86
86
  password_hash,
87
+ ...(email !== undefined && { email }),
87
88
  });
88
89
  // Grant roles
89
90
  for (const role of roles) {
@@ -315,6 +316,7 @@ export const create_test_app = async (options) => {
315
316
  username: account_options?.username ?? `test_user_${account_counter}`,
316
317
  password_value: account_options?.password_value ?? DEFAULT_TEST_PASSWORD,
317
318
  roles: account_options?.roles ?? [],
319
+ ...(account_options?.email !== undefined && { email: account_options.email }),
318
320
  });
319
321
  return {
320
322
  ...bootstrapped,
@@ -1 +1 @@
1
- {"version":3,"file":"default_spine_surface.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_spine_surface.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAIpD,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,8CAA8C,CAAC;AACrF,OAAO,EAAqB,KAAK,gBAAgB,EAAC,MAAM,2BAA2B,CAAC;AACpF,OAAO,EAAwB,KAAK,cAAc,EAAC,MAAM,8BAA8B,CAAC;AAIxF,OAAO,EAAqB,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAC5E,OAAO,KAAK,EAAC,cAAc,EAAE,eAAe,EAAC,MAAM,uBAAuB,CAAC;AAC3E,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,oCAAoC,CAAC;AAGzE;;;GAGG;AACH,eAAO,MAAM,qBAAqB,EAAE,cAAc,CAAC,MAAM,CAAwC,CAAC;AAElG;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,gBAAgB,CAAC;AAEpD;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,EAAE,gBAExB,CAAC;AAEH,iEAAiE;AACjE,eAAO,MAAM,cAAc,aAAa,CAAC;AAEzC;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,4BAA4B,CAAC;AAExD,+CAA+C;AAC/C,MAAM,WAAW,wBAAwB;IACxC;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,kBAAkB,GAAG,IAAI,CAAC;CACzD;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,mBAAmB,GAC/B,KAAK,gBAAgB,EACrB,UAAU,wBAAwB,KAChC,KAAK,CAAC,eAAe,CAQvB,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,wBAAwB,GAAI,KAAK,gBAAgB,KAAG,KAAK,CAAC,SAAS,CAkB/E,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,yBAAyB,EAAE,GAAwD,CAAC;AAEjG;;;;;;;;;GASG;AACH,eAAO,MAAM,6BAA6B,GAAI,MAAM,MAAM,KAAG,SAC6B,CAAC;AAE3F;;;;;;;;GAQG;AACH,eAAO,MAAM,yBAAyB,QAAO,cAM1C,CAAC"}
1
+ {"version":3,"file":"default_spine_surface.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_spine_surface.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAIpD,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,8CAA8C,CAAC;AACrF,OAAO,EAAqB,KAAK,gBAAgB,EAAC,MAAM,2BAA2B,CAAC;AACpF,OAAO,EAAwB,KAAK,cAAc,EAAC,MAAM,8BAA8B,CAAC;AAIxF,OAAO,EAAqB,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAC5E,OAAO,KAAK,EAAC,cAAc,EAAE,eAAe,EAAC,MAAM,uBAAuB,CAAC;AAC3E,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,oCAAoC,CAAC;AAGzE;;;GAGG;AACH,eAAO,MAAM,qBAAqB,EAAE,cAAc,CAAC,MAAM,CAAwC,CAAC;AAElG;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,gBAAgB,CAAC;AAEpD;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,EAAE,gBAExB,CAAC;AAEH,iEAAiE;AACjE,eAAO,MAAM,cAAc,aAAa,CAAC;AAEzC;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,4BAA4B,CAAC;AAExD,+CAA+C;AAC/C,MAAM,WAAW,wBAAwB;IACxC;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,kBAAkB,GAAG,IAAI,CAAC;CACzD;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,mBAAmB,GAC/B,KAAK,gBAAgB,EACrB,UAAU,wBAAwB,KAChC,KAAK,CAAC,eAAe,CAQvB,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,wBAAwB,GAAI,KAAK,gBAAgB,KAAG,KAAK,CAAC,SAAS,CAwB/E,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,yBAAyB,EAAE,GAAwD,CAAC;AAEjG;;;;;;;;;GASG;AACH,eAAO,MAAM,6BAA6B,GAAI,MAAM,MAAM,KAAG,SAC6B,CAAC;AAE3F;;;;;;;;GAQG;AACH,eAAO,MAAM,yBAAyB,QAAO,cAM1C,CAAC"}
@@ -86,6 +86,12 @@ export const create_spine_route_specs = (ctx) => [
86
86
  session_options: spine_session_options,
87
87
  ip_rate_limiter: null,
88
88
  signup_account_rate_limiter: null,
89
+ // Disable the denial floor so the identity-parity suite's accepted
90
+ // cases (valid username/email, no invite → 403 no-matching-invite)
91
+ // don't each pay the 250ms timing floor. Mirrors `login_fail_floor_ms: 0`
92
+ // above — the floor is a production timing-oracle defense, not
93
+ // behavior any cross test asserts.
94
+ signup_fail_floor_ms: 0,
89
95
  }),
90
96
  ]),
91
97
  ...(ctx.audit_sse
@@ -0,0 +1,13 @@
1
+ import '../assert_dev_env.js';
2
+ import type { SetupTest } from './setup.js';
3
+ /** Options for the identity-primitive parity suite. */
4
+ export interface IdentityParityCrossTestOptions {
5
+ /** Per-test fixture producer (in-process or cross-process). */
6
+ readonly setup_test: SetupTest;
7
+ /** REST login route path. Default `/api/account/login` (the spine convention). */
8
+ readonly login_path?: string;
9
+ /** REST signup route path. Default `/api/account/signup` (the spine convention). */
10
+ readonly signup_path?: string;
11
+ }
12
+ export declare const describe_identity_parity_cross_tests: (options: IdentityParityCrossTestOptions) => void;
13
+ //# sourceMappingURL=identity_parity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"identity_parity.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/identity_parity.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAqG9B,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,YAAY,CAAC;AAkB1C,uDAAuD;AACvD,MAAM,WAAW,8BAA8B;IAC9C,+DAA+D;IAC/D,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,kFAAkF;IAClF,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,oFAAoF;IACpF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AA0JD,eAAO,MAAM,oCAAoC,GAChD,SAAS,8BAA8B,KACrC,IA2TF,CAAC"}
@@ -0,0 +1,465 @@
1
+ import '../assert_dev_env.js';
2
+ /**
3
+ * Cross-backend identity-primitive parity for fuz_app's own spine over real
4
+ * HTTP — the `primitive_schemas` twins (`Username`, `UsernameProvided`,
5
+ * `Email`) and the login/signup input handling that enforces them, pinned
6
+ * facet-by-facet so a TS↔Rust divergence in any one surfaces as a failure:
7
+ * how usernames are **canonicalized** on the login lookup, the **ASCII-only
8
+ * creation invariant** that bounds what can ever be stored, and the **email
9
+ * format** rule applied at signup.
10
+ *
11
+ * `UsernameProvided` (login/lookup) canonicalizes the submitted username via
12
+ * `.trim().toLowerCase()`, and the DB matches case-insensitively
13
+ * (`LOWER(username) = LOWER($1)`); `Username` (creation) lowercases at store
14
+ * **and** restricts to ASCII via `^[a-zA-Z][0-9a-zA-Z_-]*[0-9a-zA-Z]$`. The
15
+ * Rust spine must produce the same canonical form, the same case-insensitive
16
+ * lookup, **and** the same ASCII-only rejection — or a username that logs in on
17
+ * TS silently 401s on Rust, or a non-ASCII username storable on one backend
18
+ * reopens the homograph-collision surface. The spec-derived round-trip +
19
+ * conformance suites never vary username casing or charset, so this corner was
20
+ * unpinned.
21
+ *
22
+ * **Canonicalization (login lookup):**
23
+ *
24
+ * - **case-insensitive login** — an account created `Mixed_Case` logs in via an
25
+ * all-uppercase submission (proves the *lookup* folds case, not merely that
26
+ * the stored form was lowercased).
27
+ * - **whitespace-trim login** — the same shape logs in with surrounding
28
+ * whitespace (proves `.trim()` on the lookup path).
29
+ * - **no Unicode case-fold collision (negative)** — a Turkish-`İ` (U+0130)
30
+ * variant of an existing ASCII username must NOT match. Both JS
31
+ * `.toLowerCase()` and Rust `str::to_lowercase()` map `İ` → `i` + U+0307
32
+ * (combining dot above), never plain `i`, so the cased homograph stays a
33
+ * distinct, non-existent username → 401 on every backend.
34
+ *
35
+ * **Username-or-email login lookup:** the login identifier resolves against
36
+ * **username or email** — TS `query_account_by_username_or_email`, Rust the
37
+ * converged `query_account_with_password_hash` OR-lookup (this was the
38
+ * divergence the suite closed: the Rust spine previously matched username
39
+ * only). An account created with an email logs in via that email,
40
+ * case-insensitively (`Email` stores the original case; folding rides the
41
+ * `LOWER(email) = LOWER($1)` lookup), and username login keeps working when an
42
+ * email is present. A non-existent email → 401.
43
+ *
44
+ * **Login input validation:** malformed login input → 400 `invalid_request_body`
45
+ * on every spine — whitespace-only username (empty after trim), over-long
46
+ * username (> 255), empty password, and an unknown body key (strict object).
47
+ * TS runs the full `LoginInput` Zod schema; the Rust spine enforces the same
48
+ * shape in `account_login` (the convergence the suite closed: the Rust spine
49
+ * previously let three of these fall through to a lookup-miss 401, and TS let a
50
+ * whitespace-only identifier through to a 401). Asserted via the error reason,
51
+ * not just the status, so a same-status-wrong-body backend still fails.
52
+ *
53
+ * **ASCII-only creation invariant:** a non-ASCII username is rejected at signup
54
+ * input validation → 400 on every backend, so no Unicode username is ever
55
+ * stored — the precondition the login no-collision case relies on. (ASCII-only
56
+ * is the intended invariant for both spines, not an accident of the TS regex.)
57
+ *
58
+ * **Length + format creation parity:** the full `Username` shape —
59
+ * `[USERNAME_LENGTH_MIN, USERNAME_LENGTH_MAX]` = `[3, 39]` and the regex
60
+ * `^[a-zA-Z][0-9a-zA-Z_-]*[0-9a-zA-Z]$` — pinned across both backends. The TS
61
+ * Zod `.min()/.max()/.regex()` and the Rust hand-rolled byte-scan
62
+ * (`is_valid_username_for_creation`) are independent reimplementations of the
63
+ * same rule, so each length boundary is tested just-outside (→ 400) and
64
+ * just-inside (→ 403 no-matching-invite, the settle for a valid username on a
65
+ * spine with `open_signup` at its `false` default), and each format violation
66
+ * (leading non-letter, trailing punctuation, embedded disallowed char) → 400,
67
+ * with mid-string `_`/`-` accepted siblings as the don't-over-reject control.
68
+ * Signup is also strict-object on both spines (TS `z.strictObject`, Rust
69
+ * `#[serde(deny_unknown_fields)]`), so an unknown signup body key → 400.
70
+ *
71
+ * **Email format (creation):** the optional signup `email` is validated to a
72
+ * loose `local@domain.tld` shape on both spines — TS `Email`
73
+ * (`^[^\s@]+@[^\s@]+\.[^\s@]+$` plus a 254-byte (RFC 5321 octet) length bound; whitespace `White_Space ∪ {U+FEFF}`), Rust the
74
+ * hand-rolled `is_valid_email`. A malformed email → 400, a well-formed one →
75
+ * 403 no-matching-invite (the same accept/reject settle the username tables
76
+ * use). The accepted siblings include the single-char-TLD `a@b.c` that Zod's
77
+ * `z.email()` would reject — the deliberately-looser rule both spines now
78
+ * share. This closed a real divergence: the Rust spine previously
79
+ * length-checked the email only, accepting any non-empty string TS rejected.
80
+ *
81
+ * Both surfaces are flat REST on every spine, so this is an imperative suite
82
+ * (not a `conformance_table` row) and ungated. Runs both legs via the shared
83
+ * `{setup_test}` protocol: the in-process leg
84
+ * (`cross_backend/identity_parity.db.test.ts`, plain `gro test`) and the
85
+ * cross-process leg (`cross_backend/identity_parity.cross.test.ts`, the TS spine
86
+ * binaries + Rust `testing_spine_stub` over real HTTP).
87
+ *
88
+ * `$lib`-free by contract (relative specifiers only).
89
+ *
90
+ * @module
91
+ */
92
+ import { describe, test, assert } from 'vitest';
93
+ import { ERROR_INVALID_CREDENTIALS, ERROR_INVALID_REQUEST_BODY, ERROR_NO_MATCHING_INVITE, } from '../../http/error_schemas.js';
94
+ import { DEFAULT_TEST_PASSWORD } from '../test_credentials.js';
95
+ /**
96
+ * The flat-REST `{error}` reason each terminal login/signup status carries on
97
+ * every spine — the assertion target that turns a status-only check into a
98
+ * body-shape check. `200` carries no `error` field (`{ok: true}`), so its
99
+ * expected reason is `undefined`. Asserting the reason (not just the status)
100
+ * catches a backend that returns the right status with a wrong, renamed, or
101
+ * info-leaking body — drift the status comparison is blind to. Wire constants,
102
+ * not literals, so a rename in `error_schemas.ts` fails the typecheck here.
103
+ */
104
+ const REASON_BY_STATUS = {
105
+ 200: undefined,
106
+ 400: ERROR_INVALID_REQUEST_BODY,
107
+ 401: ERROR_INVALID_CREDENTIALS,
108
+ 403: ERROR_NO_MATCHING_INVITE,
109
+ };
110
+ /**
111
+ * U+0130 LATIN CAPITAL LETTER I WITH DOT ABOVE. Its Unicode default lowercase
112
+ * is `i` + U+0307 (combining dot above), never plain `i` — the property the
113
+ * negative case relies on for a stable cross-impl result.
114
+ */
115
+ const TURKISH_DOTTED_I = 'İ';
116
+ /**
117
+ * Representative non-ASCII usernames spanning distinct scripts — accented Latin
118
+ * (mid-string), Greek (leading), CJK (leading). Each fails the `Username` regex
119
+ * at a different position, so a Rust regex that admitted any one script (but not
120
+ * another) is caught. All → 400 at signup input validation.
121
+ */
122
+ const USERNAME_NON_ASCII_CASES = [
123
+ { username: 'café_user', label: 'accented Latin mid-string', status: 400 },
124
+ { username: 'Ωmega_user', label: 'leading Greek', status: 400 },
125
+ { username: '日本_user', label: 'leading CJK', status: 400 },
126
+ ];
127
+ /**
128
+ * Status a signup with a **valid** username (but no matching invite, on a
129
+ * spine whose `open_signup` stays at the production default `false`) settles
130
+ * to: the username cleared input validation and reached the invite gate, which
131
+ * finds no match → 403 `no_matching_invite`. The accepted boundary/format
132
+ * cases below assert this (not 200): the point is that the username was *not*
133
+ * rejected at validation, distinguishing it from the 400 cases.
134
+ */
135
+ const VALID_USERNAME_NO_INVITE_STATUS = 403;
136
+ /**
137
+ * Username length boundary pairs for the creation schema, pinning both edges of
138
+ * `[USERNAME_LENGTH_MIN, USERNAME_LENGTH_MAX]` = `[3, 39]`. Each pair is a
139
+ * just-outside length (rejected at input validation → 400) and its just-inside
140
+ * sibling (cleared validation → 403). Pinning both sides on both backends
141
+ * catches an off-by-one in either impl's min/max check — the TS `.min()/.max()`
142
+ * vs the Rust `s.len() < MIN || s.len() > MAX` byte-length scan. Every value is
143
+ * otherwise valid (leading letter, alphanumeric tail, alphanumeric body) so the
144
+ * only variable is length.
145
+ */
146
+ const USERNAME_LENGTH_CASES = [
147
+ { username: 'ab', label: '2 chars — below min', status: 400 },
148
+ { username: 'abc', label: '3 chars — at min', status: VALID_USERNAME_NO_INVITE_STATUS },
149
+ {
150
+ username: `a${'b'.repeat(38)}`,
151
+ label: '39 chars — at max',
152
+ status: VALID_USERNAME_NO_INVITE_STATUS,
153
+ },
154
+ { username: `a${'b'.repeat(39)}`, label: '40 chars — above max', status: 400 },
155
+ ];
156
+ /**
157
+ * Username format edge cases for the creation regex
158
+ * `^[a-zA-Z][0-9a-zA-Z_-]*[0-9a-zA-Z]$`. The rejected cases each violate the
159
+ * shape at a different position — leading non-letter, trailing punctuation,
160
+ * embedded disallowed char — so a Rust byte-scan that policed only one position
161
+ * is caught. The two accepted siblings (`_` / `-` *mid-string*) are the
162
+ * non-divergence control: the same characters that are illegal at the end are
163
+ * legal in the body, so they must clear validation (→ 403) on every backend —
164
+ * pinning that neither impl over-rejects them.
165
+ */
166
+ const USERNAME_FORMAT_CASES = [
167
+ { username: '1user', label: 'leading digit', status: 400 },
168
+ { username: '_user', label: 'leading underscore', status: 400 },
169
+ { username: '-user', label: 'leading dash', status: 400 },
170
+ { username: 'user_', label: 'trailing underscore', status: 400 },
171
+ { username: 'user-', label: 'trailing dash', status: 400 },
172
+ { username: 'us er', label: 'embedded space', status: 400 },
173
+ { username: 'user@x', label: 'embedded at-sign', status: 400 },
174
+ { username: 'user.x', label: 'embedded dot', status: 400 },
175
+ {
176
+ username: 'us_er',
177
+ label: 'underscore mid-string (valid)',
178
+ status: VALID_USERNAME_NO_INVITE_STATUS,
179
+ },
180
+ { username: 'us-er', label: 'dash mid-string (valid)', status: VALID_USERNAME_NO_INVITE_STATUS },
181
+ ];
182
+ /**
183
+ * Signup email-format cases for the loose `Email` shape `local@domain.tld`
184
+ * (TS `^[^\s@]+@[^\s@]+\.[^\s@]+$` plus the 254 length bound; Rust the
185
+ * hand-rolled `is_valid_email`). The rejected cases each violate the shape at
186
+ * a different point — no `@`, no dot in the domain, empty local/domain, a dot
187
+ * at a domain edge, surrounding whitespace, over the length bound — so a
188
+ * backend policing only one of them is caught. The accepted siblings are the
189
+ * don't-over-reject control and include the shapes Zod's `z.email()` rejects
190
+ * but our looser rule accepts — single-char TLD `a@b.c` and consecutive dots
191
+ * `a..b@c.d` — pinning the deliberately-looser shared rule in both directions.
192
+ * Every case rides a valid username so the rejection (or the 403 no-invite
193
+ * settle) isolates the email.
194
+ */
195
+ const SIGNUP_EMAIL_CASES = [
196
+ { email: 'not-an-email', label: 'no @ or dot', status: 400 },
197
+ { email: 'user@host', label: 'no dot in domain', status: 400 },
198
+ { email: '@example.com', label: 'empty local part', status: 400 },
199
+ { email: 'user@', label: 'empty domain', status: 400 },
200
+ { email: 'user@.com', label: 'domain starts with a dot', status: 400 },
201
+ { email: 'user@com.', label: 'domain ends with a dot', status: 400 },
202
+ { email: 'a@@b.c', label: 'two @', status: 400 },
203
+ { email: ' user@example.com', label: 'leading whitespace', status: 400 },
204
+ { email: 'user@example.com ', label: 'trailing whitespace', status: 400 },
205
+ { email: 'us er@example.com', label: 'internal whitespace', status: 400 },
206
+ { email: 'a\u0085b@c.d', label: 'NEL U+0085 (Unicode ws JS \\s omits)', status: 400 },
207
+ { email: 'a\ufeffb@c.d', label: 'BOM/ZWNBSP U+FEFF', status: 400 },
208
+ // 255 bytes total (243-char local + "@example.com") — one over the 254-byte bound.
209
+ { email: `${'a'.repeat(243)}@example.com`, label: 'over the 254-byte bound (ASCII)', status: 400 },
210
+ // Multibyte: the bound is UTF-8 BYTES, not code units. `\u00fc` (ü) is 2
211
+ // bytes / 1 code unit, so 121×ü + "@example.com" is 254 bytes (accepted) and
212
+ // 122×ü is 256 bytes (rejected) — both far under 254 code units, so a
213
+ // code-unit `.max(254)` would wrongly accept the over case. Pins the byte
214
+ // bound across both spines (the Rust `is_valid_email` `s.len()`).
215
+ {
216
+ email: `${'\u00fc'.repeat(121)}@example.com`,
217
+ label: 'multibyte at the 254-byte bound',
218
+ status: VALID_USERNAME_NO_INVITE_STATUS,
219
+ },
220
+ {
221
+ email: `${'\u00fc'.repeat(122)}@example.com`,
222
+ label: 'multibyte over the 254-byte bound',
223
+ status: 400,
224
+ },
225
+ { email: 'user@example.com', label: 'well-formed', status: VALID_USERNAME_NO_INVITE_STATUS },
226
+ {
227
+ email: 'a@b.c',
228
+ label: 'single-char TLD (looser than z.email)',
229
+ status: VALID_USERNAME_NO_INVITE_STATUS,
230
+ },
231
+ {
232
+ email: 'a..b@c.d',
233
+ label: 'consecutive dots (looser than z.email)',
234
+ status: VALID_USERNAME_NO_INVITE_STATUS,
235
+ },
236
+ {
237
+ email: 'user+tag@example.co.uk',
238
+ label: 'plus-tag + multi-label',
239
+ status: VALID_USERNAME_NO_INVITE_STATUS,
240
+ },
241
+ ];
242
+ export const describe_identity_parity_cross_tests = (options) => {
243
+ const { setup_test } = options;
244
+ const login_path = options.login_path ?? '/api/account/login';
245
+ const signup_path = options.signup_path ?? '/api/account/signup';
246
+ /**
247
+ * POST JSON on a fresh (cookie-jar-free) transport; return the status and the
248
+ * parsed flat-REST `{error}` reason (`undefined` for success / non-JSON
249
+ * bodies). Always reads the body so the cross-process socket releases.
250
+ */
251
+ const post_json = async (fixture, path, body) => {
252
+ const res = await fixture.fresh_transport()(path, {
253
+ method: 'POST',
254
+ headers: { 'content-type': 'application/json' },
255
+ body: JSON.stringify(body),
256
+ });
257
+ const text = await res.text().catch(() => '');
258
+ let error;
259
+ try {
260
+ const parsed = text ? JSON.parse(text) : undefined;
261
+ if (parsed &&
262
+ typeof parsed === 'object' &&
263
+ typeof parsed.error === 'string') {
264
+ error = parsed.error;
265
+ }
266
+ }
267
+ catch {
268
+ // non-JSON body — leave `error` undefined.
269
+ }
270
+ return { status: res.status, error };
271
+ };
272
+ /** POST a login with the given identifier (username or email). */
273
+ const login = (fixture, username, password = DEFAULT_TEST_PASSWORD) => post_json(fixture, login_path, { username, password });
274
+ /** POST a signup with a valid password and the given username. */
275
+ const signup = (fixture, username, password = DEFAULT_TEST_PASSWORD) => post_json(fixture, signup_path, { username, password });
276
+ /**
277
+ * Assert an attempt settled to `status` **and** carried the flat-REST error
278
+ * reason that status implies on every spine (see {@link REASON_BY_STATUS}) —
279
+ * a body-shape check, not just a status check, so a backend can't pass by
280
+ * returning the right status with the wrong/leaky body.
281
+ */
282
+ const assert_attempt = (actual, status, label) => {
283
+ assert.strictEqual(actual.status, status, `${label} — expected status ${status}, got ${actual.status}`);
284
+ const expected_reason = REASON_BY_STATUS[status];
285
+ assert.strictEqual(actual.error, expected_reason, `${label} — status ${status} must carry error reason ${expected_reason ?? '(none)'}, got ${actual.error ?? '(none)'}`);
286
+ };
287
+ /** Run a table of signup username cases, asserting each settles to its expected `{status, reason}`. */
288
+ const describe_signup_username_cases = (suite_name, cases) => {
289
+ describe(suite_name, () => {
290
+ for (const { username, label, status } of cases) {
291
+ test(`${label} — "${username}" → ${status}`, async () => {
292
+ const fixture = await setup_test();
293
+ assert_attempt(await signup(fixture, username), status, `username "${username}" (${label})`);
294
+ });
295
+ }
296
+ });
297
+ };
298
+ describe('username canonicalization parity', () => {
299
+ test('case-insensitive login — uppercase submission matches a mixed-case account', async () => {
300
+ const fixture = await setup_test();
301
+ // Mixed-case ASCII username (valid per the `Username` regex), stored
302
+ // lowercase; an all-uppercase submission matches only if the lookup
303
+ // folds case on this backend.
304
+ await fixture.create_account({ username: 'Canon_Mixed_User' });
305
+ assert_attempt(await login(fixture, 'CANON_MIXED_USER'), 200, 'uppercase login');
306
+ });
307
+ test('whitespace-trim login — surrounding whitespace is trimmed on lookup', async () => {
308
+ const fixture = await setup_test();
309
+ await fixture.create_account({ username: 'Canon_Trim_User' });
310
+ assert_attempt(await login(fixture, ' canon_trim_user '), 200, 'whitespace-trim login');
311
+ });
312
+ test('no Unicode case-fold collision — Turkish İ variant of an ASCII username → 401', async () => {
313
+ const fixture = await setup_test();
314
+ // `canon_admin_user` carries an ASCII `i`; the probe replaces it with İ
315
+ // (U+0130). A backend that folded İ → plain `i` would match the stored
316
+ // username and authenticate (the bug); both impls fold İ → `i` + U+0307,
317
+ // so the canonical form differs and the lookup misses → 401
318
+ // `invalid_credentials` (asserted, not just the status — a backend that
319
+ // 401'd with a different body would still be wrong).
320
+ await fixture.create_account({ username: 'canon_admin_user' });
321
+ const homograph = `canon_adm${TURKISH_DOTTED_I}n_user`;
322
+ assert_attempt(await login(fixture, homograph), 401, 'Turkish-İ homograph');
323
+ });
324
+ });
325
+ describe('username-or-email login parity', () => {
326
+ // The login identifier matches against **username or email** (TS
327
+ // `query_account_by_username_or_email`, Rust the converged
328
+ // `query_account_with_password_hash` OR-lookup). An account that carries
329
+ // an email logs in via that email, case-insensitively, on every backend —
330
+ // the property that was unpinned (and divergent: the Rust spine matched
331
+ // username only) until this suite. Email is stored with its original case
332
+ // (`Email` does not lowercase), so case folding rides entirely on the
333
+ // `LOWER(email) = LOWER($1)` lookup, exercised by the mixed-case cases.
334
+ test('login by exact email — an account created with an email logs in via that email', async () => {
335
+ const fixture = await setup_test();
336
+ await fixture.create_account({
337
+ username: 'email_exact_user',
338
+ email: 'email-exact@example.com',
339
+ });
340
+ assert_attempt(await login(fixture, 'email-exact@example.com'), 200, 'exact-email login');
341
+ });
342
+ test('email login is case-insensitive — mixed-case stored email, lowercase submission', async () => {
343
+ const fixture = await setup_test();
344
+ await fixture.create_account({
345
+ username: 'email_mixed_user',
346
+ email: 'Email.Mixed@Example.COM',
347
+ });
348
+ assert_attempt(await login(fixture, 'email.mixed@example.com'), 200, 'mixed-case-email login');
349
+ });
350
+ test('email login is case-insensitive — lowercase stored email, uppercase submission', async () => {
351
+ const fixture = await setup_test();
352
+ await fixture.create_account({
353
+ username: 'email_upper_user',
354
+ email: 'email-upper@example.com',
355
+ });
356
+ assert_attempt(await login(fixture, 'EMAIL-UPPER@EXAMPLE.COM'), 200, 'uppercase-email login');
357
+ });
358
+ test('username login still works when the account also has an email', async () => {
359
+ const fixture = await setup_test();
360
+ await fixture.create_account({
361
+ username: 'email_dual_user',
362
+ email: 'email-dual@example.com',
363
+ });
364
+ assert_attempt(await login(fixture, 'email_dual_user'), 200, 'username login with email present');
365
+ });
366
+ test('login by a non-existent email → 401', async () => {
367
+ const fixture = await setup_test();
368
+ await fixture.create_account({
369
+ username: 'email_present_user',
370
+ email: 'email-present@example.com',
371
+ });
372
+ assert_attempt(await login(fixture, 'email-absent@example.com'), 401, 'non-existent-email login');
373
+ });
374
+ });
375
+ describe('login input validation parity', () => {
376
+ // Malformed login input → 400 `invalid_request_body` on every spine. TS
377
+ // runs the full `LoginInput` Zod schema (`UsernameProvided` non-empty /
378
+ // max 255 / non-empty-after-trim, `PasswordProvided` non-empty / max 300,
379
+ // `z.strictObject`); the Rust spine enforces the same shape in
380
+ // `account_login` (an extra key fails `deny_unknown_fields` → 400, not
381
+ // axum's 422). These each previously fell through to a lookup-miss 401 (or
382
+ // a bespoke-string 400) on one backend — a status *and* body divergence the
383
+ // strengthened `assert_attempt` (reason-checked) now pins.
384
+ test('whitespace-only username → 400 (empty after trim, not a lookup miss)', async () => {
385
+ const fixture = await setup_test();
386
+ assert_attempt(await login(fixture, ' '), 400, 'whitespace-only username');
387
+ });
388
+ test('over-long username (>255) → 400', async () => {
389
+ const fixture = await setup_test();
390
+ assert_attempt(await login(fixture, 'a'.repeat(256)), 400, 'over-long username');
391
+ });
392
+ test('empty password → 400 (rejected as malformed, not a wrong credential)', async () => {
393
+ const fixture = await setup_test();
394
+ assert_attempt(await login(fixture, 'some_user', ''), 400, 'empty password');
395
+ });
396
+ test('over-long password (>300) → 400', async () => {
397
+ const fixture = await setup_test();
398
+ assert_attempt(await login(fixture, 'some_user', 'a'.repeat(301)), 400, 'over-long password');
399
+ });
400
+ test('unknown body key → 400 (strict object on both spines)', async () => {
401
+ const fixture = await setup_test();
402
+ assert_attempt(await post_json(fixture, login_path, {
403
+ username: 'some_user',
404
+ password: DEFAULT_TEST_PASSWORD,
405
+ unexpected_field: 'x',
406
+ }), 400, 'unknown body key');
407
+ });
408
+ });
409
+ // The `Username` creation schema is ASCII-only by regex; both spines reject a
410
+ // non-ASCII username at signup input validation → 400, so no Unicode username
411
+ // is ever stored (the precondition the no-collision login case relies on).
412
+ describe_signup_username_cases('username creation ASCII-only invariant parity', USERNAME_NON_ASCII_CASES);
413
+ // Both edges of `[USERNAME_LENGTH_MIN, USERNAME_LENGTH_MAX]` = [3, 39], each
414
+ // side just-outside (→ 400) and just-inside (→ 403). Catches an off-by-one in
415
+ // either backend's length check.
416
+ describe_signup_username_cases('username creation length boundary parity', USERNAME_LENGTH_CASES);
417
+ // The creation regex `^[a-zA-Z][0-9a-zA-Z_-]*[0-9a-zA-Z]$` — rejected shapes
418
+ // (→ 400) and the mid-string `_`/`-` controls that must be accepted (→ 403),
419
+ // proving neither backend over- or under-rejects.
420
+ describe_signup_username_cases('username creation format parity', USERNAME_FORMAT_CASES);
421
+ // The optional signup `email` is validated to the loose `local@domain.tld`
422
+ // shape on both spines (TS `Email` regex + 254-byte bound, Rust `is_valid_email`):
423
+ // malformed → 400, well-formed → 403 no-invite. The username is fixed-valid so
424
+ // each case isolates the email; the `a@b.c` accept pins the deliberately
425
+ // looser-than-`z.email()` rule the validator port converged on.
426
+ describe('signup email format parity', () => {
427
+ for (const { email, label, status } of SIGNUP_EMAIL_CASES) {
428
+ test(`${label} → ${status}`, async () => {
429
+ const fixture = await setup_test();
430
+ assert_attempt(await post_json(fixture, signup_path, {
431
+ username: 'email_fmt_user',
432
+ password: DEFAULT_TEST_PASSWORD,
433
+ email,
434
+ }), status, `email "${email}" (${label})`);
435
+ });
436
+ }
437
+ // `null` email (not just absent) — TS `Email.nullish()` + the Rust
438
+ // `Option<String>` (serde maps JSON null → None) both treat it as
439
+ // "no email", so signup reaches the invite gate → 403, not a 400.
440
+ // Pins the null-vs-absent convergence the `.nullish()` loosening made.
441
+ test('null email is treated as absent (403, not 400)', async () => {
442
+ const fixture = await setup_test();
443
+ assert_attempt(await post_json(fixture, signup_path, {
444
+ username: 'email_null_user',
445
+ password: DEFAULT_TEST_PASSWORD,
446
+ email: null,
447
+ }), VALID_USERNAME_NO_INVITE_STATUS, 'null email');
448
+ });
449
+ });
450
+ describe('signup input validation parity', () => {
451
+ // Signup is `z.strictObject` on TS and `#[serde(deny_unknown_fields)]` on
452
+ // the Rust spine — an unknown body key → 400 `invalid_request_body` on
453
+ // both (the Rust spine previously ignored it via serde). The username is
454
+ // valid so the rejection isolates the unknown key (a valid body would
455
+ // otherwise reach the invite gate → 403).
456
+ test('unknown body key → 400 (strict object on both spines)', async () => {
457
+ const fixture = await setup_test();
458
+ assert_attempt(await post_json(fixture, signup_path, {
459
+ username: 'strict_signup_user',
460
+ password: DEFAULT_TEST_PASSWORD,
461
+ unexpected_field: 'x',
462
+ }), 400, 'signup unknown body key');
463
+ });
464
+ });
465
+ };
@@ -15,6 +15,15 @@ export interface CreateTestAccountOptions {
15
15
  readonly username?: string;
16
16
  readonly password_value?: string;
17
17
  readonly roles?: Array<string>;
18
+ /**
19
+ * Optional email to store on the account, exercising the username-or-email
20
+ * login lookup. Cross-process this rides the production signup body
21
+ * (claimed against the username-scoped invite, which matches regardless of
22
+ * the email); in-process it lands directly on the inserted row. Must be a
23
+ * valid `Email` (the cross-process signup validates it) and unique per test
24
+ * (the `LOWER(email)` partial-unique index rejects collisions).
25
+ */
26
+ readonly email?: string;
18
27
  }
19
28
  /**
20
29
  * Shape returned by `TestFixture.create_account`. Aliased to the
@@ -1 +1 @@
1
- {"version":3,"file":"setup.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/setup.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"setup.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/setup.ts"],"names":[],"mappings":"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"}
@@ -279,10 +279,22 @@ const mint_account = async (handle, options) => {
279
279
  throw new Error(`invite_create(${handle.config.name}, username=${username}) returned unexpected result: ` +
280
280
  JSON.stringify(invite_result));
281
281
  }
282
+ // Thread the optional email onto the production signup body — the
283
+ // username-scoped invite above matches on username regardless of the
284
+ // email, so the account lands with the email set (exercising the
285
+ // username-or-email login lookup). Guard on `!== undefined` (not truthy)
286
+ // to match the other three email guards (`create_account` here +
287
+ // `app_server.ts`): "requested" is `undefined` vs present, so a
288
+ // caller-supplied value is forwarded verbatim and signup validates it,
289
+ // rather than the truthy form silently dropping an empty string.
282
290
  const signup_response = await transport('/api/account/signup', {
283
291
  method: 'POST',
284
292
  headers: { 'Content-Type': 'application/json' },
285
- body: JSON.stringify({ username, password }),
293
+ body: JSON.stringify({
294
+ username,
295
+ password,
296
+ ...(options.email !== undefined && { email: options.email }),
297
+ }),
286
298
  });
287
299
  if (!signup_response.ok) {
288
300
  const body = await signup_response.text().catch(() => '<unreadable>');
@@ -422,6 +434,7 @@ export const default_cross_process_setup = (handle, options) => {
422
434
  ...(account_options?.password_value !== undefined && {
423
435
  password_value: account_options.password_value,
424
436
  }),
437
+ ...(account_options?.email !== undefined && { email: account_options.email }),
425
438
  });
426
439
  if (account_options?.roles && account_options.roles.length > 0) {
427
440
  await grant_roles_via_offer_accept(refreshed_handle, other, account_options.roles);
@@ -1 +1 @@
1
- {"version":3,"file":"schema_generators.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/schema_generators.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAE7B;;;;;;;;GAQG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AACtB,OAAO,EAKN,KAAK,YAAY,EACjB,MAAM,yBAAyB,CAAC;AAIjC;;;GAGG;AACH,eAAO,MAAM,aAAa,GAAI,cAAc,CAAC,CAAC,OAAO,KAAG,MAAM,GAAG,IAkBhE,CAAC;AA+FF,qEAAqE;AACrE,eAAO,MAAM,oBAAoB,GAAI,OAAO,YAAY,EAAE,cAAc,CAAC,CAAC,OAAO,KAAG,OAgGnF,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,kBAAkB,GAAI,MAAM,MAAM,EAAE,gBAAgB,CAAC,CAAC,SAAS,KAAG,MAa9E,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAC/B,cAAc,CAAC,CAAC,OAAO,KACrB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SA6B5B,CAAC"}
1
+ {"version":3,"file":"schema_generators.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/schema_generators.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAE7B;;;;;;;;GAQG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AACtB,OAAO,EAKN,KAAK,YAAY,EACjB,MAAM,yBAAyB,CAAC;AAIjC;;;GAGG;AACH,eAAO,MAAM,aAAa,GAAI,cAAc,CAAC,CAAC,OAAO,KAAG,MAAM,GAAG,IAkBhE,CAAC;AAsGF,qEAAqE;AACrE,eAAO,MAAM,oBAAoB,GAAI,OAAO,YAAY,EAAE,cAAc,CAAC,CAAC,OAAO,KAAG,OAgGnF,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,kBAAkB,GAAI,MAAM,MAAM,EAAE,gBAAgB,CAAC,CAAC,SAAS,KAAG,MAa9E,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAC/B,cAAc,CAAC,CAAC,OAAO,KACrB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SA6B5B,CAAC"}
@@ -127,6 +127,13 @@ const generate_valid_string = (field_schema) => {
127
127
  if (slug !== null && field_schema.safeParse(slug).success)
128
128
  return slug;
129
129
  }
130
+ // Email-shaped refinement (the `Email` primitive: loose `local@domain.tld`).
131
+ // The base `x`-fill has no `@`, so an email-pattern field needs a shaped
132
+ // candidate — the `z.string().regex()` form exposes a `pattern`, not the
133
+ // `format: 'email'` the `case 'email'` switch arm keys on.
134
+ const as_email = 'test@example.com';
135
+ if (field_schema.safeParse(as_email).success)
136
+ return as_email;
130
137
  // Absolute path refinement (e.g. DiskfilePath)
131
138
  const with_slash = '/' + base;
132
139
  if (field_schema.safeParse(with_slash).success)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzdev/fuz_app",
3
- "version": "0.89.0",
3
+ "version": "0.90.0",
4
4
  "description": "fullstack app library",
5
5
  "glyph": "🗝",
6
6
  "logo": "logo.svg",