@snagset/server 0.0.1
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.
- package/README.md +83 -0
- package/dist/app.js +453 -0
- package/dist/app.js.map +1 -0
- package/dist/bin.js +951 -0
- package/dist/bin.js.map +1 -0
- package/dist/boot.js +321 -0
- package/dist/boot.js.map +1 -0
- package/dist/config.generated.js +116 -0
- package/dist/config.generated.js.map +1 -0
- package/dist/config.js +180 -0
- package/dist/config.js.map +1 -0
- package/dist/crypto.js +26 -0
- package/dist/crypto.js.map +1 -0
- package/dist/db/connect.js +49 -0
- package/dist/db/connect.js.map +1 -0
- package/dist/db/migrate.js +210 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/db/migrations/0001_init.sql +1060 -0
- package/dist/db/migrations/0002_maintenance_claim.sql +109 -0
- package/dist/db/migrations/0003_email_provider.sql +35 -0
- package/dist/db/migrations/0004_unsubscribe.sql +24 -0
- package/dist/db/rls.js +58 -0
- package/dist/db/rls.js.map +1 -0
- package/dist/jobs/index.js +171 -0
- package/dist/jobs/index.js.map +1 -0
- package/dist/jobs/reaper.js +182 -0
- package/dist/jobs/reaper.js.map +1 -0
- package/dist/keys.js +77 -0
- package/dist/keys.js.map +1 -0
- package/dist/mail/index.js +75 -0
- package/dist/mail/index.js.map +1 -0
- package/dist/mail/mailer.js +27 -0
- package/dist/mail/mailer.js.map +1 -0
- package/dist/mail/notify.js +405 -0
- package/dist/mail/notify.js.map +1 -0
- package/dist/mail/resend.js +95 -0
- package/dist/mail/resend.js.map +1 -0
- package/dist/mail/smtp.js +315 -0
- package/dist/mail/smtp.js.map +1 -0
- package/dist/mail/unsubscribe.js +134 -0
- package/dist/mail/unsubscribe.js.map +1 -0
- package/dist/projectCache.js +50 -0
- package/dist/projectCache.js.map +1 -0
- package/dist/projects.js +101 -0
- package/dist/projects.js.map +1 -0
- package/dist/routes/attachments.js +159 -0
- package/dist/routes/attachments.js.map +1 -0
- package/dist/routes/auth.js +55 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/commentEdit.js +201 -0
- package/dist/routes/commentEdit.js.map +1 -0
- package/dist/routes/comments.js +313 -0
- package/dist/routes/comments.js.map +1 -0
- package/dist/routes/cors.js +174 -0
- package/dist/routes/cors.js.map +1 -0
- package/dist/routes/jobs.js +56 -0
- package/dist/routes/jobs.js.map +1 -0
- package/dist/routes/mcpAuth.js +187 -0
- package/dist/routes/mcpAuth.js.map +1 -0
- package/dist/routes/mcpToken.js +94 -0
- package/dist/routes/mcpToken.js.map +1 -0
- package/dist/routes/owner.js +288 -0
- package/dist/routes/owner.js.map +1 -0
- package/dist/routes/reviewLinks.js +169 -0
- package/dist/routes/reviewLinks.js.map +1 -0
- package/dist/routes/rounds.js +137 -0
- package/dist/routes/rounds.js.map +1 -0
- package/dist/routes/sessions.js +349 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/routes/sites.js +192 -0
- package/dist/routes/sites.js.map +1 -0
- package/dist/routes/threads.js +1080 -0
- package/dist/routes/threads.js.map +1 -0
- package/dist/routes/widget.js +132 -0
- package/dist/routes/widget.js.map +1 -0
- package/dist/serverless.js +97 -0
- package/dist/serverless.js.map +1 -0
- package/dist/session.js +152 -0
- package/dist/session.js.map +1 -0
- package/dist/storage/bucket.js +116 -0
- package/dist/storage/bucket.js.map +1 -0
- package/dist/storage/settings.js +132 -0
- package/dist/storage/settings.js.map +1 -0
- package/dist/vendor/adapters/index.js +1 -0
- package/dist/vendor/adapters/outbound/fence.js +44 -0
- package/dist/vendor/adapters/outbound/sanitise.js +233 -0
- package/dist/vendor/types/index.js +4 -0
- package/dist/vendor/types/names.js +254 -0
- package/dist/vendor/widget/object.js +38 -0
- package/dist/vendor/widget/predicates.js +377 -0
- package/package.json +53 -0
- package/static/dashboard/assets/index-CaPfKhFP.css +1 -0
- package/static/dashboard/assets/index-DfTNjHCK.js +1 -0
- package/static/dashboard/index.html +14 -0
- package/static/widget/snag.anchor-ext.js +1 -0
- package/static/widget/snag.anchor-fuzzy.js +1 -0
- package/static/widget/snag.capture.js +4 -0
- package/static/widget/snag.crumbs.js +4 -0
- package/static/widget/snag.dock.js +5 -0
- package/static/widget/snag.js +5 -0
- package/static/widget/snag.panels.js +2 -0
- package/static/widget/snag.runtime.js +2 -0
- package/static/widget/snag.screenshot.js +1 -0
- package/static/widget/snag.text.js +2 -0
- package/vercel.json +6 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { CREDENTIAL_PREFIX } from "../vendor/types/names.js";
|
|
3
|
+
import { verifyMcpToken, touchMcpToken } from "./mcpToken.js";
|
|
4
|
+
/**
|
|
5
|
+
* The write budget — 07 §8.4, `mcpw:token`, 20 writes per 10 minutes
|
|
6
|
+
* *(calibration)*, and §8.6 mitigation 5 is why it exists: *"An agent injected
|
|
7
|
+
* into 'resolve every thread' stops on the 21st and must tell its user."*
|
|
8
|
+
*
|
|
9
|
+
* Counted out of `events` rather than a bucket in memory, and that is not
|
|
10
|
+
* laziness. A budget in process memory resets when the process restarts and
|
|
11
|
+
* does not exist at all across two instances — so on the serverless entry
|
|
12
|
+
* (`serverless.ts`) an attacker gets 20 writes *per warm instance*, which is
|
|
13
|
+
* not a budget. §8.6 mitigation 8 already requires every agent write to land in
|
|
14
|
+
* `events` with the token id; counting the rows that must exist anyway makes
|
|
15
|
+
* the budget exactly as durable as the audit trail.
|
|
16
|
+
*/
|
|
17
|
+
export const WRITE_BUDGET = { writes: 20, windowMinutes: 10 };
|
|
18
|
+
export function mcpAuth(sql) {
|
|
19
|
+
return async (c, next) => {
|
|
20
|
+
const presented = /^Bearer (.+)$/i.exec(c.req.header("Authorization") ?? "")?.[1];
|
|
21
|
+
// Not an MCP token — a reviewer bearer, or nothing. Costs one string
|
|
22
|
+
// comparison and no query, which is what keeps this mountable app-wide.
|
|
23
|
+
if (!presented?.startsWith(CREDENTIAL_PREFIX.mcp))
|
|
24
|
+
return next();
|
|
25
|
+
const claims = await verifyMcpToken(sql, presented);
|
|
26
|
+
if (!claims) {
|
|
27
|
+
return c.json({
|
|
28
|
+
error: {
|
|
29
|
+
code: "unauthorized",
|
|
30
|
+
// One sentence for unknown, revoked and expired alike. Which of
|
|
31
|
+
// the three it was is exactly what a probe is trying to learn, and
|
|
32
|
+
// the operator can see it with `snagset mcp-token list`.
|
|
33
|
+
message: "That MCP token is not valid. It may have been revoked or have expired — " +
|
|
34
|
+
"mint a new one with `snagset mcp-token create`.",
|
|
35
|
+
requestId: c.get("requestId"),
|
|
36
|
+
},
|
|
37
|
+
}, 401);
|
|
38
|
+
}
|
|
39
|
+
c.set("mcp", claims);
|
|
40
|
+
/**
|
|
41
|
+
* The audit timestamp, and the address as a per-token salted hash.
|
|
42
|
+
*
|
|
43
|
+
* `void` on purpose — F29 reads `last_used_at`, but a failure to record it
|
|
44
|
+
* must never fail a request that otherwise succeeded.
|
|
45
|
+
*
|
|
46
|
+
* The salt is the token's OWN hash. 00 §14.2b puts the IP salt on
|
|
47
|
+
* `projects`, which works for a project-scoped token and has nothing to
|
|
48
|
+
* offer an org-wide one — and an org-wide token is what the CLI mints, so
|
|
49
|
+
* "use the project salt" would mean recording nothing in the common case
|
|
50
|
+
* and leaving F29 blind. `token_hash` is per-row, already stored, cannot
|
|
51
|
+
* predate the row it is on, and is not derivable by whoever is being
|
|
52
|
+
* hashed. The raw address is still never stored (08 §4.7).
|
|
53
|
+
*/
|
|
54
|
+
void touchMcpToken(sql, claims.tokenId, hashAddress(c, claims)).catch(() => { });
|
|
55
|
+
return next();
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function hashAddress(c, claims) {
|
|
59
|
+
const raw = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || c.req.header("x-real-ip")?.trim() || "";
|
|
60
|
+
if (!raw)
|
|
61
|
+
return null;
|
|
62
|
+
return createHash("sha256").update(raw, "utf8").update(claims.tokenId, "utf8").digest();
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Does this token carry the scope, for this org?
|
|
66
|
+
*
|
|
67
|
+
* BOTH halves matter and the org half is the one that is easy to forget: a
|
|
68
|
+
* valid token for another org is a valid token, and every route here resolves
|
|
69
|
+
* its project independently of the credential. This is the same check
|
|
70
|
+
* `asOwner` makes (`owner.orgId === p.org_id`), for the same reason.
|
|
71
|
+
*/
|
|
72
|
+
export function mcpAllows(claims, orgId, scope) {
|
|
73
|
+
return !!claims && claims.orgId === orgId && claims.scopes.includes(scope);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The refusal, with the scope named — 05's registered `mcp_scope_denied`.
|
|
77
|
+
*
|
|
78
|
+
* The message says which scope and how to fix it, because the reader is an
|
|
79
|
+
* agent relaying to a developer who minted the token minutes ago and has no
|
|
80
|
+
* idea the default is read-only. "Forbidden" would send them to the dashboard
|
|
81
|
+
* looking for a permission that is not there.
|
|
82
|
+
*/
|
|
83
|
+
export function scopeDenied(c, scope) {
|
|
84
|
+
return c.json({
|
|
85
|
+
error: {
|
|
86
|
+
code: "mcp_scope_denied",
|
|
87
|
+
message: `This MCP token does not carry the "${scope}" scope. Mint one that does with ` +
|
|
88
|
+
`\`snagset mcp-token create <name> ${scope}\` — the default token is read-only on purpose.`,
|
|
89
|
+
requestId: c.get("requestId"),
|
|
90
|
+
},
|
|
91
|
+
}, 403);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Spend one write from the budget, or refuse.
|
|
95
|
+
*
|
|
96
|
+
* Returns null when the write may proceed. Deliberately checked BEFORE the
|
|
97
|
+
* write rather than after: a budget enforced by refusing the 21st response
|
|
98
|
+
* while having already made the 21st change is not a budget.
|
|
99
|
+
*/
|
|
100
|
+
export async function checkWriteBudget(sql, tokenId) {
|
|
101
|
+
const [row] = await sql `
|
|
102
|
+
SELECT count(*)::int AS n FROM events
|
|
103
|
+
WHERE actor_kind = 'integration'
|
|
104
|
+
AND payload->>'mcpTokenId' = ${tokenId}
|
|
105
|
+
AND created_at > now() - ${`${WRITE_BUDGET.windowMinutes} minutes`}::interval
|
|
106
|
+
`;
|
|
107
|
+
const spent = row?.n ?? 0;
|
|
108
|
+
return spent >= WRITE_BUDGET.writes ? spent : null;
|
|
109
|
+
}
|
|
110
|
+
/** 07 §8.4's stop signal, as an HTTP response. */
|
|
111
|
+
export function budgetExhausted(c) {
|
|
112
|
+
return c.json({
|
|
113
|
+
error: {
|
|
114
|
+
code: "rate_limited",
|
|
115
|
+
message: `This agent has made ${WRITE_BUDGET.writes} writes in the last ` +
|
|
116
|
+
`${WRITE_BUDGET.windowMinutes} minutes, which is the limit. Stop and tell your user ` +
|
|
117
|
+
`what you were doing — do not retry.`,
|
|
118
|
+
requestId: c.get("requestId"),
|
|
119
|
+
},
|
|
120
|
+
}, 429);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The audit row — 07 §8.6 mitigation 8, *"so 'what did the agent do last
|
|
124
|
+
* Tuesday' is one query"*.
|
|
125
|
+
*
|
|
126
|
+
* Takes a transaction, and every caller passes the one doing the write. That
|
|
127
|
+
* is what makes the budget above sound: the audit row and the change it
|
|
128
|
+
* records commit together, so there is no window in which a write has happened
|
|
129
|
+
* and is not yet counted.
|
|
130
|
+
*/
|
|
131
|
+
export async function recordAgentWrite(tx, what) {
|
|
132
|
+
// `tx.json(…)`, NOT `JSON.stringify(…)::jsonb`.
|
|
133
|
+
//
|
|
134
|
+
// postgres.js JSON-encodes a string parameter bound to a jsonb column, so a
|
|
135
|
+
// stringified object arrives as a JSON *string scalar*: `jsonb_typeof` says
|
|
136
|
+
// `string`, and `payload->>'mcpTokenId'` is null. Which silently disabled the
|
|
137
|
+
// write budget — `checkWriteBudget` counted zero, forever, and an agent could
|
|
138
|
+
// write without limit. The audit rows were all there and all unqueryable.
|
|
139
|
+
//
|
|
140
|
+
// Missed by the unit test because that test SEEDED the rows itself, correctly,
|
|
141
|
+
// and then asserted the refusal. It proved the query, and the query was never
|
|
142
|
+
// the broken half. Caught by making 21 real writes through a published tarball
|
|
143
|
+
// and watching all 21 succeed.
|
|
144
|
+
await tx `
|
|
145
|
+
INSERT INTO events (id, org_id, project_id, type, actor_kind, actor_id, subject_type, subject_id, payload)
|
|
146
|
+
VALUES (${randomUUID()}, ${what.orgId}, ${what.projectId}, ${what.type}, 'integration',
|
|
147
|
+
${what.reviewerId}, ${what.subjectType}, ${what.subjectId},
|
|
148
|
+
${tx.json({ mcpTokenId: what.tokenId })})
|
|
149
|
+
`;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Find-or-create the agent's reviewer row — 07 §8.2 *Authorship*, and 05 §9.6's
|
|
153
|
+
* representation: `kind = 'guest'` with `is_agent = true` and
|
|
154
|
+
* `agent_mcp_token_id` set.
|
|
155
|
+
*
|
|
156
|
+
* **Never `verified`.** Draft 2 of 07 posted agent comments as `kind:
|
|
157
|
+
* 'verified'` with an `(agent)` name suffix and that is deleted: `verified`
|
|
158
|
+
* means the host application's server vouched for a named human, and it
|
|
159
|
+
* carries a moderation bypass. Laundering a machine credential into it hands
|
|
160
|
+
* an agent — possibly one acting on injected instructions — the highest trust
|
|
161
|
+
* tier in the system on the strength of a name convention.
|
|
162
|
+
*
|
|
163
|
+
* `holder_secret_hash` is 32 random bytes whose plaintext is DISCARDED here and
|
|
164
|
+
* never existed anywhere else. 0001's CHECK requires a non-verified row to be a
|
|
165
|
+
* credential holder, a studio member or a placeholder, and an agent is the
|
|
166
|
+
* first of those — its credential is the MCP token. Hashing something guessable
|
|
167
|
+
* instead (the token id, say) would make this row impersonable through
|
|
168
|
+
* `/sessions`' guest path by anyone who learned a uuid; hashing a secret nobody
|
|
169
|
+
* holds means that door is not merely locked, there is no key.
|
|
170
|
+
*/
|
|
171
|
+
export async function agentReviewer(tx, what) {
|
|
172
|
+
const [existing] = await tx `
|
|
173
|
+
SELECT id FROM reviewers
|
|
174
|
+
WHERE project_id = ${what.projectId}::uuid AND agent_mcp_token_id = ${what.tokenId}::uuid
|
|
175
|
+
`;
|
|
176
|
+
if (existing)
|
|
177
|
+
return existing.id;
|
|
178
|
+
const id = randomUUID();
|
|
179
|
+
await tx `
|
|
180
|
+
INSERT INTO reviewers (id, org_id, project_id, kind, name, holder_secret_hash, is_agent, agent_mcp_token_id)
|
|
181
|
+
VALUES (${id}::uuid, ${what.orgId}::uuid, ${what.projectId}::uuid, 'guest',
|
|
182
|
+
${what.tokenName}, ${createHash("sha256").update(randomBytes(32)).digest()},
|
|
183
|
+
true, ${what.tokenId}::uuid)
|
|
184
|
+
`;
|
|
185
|
+
return id;
|
|
186
|
+
}
|
|
187
|
+
//# sourceMappingURL=mcpAuth.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mcpAuth.js","sourceRoot":"","sources":["../../src/routes/mcpAuth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAGlE,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AACzD,OAAO,EAAE,cAAc,EAAE,aAAa,EAAkB,MAAM,eAAe,CAAC;AAiC9E;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,aAAa,EAAE,EAAE,EAAW,CAAC;AAEvE,MAAM,UAAU,OAAO,CAAC,GAAiB;IACvC,OAAO,KAAK,EAAE,CAAC,EAAE,IAAI,EAAE,EAAE;QACvB,MAAM,SAAS,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,eAAe,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;QAClF,qEAAqE;QACrE,wEAAwE;QACxE,IAAI,CAAC,SAAS,EAAE,UAAU,CAAC,iBAAiB,CAAC,GAAG,CAAC;YAAE,OAAO,IAAI,EAAE,CAAC;QAEjE,MAAM,MAAM,GAAG,MAAM,cAAc,CAAC,GAAG,EAAE,SAAS,CAAC,CAAC;QACpD,IAAI,CAAC,MAAM,EAAE,CAAC;YACZ,OAAO,CAAC,CAAC,IAAI,CACX;gBACE,KAAK,EAAE;oBACL,IAAI,EAAE,cAAc;oBACpB,gEAAgE;oBAChE,mEAAmE;oBACnE,yDAAyD;oBACzD,OAAO,EACL,0EAA0E;wBAC1E,iDAAiD;oBACnD,SAAS,EAAE,CAAC,CAAC,GAAG,CAAC,WAAW,CAAC;iBAC9B;aACF,EACD,GAAG,CACJ,CAAC;QACJ,CAAC;QAED,CAAC,CAAC,GAAG,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;QAErB;;;;;;;;;;;;;WAaG;QACH,KAAK,aAAa,CAAC,GAAG,EAAE,MAAM,CAAC,OAAO,EAAE,WAAW,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;QAEhF,OAAO,IAAI,EAAE,CAAC;IAChB,CAAC,CAAC;AACJ,CAAC;AAED,SAAS,WAAW,CAAC,CAAU,EAAE,MAAiB;IAChD,MAAM,GAAG,GACP,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,iBAAiB,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,WAAW,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;IACpG,IAAI,CAAC,GAAG;QAAE,OAAO,IAAI,CAAC;IACtB,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,MAAM,EAAE,CAAC;AAC1F,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,SAAS,CAAC,MAA6B,EAAE,KAAa,EAAE,KAAa;IACnF,OAAO,CAAC,CAAC,MAAM,IAAI,MAAM,CAAC,KAAK,KAAK,KAAK,IAAI,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;AAC7E,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,WAAW,CAAC,CAAU,EAAE,KAAa;IACnD,OAAO,CAAC,CAAC,IAAI,CACX;QACE,KAAK,EAAE;YACL,IAAI,EAAE,kBAAkB;YACxB,OAAO,EACL,sCAAsC,KAAK,mCAAmC;gBAC9E,qCAAqC,KAAK,iDAAiD;YAC7F,SAAS,EAAE,CAAC,CAAC,GAAG,CAAC,WAAW,CAAC;SAC9B;KACF,EACD,GAAG,CACJ,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CAAC,GAAiB,EAAE,OAAe;IACvE,MAAM,CAAC,GAAG,CAAC,GAAG,MAAM,GAAG,CAAiB;;;sCAGJ,OAAO;kCACX,GAAG,YAAY,CAAC,aAAa,UAAU;GACtE,CAAC;IACF,MAAM,KAAK,GAAG,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;IAC1B,OAAO,KAAK,IAAI,YAAY,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC;AACrD,CAAC;AAED,kDAAkD;AAClD,MAAM,UAAU,eAAe,CAAC,CAAU;IACxC,OAAO,CAAC,CAAC,IAAI,CACX;QACE,KAAK,EAAE;YACL,IAAI,EAAE,cAAc;YACpB,OAAO,EACL,uBAAuB,YAAY,CAAC,MAAM,sBAAsB;gBAChE,GAAG,YAAY,CAAC,aAAa,wDAAwD;gBACrF,qCAAqC;YACvC,SAAS,EAAE,CAAC,CAAC,GAAG,CAAC,WAAW,CAAC;SAC9B;KACF,EACD,GAAG,CACJ,CAAC;AACJ,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACpC,EAA2B,EAC3B,IAQC;IAED,gDAAgD;IAChD,EAAE;IACF,4EAA4E;IAC5E,4EAA4E;IAC5E,8EAA8E;IAC9E,8EAA8E;IAC9E,0EAA0E;IAC1E,EAAE;IACF,+EAA+E;IAC/E,8EAA8E;IAC9E,+EAA+E;IAC/E,+BAA+B;IAC/B,MAAM,EAAE,CAAA;;cAEI,UAAU,EAAE,KAAK,IAAI,CAAC,KAAK,KAAK,IAAI,CAAC,SAAS,KAAK,IAAI,CAAC,IAAI;cAC5D,IAAI,CAAC,UAAU,KAAK,IAAI,CAAC,WAAW,KAAK,IAAI,CAAC,SAAS;cACvD,EAAE,CAAC,IAAI,CAAC,EAAE,UAAU,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC;GAChD,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,EAA2B,EAC3B,IAA8E;IAE9E,MAAM,CAAC,QAAQ,CAAC,GAAG,MAAM,EAAE,CAAkB;;0BAErB,IAAI,CAAC,SAAS,mCAAmC,IAAI,CAAC,OAAO;GACpF,CAAC;IACF,IAAI,QAAQ;QAAE,OAAO,QAAQ,CAAC,EAAE,CAAC;IAEjC,MAAM,EAAE,GAAG,UAAU,EAAE,CAAC;IACxB,MAAM,EAAE,CAAA;;cAEI,EAAE,WAAW,IAAI,CAAC,KAAK,WAAW,IAAI,CAAC,SAAS;cAChD,IAAI,CAAC,SAAS,KAAK,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,EAAE;oBAClE,IAAI,CAAC,OAAO;GAC7B,CAAC;IACF,OAAO,EAAE,CAAC;AACZ,CAAC"}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { CREDENTIAL_PREFIX } from "../vendor/types/names.js";
|
|
3
|
+
/** 07 §8.2's vocabulary. This spec owns it; the server enforces it. */
|
|
4
|
+
export const MCP_SCOPES = [
|
|
5
|
+
"threads:read",
|
|
6
|
+
"context:read",
|
|
7
|
+
"comments:write",
|
|
8
|
+
"threads:write",
|
|
9
|
+
"source:map",
|
|
10
|
+
];
|
|
11
|
+
/**
|
|
12
|
+
* What the dashboard issues by default: **read-only, plus local source
|
|
13
|
+
* mapping**.
|
|
14
|
+
*
|
|
15
|
+
* 07 §8.2: *"The default token issued by the dashboard carries `threads:read`
|
|
16
|
+
* and `source:map` only."* `context:read` is separate on purpose — breadcrumbs
|
|
17
|
+
* and capture context are the personal-data-bearing half, and an agent that
|
|
18
|
+
* only needs "what is broken and where" should not be handed a console log.
|
|
19
|
+
*/
|
|
20
|
+
export const DEFAULT_SCOPES = ["threads:read", "source:map"];
|
|
21
|
+
const hash = (token) => createHash("sha256").update(token, "utf8").digest();
|
|
22
|
+
export async function mintMcpToken(sql, id, opts) {
|
|
23
|
+
const secret = randomBytes(32).toString("base64url");
|
|
24
|
+
const prefix = randomBytes(6).toString("base64url").slice(0, 8);
|
|
25
|
+
const token = `${CREDENTIAL_PREFIX.mcp}${prefix}_${secret}`;
|
|
26
|
+
const scopes = opts.scopes ?? DEFAULT_SCOPES;
|
|
27
|
+
for (const scope of scopes) {
|
|
28
|
+
if (!MCP_SCOPES.includes(scope)) {
|
|
29
|
+
throw new Error(`unknown MCP scope ${JSON.stringify(scope)}. Known: ${MCP_SCOPES.join(", ")}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
await sql `
|
|
33
|
+
INSERT INTO mcp_tokens (id, org_id, project_id, name, prefix, token_hash, scopes, created_by, expires_at)
|
|
34
|
+
VALUES (${id}, ${opts.orgId}, ${opts.projectId ?? null}, ${opts.name}, ${prefix},
|
|
35
|
+
${hash(token)}, ${sql.array(scopes)}, ${opts.createdBy ?? null},
|
|
36
|
+
${opts.expiresAt ?? null})
|
|
37
|
+
`;
|
|
38
|
+
return { id, token, prefix };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Verify a presented token. Returns null for anything not currently valid —
|
|
42
|
+
* unknown, revoked, or expired — without saying which.
|
|
43
|
+
*
|
|
44
|
+
* The lookup is by HASH, so a token is found in one indexed comparison rather
|
|
45
|
+
* than by scanning and comparing; `timingSafeEqual` on the row that comes back
|
|
46
|
+
* closes the remaining timing channel.
|
|
47
|
+
*/
|
|
48
|
+
export async function verifyMcpToken(sql, presented, now = new Date()) {
|
|
49
|
+
if (!presented.startsWith(CREDENTIAL_PREFIX.mcp))
|
|
50
|
+
return null;
|
|
51
|
+
const digest = hash(presented);
|
|
52
|
+
const [row] = await sql `
|
|
53
|
+
SELECT id, name, org_id, project_id, scopes, confirm_writes, allow_remote_writes,
|
|
54
|
+
token_hash, revoked_at, expires_at
|
|
55
|
+
FROM mcp_tokens
|
|
56
|
+
WHERE token_hash = ${digest}
|
|
57
|
+
LIMIT 1
|
|
58
|
+
`;
|
|
59
|
+
if (!row)
|
|
60
|
+
return null;
|
|
61
|
+
if (row.token_hash.length !== digest.length || !timingSafeEqual(row.token_hash, digest))
|
|
62
|
+
return null;
|
|
63
|
+
if (row.revoked_at)
|
|
64
|
+
return null;
|
|
65
|
+
if (row.expires_at && row.expires_at <= now)
|
|
66
|
+
return null;
|
|
67
|
+
return {
|
|
68
|
+
tokenId: row.id,
|
|
69
|
+
name: row.name,
|
|
70
|
+
orgId: row.org_id,
|
|
71
|
+
projectId: row.project_id,
|
|
72
|
+
scopes: row.scopes,
|
|
73
|
+
confirmWrites: row.confirm_writes,
|
|
74
|
+
allowRemoteWrites: row.allow_remote_writes,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Record use — `last_used_at`, and the address as a SALTED HASH.
|
|
79
|
+
*
|
|
80
|
+
* 07 F29 reads this column and 00 §14.2b put the salt in migration 0001 so no
|
|
81
|
+
* historical hash predates it. The raw address is never stored anywhere in this
|
|
82
|
+
* system (08 §4.7), and a token audit trail is not the place to make the first
|
|
83
|
+
* exception.
|
|
84
|
+
*
|
|
85
|
+
* Deliberately fire-and-forget at the call site: a failure to write an audit
|
|
86
|
+
* timestamp must not fail the request that succeeded.
|
|
87
|
+
*/
|
|
88
|
+
export async function touchMcpToken(sql, tokenId, ipHash) {
|
|
89
|
+
await sql `
|
|
90
|
+
UPDATE mcp_tokens SET last_used_at = now(), last_used_ip = ${ipHash}
|
|
91
|
+
WHERE id = ${tokenId}
|
|
92
|
+
`;
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=mcpToken.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mcpToken.js","sourceRoot":"","sources":["../../src/routes/mcpToken.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { randomUUID, randomBytes, createHash, timingSafeEqual, scryptSync } from "node:crypto";
|
|
3
|
+
import { DASHBOARD_SESSION_COOKIE, DASHBOARD_CSRF_COOKIE, HTTP_HEADER } from "../vendor/types/names.js";
|
|
4
|
+
/** 05 §4.7: 30-day absolute, 7-day idle, rolling refresh on use. */
|
|
5
|
+
const ABSOLUTE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
6
|
+
const IDLE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
7
|
+
const sha256 = (s) => createHash("sha256").update(s, "utf8").digest();
|
|
8
|
+
function fail(c, status, code, message) {
|
|
9
|
+
return c.json({ error: { code, message, requestId: c.get("requestId") } }, status);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* scrypt, not argon2id, and the deviation is deliberate.
|
|
13
|
+
*
|
|
14
|
+
* 05 §4.7's table says argon2id via `node-argon2`. That is a NATIVE module: it
|
|
15
|
+
* needs a compiler or a prebuilt binary for the exact platform and Node ABI,
|
|
16
|
+
* and A9's promise is one npm package that `npx` runs with no build step. A
|
|
17
|
+
* dependency that fails to install on a machine with no toolchain would make
|
|
18
|
+
* the first-run login unreachable, which is the same failure mode §4.7 rejects
|
|
19
|
+
* magic-link-only auth for.
|
|
20
|
+
*
|
|
21
|
+
* scrypt is memory-hard, in the standard library, and OWASP-parameterised here
|
|
22
|
+
* (N=2^17, r=8, p=1 — their scrypt baseline). The format carries its
|
|
23
|
+
* parameters so an argon2 row can be added later without a migration.
|
|
24
|
+
*/
|
|
25
|
+
const SCRYPT = { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
|
|
26
|
+
export function hashPassword(password) {
|
|
27
|
+
const salt = randomBytes(16);
|
|
28
|
+
const key = scryptSync(password, salt, 64, SCRYPT);
|
|
29
|
+
return `scrypt$${salt.toString("base64url")}$${key.toString("base64url")}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Verify, in constant time, and never leak WHICH part was wrong.
|
|
33
|
+
*
|
|
34
|
+
* Returns false for a user with no password (OAuth-only, §4.7) rather than
|
|
35
|
+
* throwing — but still spends the same work, because an early return on
|
|
36
|
+
* "this account has no password" is an account-enumeration oracle measurable
|
|
37
|
+
* over a few hundred requests.
|
|
38
|
+
*/
|
|
39
|
+
export function verifyPassword(password, stored) {
|
|
40
|
+
const parts = (stored ?? "").split("$");
|
|
41
|
+
const decoy = parts.length === 3 ? null : "scrypt$AAAAAAAAAAAAAAAAAAAAAA$AAAA";
|
|
42
|
+
const [, saltB64, keyB64] = (decoy ? decoy.split("$") : parts);
|
|
43
|
+
const salt = Buffer.from(saltB64, "base64url");
|
|
44
|
+
const want = Buffer.from(keyB64, "base64url");
|
|
45
|
+
const got = scryptSync(password, salt, want.length || 64, SCRYPT);
|
|
46
|
+
if (decoy)
|
|
47
|
+
return false;
|
|
48
|
+
return want.length === got.length && timingSafeEqual(want, got);
|
|
49
|
+
}
|
|
50
|
+
/** Both cookies, set together — they are one credential in two halves. */
|
|
51
|
+
function setSessionCookies(c, token, csrf, secure, maxAgeS) {
|
|
52
|
+
const base = `Path=/; SameSite=Lax; Max-Age=${maxAgeS}${secure ? "; Secure" : ""}`;
|
|
53
|
+
// HttpOnly: the value is the credential and no script has any reason to read it.
|
|
54
|
+
c.header("Set-Cookie", `${DASHBOARD_SESSION_COOKIE}=${token}; HttpOnly; ${base}`, { append: true });
|
|
55
|
+
// NOT HttpOnly, deliberately: double-submit works precisely because the
|
|
56
|
+
// dashboard's own JS reads this one and echoes it in a header, which a
|
|
57
|
+
// cross-site page cannot do — it can send the cookie, but it cannot read it.
|
|
58
|
+
c.header("Set-Cookie", `${DASHBOARD_CSRF_COOKIE}=${csrf}; ${base}`, { append: true });
|
|
59
|
+
}
|
|
60
|
+
function clearSessionCookies(c, secure) {
|
|
61
|
+
const base = `Path=/; SameSite=Lax; Max-Age=0${secure ? "; Secure" : ""}`;
|
|
62
|
+
c.header("Set-Cookie", `${DASHBOARD_SESSION_COOKIE}=; HttpOnly; ${base}`, { append: true });
|
|
63
|
+
c.header("Set-Cookie", `${DASHBOARD_CSRF_COOKIE}=; ${base}`, { append: true });
|
|
64
|
+
}
|
|
65
|
+
function readCookie(c, name) {
|
|
66
|
+
const raw = c.req.header("Cookie");
|
|
67
|
+
if (!raw)
|
|
68
|
+
return null;
|
|
69
|
+
for (const part of raw.split(";")) {
|
|
70
|
+
const i = part.indexOf("=");
|
|
71
|
+
if (i < 0)
|
|
72
|
+
continue;
|
|
73
|
+
if (part.slice(0, i).trim() === name)
|
|
74
|
+
return part.slice(i + 1).trim();
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The middleware. Attaches `owner` when a session verifies; refuses nothing by
|
|
80
|
+
* itself — the routes decide, exactly as `bearerAuth` does on the widget side,
|
|
81
|
+
* so a route that forgot to check is a visible omission rather than an
|
|
82
|
+
* invisible one.
|
|
83
|
+
*/
|
|
84
|
+
export function ownerAuth(deps) {
|
|
85
|
+
const { sql } = deps;
|
|
86
|
+
const now = deps.now ?? (() => new Date());
|
|
87
|
+
return async (c, next) => {
|
|
88
|
+
const token = readCookie(c, DASHBOARD_SESSION_COOKIE);
|
|
89
|
+
if (!token)
|
|
90
|
+
return next();
|
|
91
|
+
const at = now();
|
|
92
|
+
const [row] = await sql `
|
|
93
|
+
SELECT s.id, s.org_id, s.user_id, u.email, u.name, s.last_seen_at
|
|
94
|
+
FROM user_sessions s
|
|
95
|
+
JOIN users u ON u.id = s.user_id AND u.deleted_at IS NULL
|
|
96
|
+
WHERE s.token_hash = ${sha256(token)}
|
|
97
|
+
AND s.revoked_at IS NULL
|
|
98
|
+
AND s.expires_at > ${at}
|
|
99
|
+
AND s.last_seen_at > ${new Date(at.getTime() - IDLE_MS)}
|
|
100
|
+
`;
|
|
101
|
+
if (!row)
|
|
102
|
+
return next();
|
|
103
|
+
c.set("owner", {
|
|
104
|
+
userId: row.user_id,
|
|
105
|
+
orgId: row.org_id,
|
|
106
|
+
email: row.email,
|
|
107
|
+
name: row.name,
|
|
108
|
+
sessionId: row.id,
|
|
109
|
+
});
|
|
110
|
+
// Rolling refresh (§4.7), but not on every request: `last_seen_at` only
|
|
111
|
+
// needs to be accurate to within the idle window, and a write per request
|
|
112
|
+
// turns a read-only dashboard page into a write on the hot path.
|
|
113
|
+
if (at.getTime() - row.last_seen_at.getTime() > 60 * 60 * 1000) {
|
|
114
|
+
void sql `UPDATE user_sessions SET last_seen_at = ${at} WHERE id = ${row.id}`.catch(() => { });
|
|
115
|
+
}
|
|
116
|
+
return next();
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The double-submit compare itself, exported so a route that cannot mount
|
|
121
|
+
* `requireOwner()` wholesale — because it also serves a different credential
|
|
122
|
+
* — can still run the same check inline (`comments.ts`: a reviewer session
|
|
123
|
+
* posts to the same path an owner cookie does, so the route itself has to
|
|
124
|
+
* branch on which credential arrived rather than gating the whole path).
|
|
125
|
+
*
|
|
126
|
+
* `Origin` is NOT re-checked here — that half of `requireOwner()`'s story is
|
|
127
|
+
* about login CSRF specifically (§4.7's comment on `/auth/login`) and every
|
|
128
|
+
* other owner-cookie route relies on `SameSite=Lax` plus this pair.
|
|
129
|
+
*/
|
|
130
|
+
export function csrfOk(c) {
|
|
131
|
+
const sent = c.req.header(HTTP_HEADER.csrf);
|
|
132
|
+
const cookie = readCookie(c, DASHBOARD_CSRF_COOKIE);
|
|
133
|
+
return (!!sent && !!cookie && sent.length === cookie.length && timingSafeEqual(Buffer.from(sent), Buffer.from(cookie)));
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* §4.7's CSRF pair, on mutations only.
|
|
137
|
+
*
|
|
138
|
+
* TWO checks, and `SameSite=Lax` is explicitly not treated as sufficient by the
|
|
139
|
+
* spec: `Origin` must equal the server's own, AND `x-snagset-csrf` must equal
|
|
140
|
+
* the non-HttpOnly cookie. The second is what a cross-site page cannot forge —
|
|
141
|
+
* it can cause the browser to SEND our cookies, but it cannot READ one to echo
|
|
142
|
+
* it back in a header.
|
|
143
|
+
*
|
|
144
|
+
* A safe method is exempt because a GET must not be a mutation in the first
|
|
145
|
+
* place; if one ever is, that is the bug to fix rather than the check to add.
|
|
146
|
+
*/
|
|
147
|
+
export function requireOwner() {
|
|
148
|
+
return async (c, next) => {
|
|
149
|
+
const owner = c.get("owner");
|
|
150
|
+
if (!owner) {
|
|
151
|
+
return c.json({
|
|
152
|
+
error: {
|
|
153
|
+
code: "unauthorized",
|
|
154
|
+
message: "Sign in to the dashboard first.",
|
|
155
|
+
requestId: c.get("requestId"),
|
|
156
|
+
},
|
|
157
|
+
}, 401);
|
|
158
|
+
}
|
|
159
|
+
if (!["GET", "HEAD", "OPTIONS"].includes(c.req.method) && !csrfOk(c)) {
|
|
160
|
+
return c.json({
|
|
161
|
+
error: {
|
|
162
|
+
code: "csrf_failed",
|
|
163
|
+
message: "That request could not be verified. Reload the dashboard and try again.",
|
|
164
|
+
requestId: c.get("requestId"),
|
|
165
|
+
},
|
|
166
|
+
}, 403);
|
|
167
|
+
}
|
|
168
|
+
return next();
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
export function ownerRoutes(deps) {
|
|
172
|
+
const app = new Hono();
|
|
173
|
+
const { sql } = deps;
|
|
174
|
+
const now = deps.now ?? (() => new Date());
|
|
175
|
+
/**
|
|
176
|
+
* Sign in. ONE error for every failure — unknown email, wrong password,
|
|
177
|
+
* deleted user — because the difference between them is exactly the
|
|
178
|
+
* information an account-enumeration attack is after.
|
|
179
|
+
*/
|
|
180
|
+
app.post("/auth/login", async (c) => {
|
|
181
|
+
// The origin check applies here too: a cross-site page cannot read the
|
|
182
|
+
// response, but it CAN log a victim into an account the attacker controls
|
|
183
|
+
// (login CSRF), and every subsequent action the victim takes lands in the
|
|
184
|
+
// attacker's org.
|
|
185
|
+
// The origin must be OURS — but "ours" is not only the configured base URL.
|
|
186
|
+
//
|
|
187
|
+
// The first attempt compared against `SNAGSET_BASE_URL` alone, which
|
|
188
|
+
// defaults to `http://localhost:8080`, and then refused a browser sitting
|
|
189
|
+
// on `http://127.0.0.1:8080` — the same server, reached by the other name
|
|
190
|
+
// for it. An operator does not reach a self-hosted instance by the name a
|
|
191
|
+
// config default guessed; they reach it by an IP, a LAN hostname, or a
|
|
192
|
+
// tunnel, and being told "this instance does not serve that" by the
|
|
193
|
+
// instance currently serving it is a dead end with no next step.
|
|
194
|
+
//
|
|
195
|
+
// So the request's own Host counts too. That is the standard formulation
|
|
196
|
+
// of this check and it keeps the property that matters: a cross-site page
|
|
197
|
+
// cannot set `Origin`, which is the header being tested. Host is
|
|
198
|
+
// attacker-influenced in principle, but a Host that reaches this process
|
|
199
|
+
// is one the deployment already routes here, and spoofing it does not
|
|
200
|
+
// deliver the victim's cookie anywhere new.
|
|
201
|
+
const origin = c.req.header("Origin");
|
|
202
|
+
const host = c.req.header("Host");
|
|
203
|
+
const ours = new Set([deps.origin, host ? `http://${host}` : "", host ? `https://${host}` : ""]);
|
|
204
|
+
if (origin && !ours.has(origin)) {
|
|
205
|
+
return fail(c, 403, "forbidden", "That sign-in request came from another site.");
|
|
206
|
+
}
|
|
207
|
+
const body = (await c.req.json().catch(() => null));
|
|
208
|
+
const email = typeof body?.email === "string" ? body.email.trim().toLowerCase() : "";
|
|
209
|
+
const password = typeof body?.password === "string" ? body.password : "";
|
|
210
|
+
if (!email || !password) {
|
|
211
|
+
return fail(c, 400, "validation_failed", "Both an email address and a password are required.");
|
|
212
|
+
}
|
|
213
|
+
const [user] = await sql `
|
|
214
|
+
SELECT id, org_id, email, name, password_hash
|
|
215
|
+
FROM users WHERE email = ${email} AND deleted_at IS NULL
|
|
216
|
+
`;
|
|
217
|
+
// `verifyPassword` spends the same work on a missing row as on a wrong
|
|
218
|
+
// password, so this branch costs what the real one costs.
|
|
219
|
+
if (!verifyPassword(password, user?.password_hash ?? null)) {
|
|
220
|
+
return fail(c, 401, "invalid_credentials", "That email address and password do not match an account.");
|
|
221
|
+
}
|
|
222
|
+
const token = randomBytes(32).toString("base64url");
|
|
223
|
+
const csrf = randomBytes(32).toString("base64url");
|
|
224
|
+
const expires = new Date(now().getTime() + ABSOLUTE_MS);
|
|
225
|
+
await sql `
|
|
226
|
+
INSERT INTO user_sessions (id, org_id, user_id, token_hash, user_agent, expires_at)
|
|
227
|
+
VALUES (${randomUUID()}, ${user.org_id}, ${user.id}, ${sha256(token)},
|
|
228
|
+
${(c.req.header("User-Agent") ?? "").slice(0, 400)}, ${expires})
|
|
229
|
+
`;
|
|
230
|
+
setSessionCookies(c, token, csrf, deps.secure, Math.floor(ABSOLUTE_MS / 1000));
|
|
231
|
+
// No id, for the reason `/api/v1/me` gives: nothing outside the database
|
|
232
|
+
// needs it, and an internal id put on the wire because it was to hand is
|
|
233
|
+
// how the first one always gets out.
|
|
234
|
+
return c.json({ user: { email: user.email, name: user.name }, csrfToken: csrf });
|
|
235
|
+
});
|
|
236
|
+
/** Sign out. Revokes the ROW, so the cookie being kept changes nothing. */
|
|
237
|
+
app.post("/auth/logout", async (c) => {
|
|
238
|
+
const owner = c.get("owner");
|
|
239
|
+
if (owner)
|
|
240
|
+
await sql `UPDATE user_sessions SET revoked_at = now() WHERE id = ${owner.sessionId}`;
|
|
241
|
+
clearSessionCookies(c, deps.secure);
|
|
242
|
+
return c.json({ ok: true });
|
|
243
|
+
});
|
|
244
|
+
/**
|
|
245
|
+
* Who am I, and what can I see — the dashboard's first call on every load.
|
|
246
|
+
*
|
|
247
|
+
* It returns the projects too, because every dashboard screen needs them and
|
|
248
|
+
* a separate round trip for a list this size is a round trip for nothing.
|
|
249
|
+
*/
|
|
250
|
+
app.get("/api/v1/me", async (c) => {
|
|
251
|
+
const owner = c.get("owner");
|
|
252
|
+
if (!owner)
|
|
253
|
+
return fail(c, 401, "unauthorized", "Sign in to the dashboard first.");
|
|
254
|
+
const [org] = await sql `
|
|
255
|
+
SELECT name, slug FROM orgs WHERE id = ${owner.orgId}
|
|
256
|
+
`;
|
|
257
|
+
const projects = await sql `
|
|
258
|
+
SELECT p.uid, p.name, p.key_prefix, p.write_mode,
|
|
259
|
+
count(t.id)::int AS threads,
|
|
260
|
+
count(t.id) FILTER (WHERE t.status <> 'resolved')::int AS unresolved
|
|
261
|
+
FROM projects p
|
|
262
|
+
LEFT JOIN threads t ON t.project_id = p.id AND t.deleted_at IS NULL AND t.private = false
|
|
263
|
+
WHERE p.org_id = ${owner.orgId} AND p.deleted_at IS NULL
|
|
264
|
+
GROUP BY p.id, p.uid, p.name, p.key_prefix, p.write_mode
|
|
265
|
+
ORDER BY p.created_at
|
|
266
|
+
`;
|
|
267
|
+
return c.json({
|
|
268
|
+
// NO id. Foundations §2.10 mints no `usr_` prefix, and the dashboard has
|
|
269
|
+
// no use for one: it renders a name and an email. AC-1-9 is about
|
|
270
|
+
// project uuids specifically, but the habit is the point — an internal
|
|
271
|
+
// id put on the wire because it was to hand is how the first one always
|
|
272
|
+
// gets out.
|
|
273
|
+
user: { email: owner.email, name: owner.name },
|
|
274
|
+
org: { name: org?.name ?? "", slug: org?.slug ?? "" },
|
|
275
|
+
projects: projects.map((p) => ({
|
|
276
|
+
uid: p.uid,
|
|
277
|
+
name: p.name,
|
|
278
|
+
keyPrefix: p.key_prefix,
|
|
279
|
+
writeMode: p.write_mode,
|
|
280
|
+
threads: p.threads,
|
|
281
|
+
unresolved: p.unresolved,
|
|
282
|
+
})),
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
return app;
|
|
286
|
+
}
|
|
287
|
+
export { setSessionCookies };
|
|
288
|
+
//# sourceMappingURL=owner.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"owner.js","sourceRoot":"","sources":["../../src/routes/owner.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAwC,MAAM,MAAM,CAAC;AAClE,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,UAAU,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE/F,OAAO,EAAE,wBAAwB,EAAE,qBAAqB,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAkDpG,oEAAoE;AACpE,MAAM,WAAW,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;AAC7C,MAAM,OAAO,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;AAExC,MAAM,MAAM,GAAG,CAAC,CAAS,EAAU,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,MAAM,EAAE,CAAC;AAEtF,SAAS,IAAI,CAAC,CAAM,EAAE,MAAc,EAAE,IAAY,EAAE,OAAe;IACjE,OAAO,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAC,GAAG,CAAC,WAAW,CAAC,EAAE,EAAE,EAAE,MAAa,CAAC,CAAC;AAC5F,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,GAAG,EAAE,CAAC,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,MAAM,EAAE,GAAG,GAAG,IAAI,GAAG,IAAI,EAAW,CAAC;AAE9E,MAAM,UAAU,YAAY,CAAC,QAAgB;IAC3C,MAAM,IAAI,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC;IAC7B,MAAM,GAAG,GAAG,UAAU,CAAC,QAAQ,EAAE,IAAI,EAAE,EAAE,EAAE,MAAM,CAAC,CAAC;IACnD,OAAO,UAAU,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,IAAI,GAAG,CAAC,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC;AAC7E,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,cAAc,CAAC,QAAgB,EAAE,MAAqB;IACpE,MAAM,KAAK,GAAG,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACxC,MAAM,KAAK,GAAG,KAAK,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,oCAAoC,CAAC;IAC/E,MAAM,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAA6B,CAAC;IAC3F,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,WAAW,CAAC,CAAC;IAC/C,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IAC9C,MAAM,GAAG,GAAG,UAAU,CAAC,QAAQ,EAAE,IAAI,EAAE,IAAI,CAAC,MAAM,IAAI,EAAE,EAAE,MAAM,CAAC,CAAC;IAClE,IAAI,KAAK;QAAE,OAAO,KAAK,CAAC;IACxB,OAAO,IAAI,CAAC,MAAM,KAAK,GAAG,CAAC,MAAM,IAAI,eAAe,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;AAClE,CAAC;AAED,0EAA0E;AAC1E,SAAS,iBAAiB,CAAC,CAAM,EAAE,KAAa,EAAE,IAAY,EAAE,MAAe,EAAE,OAAe;IAC9F,MAAM,IAAI,GAAG,iCAAiC,OAAO,GAAG,MAAM,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC;IACnF,iFAAiF;IACjF,CAAC,CAAC,MAAM,CAAC,YAAY,EAAE,GAAG,wBAAwB,IAAI,KAAK,eAAe,IAAI,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;IACpG,wEAAwE;IACxE,uEAAuE;IACvE,6EAA6E;IAC7E,CAAC,CAAC,MAAM,CAAC,YAAY,EAAE,GAAG,qBAAqB,IAAI,IAAI,KAAK,IAAI,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;AACxF,CAAC;AAED,SAAS,mBAAmB,CAAC,CAAM,EAAE,MAAe;IAClD,MAAM,IAAI,GAAG,kCAAkC,MAAM,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC;IAC1E,CAAC,CAAC,MAAM,CAAC,YAAY,EAAE,GAAG,wBAAwB,gBAAgB,IAAI,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;IAC5F,CAAC,CAAC,MAAM,CAAC,YAAY,EAAE,GAAG,qBAAqB,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;AACjF,CAAC;AAED,SAAS,UAAU,CAAC,CAAM,EAAE,IAAY;IACtC,MAAM,GAAG,GAAG,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IACnC,IAAI,CAAC,GAAG;QAAE,OAAO,IAAI,CAAC;IACtB,KAAK,MAAM,IAAI,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC;QAClC,MAAM,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QAC5B,IAAI,CAAC,GAAG,CAAC;YAAE,SAAS;QACpB,IAAI,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IACxE,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,SAAS,CAAC,IAAe;IACvC,MAAM,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;IACrB,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC;IAC3C,OAAO,KAAK,EAAE,CAAC,EAAE,IAAI,EAAE,EAAE;QACvB,MAAM,KAAK,GAAG,UAAU,CAAC,CAAQ,EAAE,wBAAwB,CAAC,CAAC;QAC7D,IAAI,CAAC,KAAK;YAAE,OAAO,IAAI,EAAE,CAAC;QAE1B,MAAM,EAAE,GAAG,GAAG,EAAE,CAAC;QACjB,MAAM,CAAC,GAAG,CAAC,GAAG,MAAM,GAAG,CAEtB;;;;6BAIwB,MAAM,CAAC,KAAK,CAAC;;6BAEb,EAAE;+BACA,IAAI,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,OAAO,CAAC;KAC1D,CAAC;QACF,IAAI,CAAC,GAAG;YAAE,OAAO,IAAI,EAAE,CAAC;QAExB,CAAC,CAAC,GAAG,CAAC,OAAO,EAAE;YACb,MAAM,EAAE,GAAG,CAAC,OAAO;YACnB,KAAK,EAAE,GAAG,CAAC,MAAM;YACjB,KAAK,EAAE,GAAG,CAAC,KAAK;YAChB,IAAI,EAAE,GAAG,CAAC,IAAI;YACd,SAAS,EAAE,GAAG,CAAC,EAAE;SAClB,CAAC,CAAC;QAEH,wEAAwE;QACxE,0EAA0E;QAC1E,iEAAiE;QACjE,IAAI,EAAE,CAAC,OAAO,EAAE,GAAG,GAAG,CAAC,YAAY,CAAC,OAAO,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,EAAE,CAAC;YAC/D,KAAK,GAAG,CAAA,2CAA2C,EAAE,eAAe,GAAG,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;QAC/F,CAAC;QACD,OAAO,IAAI,EAAE,CAAC;IAChB,CAAC,CAAC;AACJ,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,MAAM,CAAC,CAAM;IAC3B,MAAM,IAAI,GAAG,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC;IAC5C,MAAM,MAAM,GAAG,UAAU,CAAC,CAAC,EAAE,qBAAqB,CAAC,CAAC;IACpD,OAAO,CACL,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,MAAM,IAAI,IAAI,CAAC,MAAM,KAAK,MAAM,CAAC,MAAM,IAAI,eAAe,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAC/G,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,YAAY;IAC1B,OAAO,KAAK,EAAE,CAAC,EAAE,IAAI,EAAE,EAAE;QACvB,MAAM,KAAK,GAAG,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QAC7B,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,OAAQ,CAAS,CAAC,IAAI,CACpB;gBACE,KAAK,EAAE;oBACL,IAAI,EAAE,cAAc;oBACpB,OAAO,EAAE,iCAAiC;oBAC1C,SAAS,EAAE,CAAC,CAAC,GAAG,CAAC,WAAW,CAAC;iBAC9B;aACF,EACD,GAAG,CACJ,CAAC;QACJ,CAAC;QACD,IAAI,CAAC,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAQ,CAAC,EAAE,CAAC;YAC5E,OAAQ,CAAS,CAAC,IAAI,CACpB;gBACE,KAAK,EAAE;oBACL,IAAI,EAAE,aAAa;oBACnB,OAAO,EAAE,yEAAyE;oBAClF,SAAS,EAAE,CAAC,CAAC,GAAG,CAAC,WAAW,CAAC;iBAC9B;aACF,EACD,GAAG,CACJ,CAAC;QACJ,CAAC;QACD,OAAO,IAAI,EAAE,CAAC;IAChB,CAAC,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,IAAe;IACzC,MAAM,GAAG,GAAG,IAAI,IAAI,EAAO,CAAC;IAC5B,MAAM,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;IACrB,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC;IAE3C;;;;OAIG;IACH,GAAG,CAAC,IAAI,CAAC,aAAa,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE;QAClC,uEAAuE;QACvE,0EAA0E;QAC1E,0EAA0E;QAC1E,kBAAkB;QAClB,4EAA4E;QAC5E,EAAE;QACF,qEAAqE;QACrE,0EAA0E;QAC1E,0EAA0E;QAC1E,0EAA0E;QAC1E,uEAAuE;QACvE,oEAAoE;QACpE,iEAAiE;QACjE,EAAE;QACF,yEAAyE;QACzE,0EAA0E;QAC1E,iEAAiE;QACjE,yEAAyE;QACzE,sEAAsE;QACtE,4CAA4C;QAC5C,MAAM,MAAM,GAAG,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;QACtC,MAAM,IAAI,GAAG,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QAClC,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC,UAAU,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,IAAI,CAAC,CAAC,CAAC,WAAW,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;QACjG,IAAI,MAAM,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;YAChC,OAAO,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,WAAW,EAAE,8CAA8C,CAAC,CAAC;QACnF,CAAC;QAED,MAAM,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAmD,CAAC;QACtG,MAAM,KAAK,GAAG,OAAO,IAAI,EAAE,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACrF,MAAM,QAAQ,GAAG,OAAO,IAAI,EAAE,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC;QACzE,IAAI,CAAC,KAAK,IAAI,CAAC,QAAQ,EAAE,CAAC;YACxB,OAAO,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,mBAAmB,EAAE,oDAAoD,CAAC,CAAC;QACjG,CAAC;QAED,MAAM,CAAC,IAAI,CAAC,GAAG,MAAM,GAAG,CAA6F;;iCAExF,KAAK;KACjC,CAAC;QACF,uEAAuE;QACvE,0DAA0D;QAC1D,IAAI,CAAC,cAAc,CAAC,QAAQ,EAAE,IAAI,EAAE,aAAa,IAAI,IAAI,CAAC,EAAE,CAAC;YAC3D,OAAO,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,qBAAqB,EAAE,0DAA0D,CAAC,CAAC;QACzG,CAAC;QAED,MAAM,KAAK,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;QACpD,MAAM,IAAI,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;QACnD,MAAM,OAAO,GAAG,IAAI,IAAI,CAAC,GAAG,EAAE,CAAC,OAAO,EAAE,GAAG,WAAW,CAAC,CAAC;QACxD,MAAM,GAAG,CAAA;;gBAEG,UAAU,EAAE,KAAK,IAAK,CAAC,MAAM,KAAK,IAAK,CAAC,EAAE,KAAK,MAAM,CAAC,KAAK,CAAC;gBAC5D,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,KAAK,OAAO;KACvE,CAAC;QACF,iBAAiB,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,KAAK,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC,CAAC;QAC/E,yEAAyE;QACzE,yEAAyE;QACzE,qCAAqC;QACrC,OAAO,CAAC,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,IAAK,CAAC,KAAK,EAAE,IAAI,EAAE,IAAK,CAAC,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACrF,CAAC,CAAC,CAAC;IAEH,2EAA2E;IAC3E,GAAG,CAAC,IAAI,CAAC,cAAc,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE;QACnC,MAAM,KAAK,GAAG,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QAC7B,IAAI,KAAK;YAAE,MAAM,GAAG,CAAA,0DAA0D,KAAK,CAAC,SAAS,EAAE,CAAC;QAChG,mBAAmB,CAAC,CAAC,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC;QACpC,OAAO,CAAC,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;IAC9B,CAAC,CAAC,CAAC;IAEH;;;;;OAKG;IACH,GAAG,CAAC,GAAG,CAAC,YAAY,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE;QAChC,MAAM,KAAK,GAAG,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QAC7B,IAAI,CAAC,KAAK;YAAE,OAAO,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,cAAc,EAAE,iCAAiC,CAAC,CAAC;QACnF,MAAM,CAAC,GAAG,CAAC,GAAG,MAAM,GAAG,CAAkC;+CACd,KAAK,CAAC,KAAK;KACrD,CAAC;QACF,MAAM,QAAQ,GAAG,MAAM,GAAG,CAEzB;;;;;;yBAMoB,KAAK,CAAC,KAAK;;;KAG/B,CAAC;QACF,OAAO,CAAC,CAAC,IAAI,CAAC;YACZ,yEAAyE;YACzE,kEAAkE;YAClE,uEAAuE;YACvE,wEAAwE;YACxE,YAAY;YACZ,IAAI,EAAE,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE;YAC9C,GAAG,EAAE,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,IAAI,EAAE,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,IAAI,EAAE,EAAE;YACrD,QAAQ,EAAE,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;gBAC7B,GAAG,EAAE,CAAC,CAAC,GAAG;gBACV,IAAI,EAAE,CAAC,CAAC,IAAI;gBACZ,SAAS,EAAE,CAAC,CAAC,UAAU;gBACvB,SAAS,EAAE,CAAC,CAAC,UAAU;gBACvB,OAAO,EAAE,CAAC,CAAC,OAAO;gBAClB,UAAU,EAAE,CAAC,CAAC,UAAU;aACzB,CAAC,CAAC;SACJ,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,OAAO,GAAG,CAAC;AACb,CAAC;AAED,OAAO,EAAE,iBAAiB,EAAE,CAAC"}
|