hoja-cli 0.21.4__py3-none-any.whl

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 (107) hide show
  1. VERSION +1 -0
  2. google_workspace/__init__.py +19 -0
  3. google_workspace/client.py +253 -0
  4. google_workspace/config.py +39 -0
  5. google_workspace/errors.py +19 -0
  6. google_workspace/mcp_server.py +100 -0
  7. google_workspace/oauth.py +131 -0
  8. google_workspace/principal.py +83 -0
  9. google_workspace/py.typed +0 -0
  10. google_workspace/tools.py +64 -0
  11. google_workspace/writes.py +44 -0
  12. hoja/__init__.py +29 -0
  13. hoja/__main__.py +6 -0
  14. hoja/auth.py +42 -0
  15. hoja/cli.py +161 -0
  16. hoja/cli_state.py +55 -0
  17. hoja/client.py +268 -0
  18. hoja/commands/__init__.py +4 -0
  19. hoja/commands/auth_cmd.py +271 -0
  20. hoja/commands/comment.py +149 -0
  21. hoja/commands/cycle.py +75 -0
  22. hoja/commands/init.py +108 -0
  23. hoja/commands/issue.py +398 -0
  24. hoja/commands/label.py +163 -0
  25. hoja/commands/memory.py +240 -0
  26. hoja/commands/project.py +56 -0
  27. hoja/commands/raw.py +50 -0
  28. hoja/commands/team.py +102 -0
  29. hoja/commands/update.py +162 -0
  30. hoja/config.py +362 -0
  31. hoja/errors.py +107 -0
  32. hoja/filters.py +150 -0
  33. hoja/graphql/__init__.py +5 -0
  34. hoja/graphql/fragments.py +138 -0
  35. hoja/graphql/mutations.py +160 -0
  36. hoja/graphql/queries.py +245 -0
  37. hoja/mcp/__init__.py +27 -0
  38. hoja/mcp/content.py +151 -0
  39. hoja/mcp/prompts.py +298 -0
  40. hoja/mcp/resources.py +213 -0
  41. hoja/mcp/server.py +189 -0
  42. hoja/memory_board.py +370 -0
  43. hoja/models/__init__.py +37 -0
  44. hoja/models/base.py +64 -0
  45. hoja/models/cycle.py +32 -0
  46. hoja/models/enums.py +62 -0
  47. hoja/models/issue.py +99 -0
  48. hoja/models/label.py +17 -0
  49. hoja/models/project.py +60 -0
  50. hoja/models/team.py +31 -0
  51. hoja/models/user.py +32 -0
  52. hoja/models/workflow.py +65 -0
  53. hoja/output.py +410 -0
  54. hoja/skill_content/commands.md +222 -0
  55. hoja/skill_content/examples.md +193 -0
  56. hoja/skill_content/filters.md +101 -0
  57. hoja/skill_content/output-formats.md +92 -0
  58. hoja/skill_content/overview.md +123 -0
  59. hoja/skill_content/workflows.md +174 -0
  60. hoja_cli-0.21.4.dist-info/METADATA +279 -0
  61. hoja_cli-0.21.4.dist-info/RECORD +107 -0
  62. hoja_cli-0.21.4.dist-info/WHEEL +4 -0
  63. hoja_cli-0.21.4.dist-info/entry_points.txt +4 -0
  64. hoja_cli-0.21.4.dist-info/licenses/LICENSE +21 -0
  65. hoja_server/README.md +135 -0
  66. hoja_server/__init__.py +11 -0
  67. hoja_server/abac.py +116 -0
  68. hoja_server/agent_chat.py +280 -0
  69. hoja_server/agent_conversations.py +91 -0
  70. hoja_server/agent_durable.py +332 -0
  71. hoja_server/agent_prompt.py +117 -0
  72. hoja_server/agent_threads.py +96 -0
  73. hoja_server/agent_uploads.py +60 -0
  74. hoja_server/app.py +2189 -0
  75. hoja_server/cadence.py +231 -0
  76. hoja_server/cli.py +191 -0
  77. hoja_server/cloudflare_realtime.py +110 -0
  78. hoja_server/comment_policy.py +70 -0
  79. hoja_server/connector_actions.py +119 -0
  80. hoja_server/connectors_api.py +375 -0
  81. hoja_server/db.py +1816 -0
  82. hoja_server/denials.py +18 -0
  83. hoja_server/deployment.py +28 -0
  84. hoja_server/email_adapter.py +94 -0
  85. hoja_server/google_login.py +217 -0
  86. hoja_server/google_oidc.py +342 -0
  87. hoja_server/identity_transfer.py +210 -0
  88. hoja_server/mcp_oauth.py +623 -0
  89. hoja_server/mcp_tools.py +579 -0
  90. hoja_server/meeting_policy.py +179 -0
  91. hoja_server/meetings.py +1332 -0
  92. hoja_server/meetings_api.py +909 -0
  93. hoja_server/model_providers.py +205 -0
  94. hoja_server/realtime.py +690 -0
  95. hoja_server/resolvers.py +1281 -0
  96. hoja_server/schema.graphql +477 -0
  97. hoja_server/schema_sdl.py +8 -0
  98. hoja_server/secrets_box.py +39 -0
  99. hoja_server/signup_config.py +292 -0
  100. hoja_server/signup_gate.py +393 -0
  101. hoja_server/signup_policy.py +97 -0
  102. hoja_server/signup_store.py +577 -0
  103. hoja_server/store.py +2179 -0
  104. hoja_server/sync.py +249 -0
  105. hoja_server/tenant_context.py +131 -0
  106. hoja_server/turnstile.py +81 -0
  107. hoja_server/writer.py +1262 -0
