1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
//! Spec §5: auth.users → auth.model jwt + roles; identities → oauth dep;
//! users → seed rows preserving bcrypt hashes (verified via jerrycan-auth's
//! bcrypt dispatch). Passwords/keys are NEVER copied into config.
use crate::platform::design::{
Auth, AuthModel, Endpoint, Entity, Field, FieldType, HttpMethod, ModuleDesign, ProbePolicy,
RequestBody, Success,
};
pub struct AuthOutput {
pub auth: Auth,
pub dependencies: Vec<String>, // "auth" [+ "oauth"]
pub users_module: ModuleDesign,
}
pub fn build_auth(member_roles: &[String], providers: &[String]) -> AuthOutput {
let mut roles: Vec<String> = member_roles.to_vec();
roles.sort();
roles.dedup();
let mut dependencies = vec!["auth".to_string()];
let has_oauth_provider = providers
.iter()
.any(|p| !matches!(p.as_str(), "email" | "phone"));
if has_oauth_provider {
dependencies.push("oauth".to_string());
}
let field = |name: &str, ft: FieldType, required: bool, unique: bool, write_only: bool| Field {
name: name.into(),
field_type: ft,
required,
unique,
index: false,
values: None,
default: None,
min: None,
max: None,
min_len: None,
max_len: None,
write_only,
reserve_against: None,
};
let user = Entity {
name: "User".into(),
// Default table name (`users`) is exactly the target — no override.
table: None,
belongs_to: vec![],
public_read: false,
unique: vec![],
fields: vec![
field("id", FieldType::Uuid, true, false, false),
field("email", FieldType::String, true, true, false),
// #112: mark the hash write_only explicitly so the emitted design.json
// shows the intent (it is also auto-hidden by name); the migrated app's
// register/login responses no longer leak the bcrypt hash.
field("password_hash", FieldType::String, false, false, true),
],
};
let users_module = ModuleDesign {
name: "users".into(),
mount: None,
description: Some("Migrated from Supabase auth.users".into()),
entities: vec![user],
endpoints: vec![
Endpoint {
operation_id: "register".into(),
method: HttpMethod::POST,
path: "/register".into(),
auth_required: false,
required_roles: vec![],
public: true,
probe: ProbePolicy::Auto,
request_body: Some(RequestBody {
entity: Some("User".into()),
fields: vec![],
}),
success: Success {
status: 201,
entity: Some("User".into()),
list: false,
},
errors: vec![],
},
Endpoint {
operation_id: "login".into(),
method: HttpMethod::POST,
path: "/login".into(),
auth_required: false,
required_roles: vec![],
public: true,
// Login verifies a credential the generator can't synthesize, so
// skip its un-greenable 2xx probe (issue #11) — keeps the migrated
// design able to reach `jerrycan check` ok:true.
probe: ProbePolicy::Skip,
request_body: Some(RequestBody {
entity: Some("User".into()),
fields: vec![],
}),
// #106: no `entity` on the success — jwt login must return the
// freshly minted bearer token, NOT the User row. An entity-shaped
// success (`Json<User>`) pins the return type and leaves nowhere
// for the token; a bare 200 lets the handler return its own token
// response (the reference-slice login shape).
success: Success {
status: 200,
entity: None,
list: false,
},
errors: vec![],
},
],
subroutes: vec![],
dependencies: vec![],
};
AuthOutput {
auth: Auth {
model: AuthModel::Jwt,
roles,
},
dependencies,
users_module,
}
}
/// Providers found in auth.identities data (distinct `provider` column values,
/// sorted). Streamed by the seed reader; kept separate so live mode reuses it.
pub fn providers_from_identities(
rows: impl Iterator<Item = Vec<Option<String>>>,
provider_idx: usize,
) -> Vec<String> {
let mut set: std::collections::BTreeSet<String> = rows
.filter_map(|r| r.get(provider_idx).cloned().flatten())
.collect();
set.remove("");
set.into_iter().collect()
}
/// auth.users CSV → generated `users` table rows. Unmapped auth.users columns
/// are dropped (Supabase-internal). Order is stable for deterministic seeds.
pub fn user_seed_mapping() -> &'static [(&'static str, &'static str)] {
&[
("id", "id"),
("email", "email"),
("encrypted_password", "password_hash"),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::platform::design::AuthModel;
#[test]
fn auth_users_produce_the_jwt_auth_block_and_a_users_module() {
let out = build_auth(
&["owner".to_string(), "member".to_string()], // member_roles from tenancy
&["google".to_string()], // providers from auth.identities
);
assert_eq!(out.auth.model, AuthModel::Jwt, "Supabase auth is JWT");
assert_eq!(out.auth.roles, vec!["member", "owner"], "sorted, deduped");
assert!(out.dependencies.contains(&"auth".to_string()));
assert!(
out.dependencies.contains(&"oauth".to_string()),
"google identity → oauth dep"
);
let users = &out.users_module;
assert_eq!(users.name, "users");
let user = &users.entities[0];
assert_eq!(user.name, "User");
let email = user.fields.iter().find(|f| f.name == "email").unwrap();
assert!(email.unique);
let hash = user
.fields
.iter()
.find(|f| f.name == "password_hash")
.unwrap();
assert!(!hash.required, "oauth-only users have no password hash");
// register + login are public (JL0004 carve-out), matching the reference slice.
assert!(
users
.endpoints
.iter()
.any(|e| e.operation_id == "register" && e.public)
);
assert!(
users
.endpoints
.iter()
.any(|e| e.operation_id == "login" && e.public)
);
// #106: jwt login must NOT carry an entity-shaped success — a `Json<User>`
// return leaves nowhere for the bearer token. A bare 200 lets the handler
// return its own token response (the reference-slice login shape).
let login = users
.endpoints
.iter()
.find(|e| e.operation_id == "login")
.expect("login endpoint");
assert!(
login.success.entity.is_none(),
"jwt login success must have no entity so the handler can return the token, got {:?}",
login.success.entity
);
}
#[test]
fn no_identity_providers_means_no_oauth_dependency() {
let out = build_auth(&[], &[]);
assert!(!out.dependencies.contains(&"oauth".to_string()));
}
}