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.
- VERSION +1 -0
- google_workspace/__init__.py +19 -0
- google_workspace/client.py +253 -0
- google_workspace/config.py +39 -0
- google_workspace/errors.py +19 -0
- google_workspace/mcp_server.py +100 -0
- google_workspace/oauth.py +131 -0
- google_workspace/principal.py +83 -0
- google_workspace/py.typed +0 -0
- google_workspace/tools.py +64 -0
- google_workspace/writes.py +44 -0
- hoja/__init__.py +29 -0
- hoja/__main__.py +6 -0
- hoja/auth.py +42 -0
- hoja/cli.py +161 -0
- hoja/cli_state.py +55 -0
- hoja/client.py +268 -0
- hoja/commands/__init__.py +4 -0
- hoja/commands/auth_cmd.py +271 -0
- hoja/commands/comment.py +149 -0
- hoja/commands/cycle.py +75 -0
- hoja/commands/init.py +108 -0
- hoja/commands/issue.py +398 -0
- hoja/commands/label.py +163 -0
- hoja/commands/memory.py +240 -0
- hoja/commands/project.py +56 -0
- hoja/commands/raw.py +50 -0
- hoja/commands/team.py +102 -0
- hoja/commands/update.py +162 -0
- hoja/config.py +362 -0
- hoja/errors.py +107 -0
- hoja/filters.py +150 -0
- hoja/graphql/__init__.py +5 -0
- hoja/graphql/fragments.py +138 -0
- hoja/graphql/mutations.py +160 -0
- hoja/graphql/queries.py +245 -0
- hoja/mcp/__init__.py +27 -0
- hoja/mcp/content.py +151 -0
- hoja/mcp/prompts.py +298 -0
- hoja/mcp/resources.py +213 -0
- hoja/mcp/server.py +189 -0
- hoja/memory_board.py +370 -0
- hoja/models/__init__.py +37 -0
- hoja/models/base.py +64 -0
- hoja/models/cycle.py +32 -0
- hoja/models/enums.py +62 -0
- hoja/models/issue.py +99 -0
- hoja/models/label.py +17 -0
- hoja/models/project.py +60 -0
- hoja/models/team.py +31 -0
- hoja/models/user.py +32 -0
- hoja/models/workflow.py +65 -0
- hoja/output.py +410 -0
- hoja/skill_content/commands.md +222 -0
- hoja/skill_content/examples.md +193 -0
- hoja/skill_content/filters.md +101 -0
- hoja/skill_content/output-formats.md +92 -0
- hoja/skill_content/overview.md +123 -0
- hoja/skill_content/workflows.md +174 -0
- hoja_cli-0.21.4.dist-info/METADATA +279 -0
- hoja_cli-0.21.4.dist-info/RECORD +107 -0
- hoja_cli-0.21.4.dist-info/WHEEL +4 -0
- hoja_cli-0.21.4.dist-info/entry_points.txt +4 -0
- hoja_cli-0.21.4.dist-info/licenses/LICENSE +21 -0
- hoja_server/README.md +135 -0
- hoja_server/__init__.py +11 -0
- hoja_server/abac.py +116 -0
- hoja_server/agent_chat.py +280 -0
- hoja_server/agent_conversations.py +91 -0
- hoja_server/agent_durable.py +332 -0
- hoja_server/agent_prompt.py +117 -0
- hoja_server/agent_threads.py +96 -0
- hoja_server/agent_uploads.py +60 -0
- hoja_server/app.py +2189 -0
- hoja_server/cadence.py +231 -0
- hoja_server/cli.py +191 -0
- hoja_server/cloudflare_realtime.py +110 -0
- hoja_server/comment_policy.py +70 -0
- hoja_server/connector_actions.py +119 -0
- hoja_server/connectors_api.py +375 -0
- hoja_server/db.py +1816 -0
- hoja_server/denials.py +18 -0
- hoja_server/deployment.py +28 -0
- hoja_server/email_adapter.py +94 -0
- hoja_server/google_login.py +217 -0
- hoja_server/google_oidc.py +342 -0
- hoja_server/identity_transfer.py +210 -0
- hoja_server/mcp_oauth.py +623 -0
- hoja_server/mcp_tools.py +579 -0
- hoja_server/meeting_policy.py +179 -0
- hoja_server/meetings.py +1332 -0
- hoja_server/meetings_api.py +909 -0
- hoja_server/model_providers.py +205 -0
- hoja_server/realtime.py +690 -0
- hoja_server/resolvers.py +1281 -0
- hoja_server/schema.graphql +477 -0
- hoja_server/schema_sdl.py +8 -0
- hoja_server/secrets_box.py +39 -0
- hoja_server/signup_config.py +292 -0
- hoja_server/signup_gate.py +393 -0
- hoja_server/signup_policy.py +97 -0
- hoja_server/signup_store.py +577 -0
- hoja_server/store.py +2179 -0
- hoja_server/sync.py +249 -0
- hoja_server/tenant_context.py +131 -0
- hoja_server/turnstile.py +81 -0
- 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
|