@@ -0,0 +1,97 @@
1
+ """Slug and name policy for a new workspace — a mirror of the provisioner's rules.
2
+
3
+ The provisioner (``Hoja-Cloud/scripts/hoja-tenant``) is the authority: it refuses
4
+ a slug that is not a DNS label, a reserved name, or an existing tenant. The gate
5
+ must refuse the same inputs *earlier*, with a better message, or a stranger gets
6
+ as far as the provisioning queue before learning "www" is taken.
7
+
8
+ ``RESERVED_SLUGS`` and ``SLUG_RE`` below are copied verbatim from
9
+ ``Hoja-Cloud/scripts/hoja-tenant``; ``tests/test_signup_gate.py`` asserts the
10
+ parity (and the copy is checked against the script when that repo is present).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+
17
+ #: Names that already mean something on the base domain (site, API, mail, edge).
18
+ #: A tenant may never take one — copied from Hoja-Cloud/scripts/hoja-tenant.
19
+ RESERVED_SLUGS = frozenset({
20
+ "www",
21
+ "app",
22
+ "api",
23
+ "admin",
24
+ "mail",
25
+ "smtp",
26
+ "imap",
27
+ "pop",
28
+ "webmail",
29
+ "ns1",
30
+ "ns2",
31
+ "autodiscover",
32
+ "autoconfig",
33
+ "status",
34
+ "docs",
35
+ "blog",
36
+ "support",
37
+ "help",
38
+ "billing",
39
+ "auth",
40
+ "login",
41
+ "sso",
42
+ "cdn",
43
+ "assets",
44
+ "static",
45
+ "traefik",
46
+ "dashboard",
47
+ })
48
+
49
+ #: Same expression as the provisioner: one DNS label, no leading/trailing hyphen.
50
+ SLUG_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
51
+
52
+ SLUG_MAX_LENGTH = 63
53
+
54
+
55
+ class SlugRefused(ValueError):
56
+ """A slug the provisioner would refuse. ``reason`` is a stable code."""
57
+
58
+ def __init__(self, reason: str, message: str):
59
+ super().__init__(message)
60
+ self.reason = reason
61
+
62
+
63
+ def validate_slug(slug: object) -> str:
64
+ """Return the normalised slug, or raise :class:`SlugRefused`.
65
+
66
+ Order matters only for the message a person sees; every check refuses.
67
+ """
68
+ if not isinstance(slug, str) or not slug.strip():
69
+ raise SlugRefused("slug_required", "Pick a short name for your workspace.")
70
+ candidate = slug.strip()
71
+ if candidate != candidate.lower():
72
+ raise SlugRefused("invalid_slug", "Use lowercase letters, digits and hyphens only.")
73
+ if len(candidate) > SLUG_MAX_LENGTH:
74
+ raise SlugRefused(
75
+ "invalid_slug",
76
+ f"{candidate!r} is {len(candidate)} characters; a DNS label is at most "
77
+ f"{SLUG_MAX_LENGTH}.",
78
+ )
79
+ if candidate in RESERVED_SLUGS:
80
+ raise SlugRefused(
81
+ "reserved_slug",
82
+ f"{candidate!r} is reserved: it already names something on this domain.",
83
+ )
84
+ if candidate.startswith("xn--"):
85
+ raise SlugRefused("invalid_slug", "That looks like a punycode label; pick a plain one.")
86
+ if "--" in candidate:
87
+ raise SlugRefused(
88
+ "invalid_slug",
89
+ f"{candidate!r} has a double hyphen; pick e.g. {candidate.replace('--', '-')}.",
90
+ )
91
+ if not SLUG_RE.match(candidate):
92
+ raise SlugRefused(
93
+ "invalid_slug",
94
+ f"{candidate!r} is not a DNS label: lowercase a-z, digits and inner hyphens only, "
95
+ "no leading or trailing hyphen.",
96
+ )
97
+ return candidate
@@ -0,0 +1,577 @@
1
+ """Persistence for the hosted sign-up surface: flows, sessions, eligibility.
2
+
3
+ Deliberately separate from :class:`hoja_server.store.Store`, which owns
4
+ workspace data. Nothing here can create a workspace: the strongest row this
5
+ module writes is a ``signup_eligibility`` row with ``status='eligible'`` and
6
+ ``claimed_at IS NULL`` — a *request* the provisioner may pick up later.
7
+
8
+ Concurrency notes, because they are the whole safety story:
9
+
10
+ * ``consume_flow`` marks the flow row used with a guarded ``UPDATE``; a second
11
+ callback with the same state matches no row and is refused.
12
+ * ``turnstile_use.token_hash`` is the primary key, so spending a token twice
13
+ fails on insert rather than passing the gate.
14
+ * ``signup_eligibility`` has unique ``slug``, unique ``idempotency_key`` and
15
+ unique ``(google_sub, slug)``, so a retried request cannot enqueue two rows.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import hmac
22
+ import json
23
+ import secrets
24
+ from datetime import datetime, timedelta, timezone
25
+ from pathlib import Path
26
+
27
+ from sqlalchemy import and_, case, func, select
28
+ from sqlalchemy.exc import IntegrityError
29
+
30
+ from hoja_server.db import (
31
+ new_id,
32
+ now_iso,
33
+ signup_eligibility,
34
+ signup_flow,
35
+ signup_identity,
36
+ signup_invite,
37
+ signup_session,
38
+ token_hash,
39
+ turnstile_use,
40
+ )
41
+
42
+
43
+ def _one(conn, stmt):
44
+ row = conn.execute(stmt).first()
45
+ return dict(row._mapping) if row else None
46
+
47
+
48
+ def _utc_iso(moment: datetime) -> str:
49
+ return moment.astimezone(timezone.utc).isoformat()
50
+
51
+
52
+ def _now(now: datetime | None) -> datetime:
53
+ return now if now is not None else datetime.now(timezone.utc)
54
+
55
+
56
+ class EligibilityConflict(RuntimeError):
57
+ """A unique constraint says this slug or identity is already spoken for."""
58
+
59
+ def __init__(self, reason: str, message: str):
60
+ super().__init__(message)
61
+ self.reason = reason
62
+
63
+
64
+ def idempotency_key_for(subject: str, slug: str) -> str:
65
+ """Stable key for one (identity, slug) request: the provisioner's guard."""
66
+ return hashlib.sha256(f"{subject}\x00{slug}".encode()).hexdigest()
67
+
68
+
69
+ class SignupStore:
70
+ def __init__(self, engine):
71
+ self.engine = engine
72
+
73
+ # ---------------------------------------------------------------- flows
74
+
75
+ def start_flow(
76
+ self,
77
+ *,
78
+ state: str,
79
+ binding: str,
80
+ nonce: str,
81
+ code_verifier: str,
82
+ redirect_uri: str,
83
+ return_to: str | None,
84
+ ttl_seconds: int,
85
+ now: datetime | None = None,
86
+ ) -> str:
87
+ moment = _now(now)
88
+ flow_id = new_id()
89
+ with self.engine.begin() as conn:
90
+ conn.execute(
91
+ signup_flow.insert().values(
92
+ id=flow_id,
93
+ state_hash=token_hash(state),
94
+ binding_hash=token_hash(binding),
95
+ nonce=nonce,
96
+ code_verifier=code_verifier,
97
+ redirect_uri=redirect_uri,
98
+ return_to=return_to,
99
+ created_at=_utc_iso(moment),
100
+ expires_at=_utc_iso(moment + timedelta(seconds=ttl_seconds)),
101
+ )
102
+ )
103
+ return flow_id
104
+
105
+ def consume_flow(
106
+ self,
107
+ *,
108
+ state: str,
109
+ binding: str | None,
110
+ now: datetime | None = None,
111
+ ) -> tuple[dict | None, str]:
112
+ """Single-use lookup for the callback. Returns ``(flow, reason)``.
113
+
114
+ ``reason`` is ``ok``, ``state_unknown``, ``state_binding_mismatch``,
115
+ ``state_replayed`` or ``state_expired``. A mismatched browser binding must
116
+ not consume the flow: the rightful browser may still complete it.
117
+ """
118
+ if not state or not binding:
119
+ return None, "state_unknown"
120
+ moment = _now(now)
121
+ marker = _utc_iso(moment)
122
+ with self.engine.begin() as conn:
123
+ row = _one(
124
+ conn,
125
+ select(signup_flow).where(signup_flow.c.state_hash == token_hash(state)),
126
+ )
127
+ if row is None:
128
+ return None, "state_unknown"
129
+ if not hmac.compare_digest(row["binding_hash"], token_hash(binding)):
130
+ return None, "state_binding_mismatch"
131
+ if row["consumed_at"]:
132
+ return None, "state_replayed"
133
+ if row["expires_at"] <= marker:
134
+ return None, "state_expired"
135
+ result = conn.execute(
136
+ signup_flow.update()
137
+ .where(
138
+ and_(
139
+ signup_flow.c.id == row["id"],
140
+ signup_flow.c.consumed_at.is_(None),
141
+ )
142
+ )
143
+ .values(consumed_at=marker)
144
+ )
145
+ if result.rowcount != 1:
146
+ return None, "state_replayed"
147
+ row["consumed_at"] = marker
148
+ return row, "ok"
149
+
150
+ # ------------------------------------------------------------- identity
151
+
152
+ def upsert_identity(
153
+ self,
154
+ *,
155
+ subject: str,
156
+ email: str,
157
+ email_verified: bool,
158
+ name: str | None = None,
159
+ picture: str | None = None,
160
+ hosted_domain: str | None = None,
161
+ now: datetime | None = None,
162
+ ) -> dict:
163
+ """Insert or refresh the person. ``(provider, subject)`` is the key."""
164
+ moment = _now(now)
165
+ stamp = _utc_iso(moment)
166
+ values = {
167
+ "email": email,
168
+ "email_verified": bool(email_verified),
169
+ "name": name,
170
+ "picture": picture,
171
+ "hosted_domain": hosted_domain,
172
+ "google_verified_at": stamp,
173
+ "updated_at": stamp,
174
+ }
175
+ with self.engine.begin() as conn:
176
+ existing = _one(
177
+ conn,
178
+ select(signup_identity).where(
179
+ and_(
180
+ signup_identity.c.provider == "google",
181
+ signup_identity.c.subject == subject,
182
+ )
183
+ ),
184
+ )
185
+ if existing is not None:
186
+ conn.execute(
187
+ signup_identity.update()
188
+ .where(signup_identity.c.id == existing["id"])
189
+ .values(**values)
190
+ )
191
+ return {**existing, **values}
192
+ try:
193
+ with self.engine.begin() as conn:
194
+ conn.execute(
195
+ signup_identity.insert().values(
196
+ id=new_id(),
197
+ provider="google",
198
+ subject=subject,
199
+ created_at=stamp,
200
+ **values,
201
+ )
202
+ )
203
+ except IntegrityError:
204
+ pass # a concurrent sign-in inserted it first; read below
205
+ with self.engine.connect() as conn:
206
+ row = _one(
207
+ conn,
208
+ select(signup_identity).where(
209
+ and_(
210
+ signup_identity.c.provider == "google",
211
+ signup_identity.c.subject == subject,
212
+ )
213
+ ),
214
+ )
215
+ if row is None: # pragma: no cover - only if the row vanished mid-flight
216
+ raise EligibilityConflict("identity_missing", "Could not record the Google identity.")
217
+ return row
218
+
219
+ def identity_by_subject(self, subject: str) -> dict | None:
220
+ with self.engine.connect() as conn:
221
+ return _one(
222
+ conn,
223
+ select(signup_identity).where(
224
+ and_(
225
+ signup_identity.c.provider == "google",
226
+ signup_identity.c.subject == subject,
227
+ )
228
+ ),
229
+ )
230
+
231
+ # ------------------------------------------------------------- sessions
232
+
233
+ def create_session(
234
+ self,
235
+ *,
236
+ identity_id: str,
237
+ client_ip: str | None,
238
+ user_agent: str | None,
239
+ days: int,
240
+ rotated_from: str | None = None,
241
+ now: datetime | None = None,
242
+ ) -> tuple[str, dict]:
243
+ moment = _now(now)
244
+ raw = secrets.token_urlsafe(48)
245
+ row_id = new_id()
246
+ with self.engine.begin() as conn:
247
+ conn.execute(
248
+ signup_session.insert().values(
249
+ id=row_id,
250
+ token_hash=token_hash(raw),
251
+ identity_id=identity_id,
252
+ created_at=_utc_iso(moment),
253
+ expires_at=_utc_iso(moment + timedelta(days=days)),
254
+ rotated_from=rotated_from,
255
+ client_ip=client_ip,
256
+ user_agent=(user_agent or "")[:512] or None,
257
+ )
258
+ )
259
+ return raw, {
260
+ "id": row_id,
261
+ "identity_id": identity_id,
262
+ "expires_at": _utc_iso(moment + timedelta(days=days)),
263
+ }
264
+
265
+ def rotate_session(
266
+ self,
267
+ *,
268
+ previous_raw: str | None,
269
+ identity_id: str,
270
+ client_ip: str | None,
271
+ user_agent: str | None,
272
+ days: int,
273
+ now: datetime | None = None,
274
+ ) -> tuple[str, dict]:
275
+ """Revoke the presented session and issue a fresh one, same transaction."""
276
+ moment = _now(now)
277
+ raw = secrets.token_urlsafe(48)
278
+ row_id = new_id()
279
+ marker = _utc_iso(moment)
280
+ with self.engine.begin() as conn:
281
+ if previous_raw:
282
+ conn.execute(
283
+ signup_session.update()
284
+ .where(
285
+ and_(
286
+ signup_session.c.token_hash == token_hash(previous_raw),
287
+ signup_session.c.revoked_at.is_(None),
288
+ )
289
+ )
290
+ .values(revoked_at=marker)
291
+ )
292
+ conn.execute(
293
+ signup_session.insert().values(
294
+ id=row_id,
295
+ token_hash=token_hash(raw),
296
+ identity_id=identity_id,
297
+ created_at=marker,
298
+ expires_at=_utc_iso(moment + timedelta(days=days)),
299
+ rotated_from=token_hash(previous_raw) if previous_raw else None,
300
+ client_ip=client_ip,
301
+ user_agent=(user_agent or "")[:512] or None,
302
+ )
303
+ )
304
+ return raw, {
305
+ "id": row_id,
306
+ "identity_id": identity_id,
307
+ "expires_at": _utc_iso(moment + timedelta(days=days)),
308
+ }
309
+
310
+ def resolve_session(self, raw: str | None, *, now: datetime | None = None) -> dict | None:
311
+ """opaque cookie value → {session, identity} when live and unexpired."""
312
+ if not raw:
313
+ return None
314
+ marker = _utc_iso(_now(now))
315
+ with self.engine.connect() as conn:
316
+ row = _one(
317
+ conn,
318
+ select(
319
+ signup_session.c.id.label("session_id"),
320
+ signup_session.c.expires_at.label("session_expires_at"),
321
+ signup_session.c.client_ip.label("session_client_ip"),
322
+ signup_identity.c.id.label("identity_id"),
323
+ signup_identity.c.subject,
324
+ signup_identity.c.email,
325
+ signup_identity.c.email_verified,
326
+ signup_identity.c.google_verified_at,
327
+ signup_identity.c.hosted_domain,
328
+ )
329
+ .join(signup_identity, signup_identity.c.id == signup_session.c.identity_id)
330
+ .where(
331
+ and_(
332
+ signup_session.c.token_hash == token_hash(raw),
333
+ signup_session.c.revoked_at.is_(None),
334
+ signup_session.c.expires_at > marker,
335
+ )
336
+ ),
337
+ )
338
+ return row
339
+
340
+ def revoke_session(self, raw: str | None) -> None:
341
+ if not raw:
342
+ return
343
+ with self.engine.begin() as conn:
344
+ conn.execute(
345
+ signup_session.update()
346
+ .where(
347
+ and_(
348
+ signup_session.c.token_hash == token_hash(raw),
349
+ signup_session.c.revoked_at.is_(None),
350
+ )
351
+ )
352
+ .values(revoked_at=now_iso())
353
+ )
354
+
355
+ # -------------------------------------------------------------- invites
356
+
357
+ def active_invite_for(self, email: str, *, now: datetime | None = None) -> dict | None:
358
+ marker = _utc_iso(_now(now))
359
+ with self.engine.connect() as conn:
360
+ return _one(
361
+ conn,
362
+ select(signup_invite).where(
363
+ and_(
364
+ signup_invite.c.email == (email or "").strip().lower(),
365
+ signup_invite.c.consumed_at.is_(None),
366
+ (signup_invite.c.expires_at.is_(None))
367
+ | (signup_invite.c.expires_at > marker),
368
+ )
369
+ ),
370
+ )
371
+
372
+ def consume_invite(self, invite_id: str, identity_id: str, *, now: datetime | None = None) -> bool:
373
+ with self.engine.begin() as conn:
374
+ result = conn.execute(
375
+ signup_invite.update()
376
+ .where(
377
+ and_(
378
+ signup_invite.c.id == invite_id,
379
+ signup_invite.c.consumed_at.is_(None),
380
+ )
381
+ )
382
+ .values(consumed_at=_utc_iso(_now(now)), consumed_by_identity_id=identity_id)
383
+ )
384
+ return result.rowcount == 1
385
+
386
+ def add_invite(
387
+ self,
388
+ *,
389
+ email: str,
390
+ note: str | None = None,
391
+ created_by: str | None = None,
392
+ expires_at: str | None = None,
393
+ now: datetime | None = None,
394
+ ) -> dict:
395
+ invite_id = new_id()
396
+ with self.engine.begin() as conn:
397
+ conn.execute(
398
+ signup_invite.insert().values(
399
+ id=invite_id,
400
+ email=email.strip().lower(),
401
+ note=note,
402
+ created_by=created_by,
403
+ created_at=_utc_iso(_now(now)),
404
+ expires_at=expires_at,
405
+ )
406
+ )
407
+ return {"id": invite_id, "email": email.strip().lower(), "expires_at": expires_at}
408
+
409
+ # ------------------------------------------------------- human (single use)
410
+
411
+ def consume_turnstile_token(
412
+ self,
413
+ *,
414
+ token: str,
415
+ identity_id: str | None,
416
+ client_ip: str | None,
417
+ hostname: str | None,
418
+ now: datetime | None = None,
419
+ ) -> bool:
420
+ """Spend the token. False means it was already spent (a replay)."""
421
+ if not token:
422
+ return False
423
+ try:
424
+ with self.engine.begin() as conn:
425
+ conn.execute(
426
+ turnstile_use.insert().values(
427
+ token_hash=token_hash(token),
428
+ identity_id=identity_id,
429
+ client_ip=client_ip,
430
+ hostname=hostname,
431
+ consumed_at=_utc_iso(_now(now)),
432
+ )
433
+ )
434
+ except IntegrityError:
435
+ return False
436
+ return True
437
+
438
+ # ------------------------------------------------------------ eligibility
439
+
440
+ def eligibility_for(self, identity_id: str, slug: str) -> dict | None:
441
+ with self.engine.connect() as conn:
442
+ return _one(
443
+ conn,
444
+ select(signup_eligibility).where(
445
+ and_(
446
+ signup_eligibility.c.identity_id == identity_id,
447
+ signup_eligibility.c.slug == slug,
448
+ )
449
+ ),
450
+ )
451
+
452
+ def latest_eligibility_for_identity(self, identity_id: str) -> dict | None:
453
+ """The request this person should see on a fresh page load.
454
+
455
+ An active workspace wins over a newer failed/duplicate row, so a
456
+ completed workspace can never be hidden behind queue history.
457
+ """
458
+ with self.engine.connect() as conn:
459
+ return _one(
460
+ conn,
461
+ select(signup_eligibility)
462
+ .where(signup_eligibility.c.identity_id == identity_id)
463
+ .order_by(
464
+ case((signup_eligibility.c.status == "active", 1), else_=0).desc(),
465
+ signup_eligibility.c.created_at.desc(),
466
+ )
467
+ .limit(1),
468
+ )
469
+
470
+ def eligibility_by_subject_slug(self, subject: str, slug: str) -> dict | None:
471
+ with self.engine.connect() as conn:
472
+ return _one(
473
+ conn,
474
+ select(signup_eligibility).where(
475
+ and_(
476
+ signup_eligibility.c.google_sub == subject,
477
+ signup_eligibility.c.slug == slug,
478
+ )
479
+ ),
480
+ )
481
+
482
+ def eligibility_by_slug(self, slug: str) -> dict | None:
483
+ with self.engine.connect() as conn:
484
+ return _one(
485
+ conn,
486
+ select(signup_eligibility).where(signup_eligibility.c.slug == slug),
487
+ )
488
+
489
+ def eligibility_count(
490
+ self,
491
+ *,
492
+ client_ip: str | None = None,
493
+ google_sub: str | None = None,
494
+ since: datetime | None = None,
495
+ ) -> int:
496
+ statement = select(func.count()).select_from(signup_eligibility)
497
+ conditions = []
498
+ if client_ip is not None:
499
+ conditions.append(signup_eligibility.c.client_ip == client_ip)
500
+ if google_sub is not None:
501
+ conditions.append(signup_eligibility.c.google_sub == google_sub)
502
+ if since is not None:
503
+ conditions.append(signup_eligibility.c.created_at >= _utc_iso(since))
504
+ if conditions:
505
+ statement = statement.where(and_(*conditions))
506
+ with self.engine.connect() as conn:
507
+ return int(conn.execute(statement).scalar_one() or 0)
508
+
509
+ def record_eligibility(
510
+ self,
511
+ *,
512
+ identity: dict,
513
+ slug: str,
514
+ client_ip: str,
515
+ turnstile_hostname: str | None,
516
+ now: datetime | None = None,
517
+ ) -> tuple[dict, bool]:
518
+ """Enqueue one workspace request. Returns ``(row, created)``.
519
+
520
+ ``created`` is False when an identical request was already recorded, so
521
+ a retried POST is idempotent instead of a second queue entry. Raises
522
+ :class:`EligibilityConflict` when the slug belongs to someone else.
523
+ """
524
+ subject = identity["subject"]
525
+ identity_id = identity.get("identity_id") or identity["id"]
526
+ key = idempotency_key_for(subject, slug)
527
+ existing = self.eligibility_for(identity_id, slug)
528
+ if existing is not None:
529
+ return existing, False
530
+ row_id = new_id()
531
+ try:
532
+ with self.engine.begin() as conn:
533
+ conn.execute(
534
+ signup_eligibility.insert().values(
535
+ id=row_id,
536
+ idempotency_key=key,
537
+ identity_id=identity_id,
538
+ google_sub=subject,
539
+ email=identity["email"],
540
+ slug=slug,
541
+ status="eligible",
542
+ client_ip=client_ip or "unknown",
543
+ turnstile_hostname=turnstile_hostname,
544
+ created_at=_utc_iso(_now(now)),
545
+ )
546
+ )
547
+ except IntegrityError:
548
+ row = self.eligibility_by_slug(slug)
549
+ if row is not None and row["google_sub"] == subject:
550
+ return row, False
551
+ raise EligibilityConflict(
552
+ "slug_taken", f"{slug!r} was just taken. Pick another name."
553
+ ) from None
554
+ with self.engine.connect() as conn:
555
+ row = _one(conn, select(signup_eligibility).where(signup_eligibility.c.id == row_id))
556
+ return row, True
557
+
558
+ def taken_slugs(self, registry_path: str | None = None) -> set[str]:
559
+ """Slugs the provisioner already owns, plus every queued request.
560
+
561
+ ``registry_path`` is Hoja-Cloud's ``tenants/registry.json`` when the
562
+ deployment points ``HOJA_TENANT_REGISTRY`` at it; absent, the gate only
563
+ knows what it has seen itself.
564
+ """
565
+ with self.engine.connect() as conn:
566
+ taken = {row[0] for row in conn.execute(select(signup_eligibility.c.slug))}
567
+ if registry_path:
568
+ try:
569
+ document = json.loads(Path(registry_path).read_text(encoding="utf-8"))
570
+ except (OSError, ValueError):
571
+ document = None
572
+ tenants = document.get("tenants") if isinstance(document, dict) else None
573
+ if isinstance(tenants, list):
574
+ for tenant in tenants:
575
+ if isinstance(tenant, dict) and isinstance(tenant.get("slug"), str):
576
+ taken.add(tenant["slug"])
577
+ return taken