@huynle/hub 0.9.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +296 -0
- package/Dockerfile +5 -1
- package/README.md +152 -77
- package/package.json +7 -4
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +532 -261
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/cli.ts +36 -1
- package/packages/cli/src/subscriptions.ts +49 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/sessions.ts +10 -6
- package/packages/codex/src/spoke.ts +2 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/schema/app-delivery/v1.schema.json +7 -5
- package/packages/protocol/schema/app-front/v1.schema.json +410 -0
- package/packages/protocol/src/app-front.ts +293 -0
- package/packages/protocol/src/concierge.ts +10 -1
- package/packages/protocol/src/context-cards.ts +59 -0
- package/packages/protocol/src/credentials.ts +39 -2
- package/packages/protocol/src/index.ts +69 -11
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/app-front.ts +524 -0
- package/packages/sdk/src/client.ts +37 -4
- package/packages/sdk/src/envelope.ts +9 -2
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/types.ts +39 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/delivery.ts +17 -1
- package/packages/spoke-sdk/src/harness-spoke.ts +4 -3
- package/packages/voice-client/README.md +146 -0
- package/packages/voice-client/src/vad/LICENSE +21 -0
- package/src/central/agent/acting-routes.ts +17 -3
- package/src/central/agent/acting.ts +12 -3
- package/src/central/agent/actor.ts +6 -1
- package/src/central/agent/app-decisions.ts +146 -0
- package/src/central/agent/app-tokens.ts +9 -3
- package/src/central/agent/audit.ts +3 -0
- package/src/central/agent/connect-routes.ts +64 -0
- package/src/central/agent/connect.ts +868 -0
- package/src/central/agent/conversation.ts +625 -0
- package/src/central/agent/decisions.ts +71 -6
- package/src/central/agent/delivery.ts +3 -0
- package/src/central/agent/directory.ts +5 -0
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/grant-requests.ts +46 -9
- package/src/central/agent/grants.ts +13 -2
- package/src/central/agent/message-limits.ts +2 -2
- package/src/central/agent/message-routes.ts +13 -4
- package/src/central/agent/read-routes.ts +16 -2
- package/src/central/agent/routes.ts +108 -6
- package/src/central/agent/speech-routes.ts +316 -0
- package/src/central/agent/subscription-routes.ts +174 -0
- package/src/central/agent/subscriptions.ts +980 -0
- package/src/central/agent-data.ts +43 -1
- package/src/central/backend/memory-counters.ts +2 -2
- package/src/central/backend/types.ts +5 -2
- package/src/central/backend/valkey-counters.ts +5 -4
- package/src/central/client-address.ts +16 -0
- package/src/central/concierge/board-render.ts +52 -1
- package/src/central/concierge/board-routes.ts +25 -2
- package/src/central/concierge/board.ts +11 -1
- package/src/central/concierge/context.ts +29 -8
- package/src/central/concierge/grant-request.ts +216 -0
- package/src/central/concierge/prompt.ts +49 -2
- package/src/central/concierge/session-routes.ts +3 -0
- package/src/central/config.ts +64 -3
- package/src/central/context-cards/budget.ts +121 -0
- package/src/central/context-cards/changes.ts +29 -0
- package/src/central/context-cards/config.ts +145 -0
- package/src/central/context-cards/engine.ts +379 -0
- package/src/central/context-cards/inputs.ts +111 -0
- package/src/central/context-cards/pickup.ts +134 -0
- package/src/central/context-cards/registry.ts +89 -0
- package/src/central/context-cards/routes.ts +37 -0
- package/src/central/context-cards/service.ts +367 -0
- package/src/central/context-cards/store.ts +67 -0
- package/src/central/context-cards/types.ts +88 -0
- package/src/central/fetcher.ts +10 -2
- package/src/central/front-relays.ts +423 -0
- package/src/central/http.ts +516 -56
- package/src/central/human-activity.ts +453 -0
- package/src/central/llm/chat.ts +133 -0
- package/src/central/registry.ts +162 -9
- package/src/central/speech/service.ts +9 -0
- package/src/central/spoke-ws.ts +13 -0
- package/src/central/store.ts +9 -12
- package/src/config.ts +30 -13
- package/src/index.ts +45 -43
- package/src/rpc.ts +33 -4
- package/src/spoke/concierge-agent.ts +1 -1
- package/src/spoke/conversation.ts +6 -75
- package/src/spoke/deliver.ts +18 -8
- package/src/spoke/hub-rpc.ts +7 -2
- package/src/spoke/hub-tool-subscriptions.ts +82 -0
- package/src/spoke/hub-tool-title.ts +89 -0
- package/src/spoke/hub-tool.ts +56 -18
- package/src/spoke/session-context.ts +10 -2
- package/src/spoke/spoke.ts +209 -238
- package/src/spoke/summary.ts +36 -1
- package/src/spoke/taskboard.ts +5 -6
- package/src/spoke/voice-front.ts +323 -30
- package/src/spoke/who-teams.ts +51 -2
- package/src/tui-messaging.ts +24 -10
- package/src/tui.ts +6 -6
- package/web/dist/assets/{ArtifactPage-BKISN-Qj.js → ArtifactPage-Bz992ZX6.js} +1 -1
- package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-BJZNixJC.js} +1 -1
- package/web/dist/assets/ArtifactWorkbench-Dti3VP07.js +6 -0
- package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
- package/web/dist/assets/Diagram-B4HdUxw0.js +1 -0
- package/web/dist/assets/{Library-CcjZXsfF.js → Library-CpzD2HDW.js} +2 -2
- package/web/dist/assets/Lightbox-BlbivCkd.js +1 -0
- package/web/dist/assets/MediaImage-Ch3ryIdR.js +4 -0
- package/web/dist/assets/ProfilesDestination-DlQeogea.js +1 -0
- package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-4xdWCaI-.js} +1 -1
- package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-CpQdIRns.js} +2 -2
- package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
- package/web/dist/assets/index-BC0O1gfR.css +1 -0
- package/web/dist/assets/index-BS6lA6fN.js +33 -0
- package/web/dist/assets/{live-ycZ_aQZO.js → live-CVUFIdUX.js} +2 -2
- package/web/dist/assets/{pane-layout-0RrLHGDr.js → pane-layout-BW0RdX5a.js} +1 -1
- package/web/dist/assets/{renderer2d-BIwbgRoX.js → renderer2d-IaJiloPi.js} +1 -1
- package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-ZiD1uKGW.js} +1 -1
- package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-C7IAlkgY.js} +1 -1
- package/web/dist/assets/{source-BWlvaODa.js → source-C-De8L3s.js} +1 -1
- package/web/dist/assets/toast-CXTwTvDw.js +9 -0
- package/web/dist/assets/vad-worklet-BfxJhrXy.js +27 -0
- package/web/dist/index.html +6 -6
- package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +0 -6
- package/web/dist/assets/Diagram-azw2B_QH.js +0 -1
- package/web/dist/assets/Lightbox-Du56MXGX.js +0 -1
- package/web/dist/assets/MediaImage-Ds_JUx3a.js +0 -4
- package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +0 -1
- package/web/dist/assets/index-C3ci5UtY.css +0 -1
- package/web/dist/assets/index-LNMZCqgE.js +0 -36
- package/web/dist/assets/toast-Cuu2K0hx.js +0 -1
- /package/{web/src/voice → packages/voice-client/src}/turn/LICENSE +0 -0
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
// Connect an app (AF1, docs/plans/2026-11-08-app-front-contracts.md §3; decisions w6lfbfaq, zn7oxf4g, nixct956): an
|
|
2
|
+
// OAuth 2.1-style authorization code + PKCE flow for registered, confidential app clients.
|
|
3
|
+
//
|
|
4
|
+
// - Clients live on the registering user's agent data (`appClients`, the secret's sha256 only): audience "hub"
|
|
5
|
+
// (an admin's, every user may connect) or "user" (personal: only its owner sees and uses it). IDs are hub-unique.
|
|
6
|
+
// - GET /connect/authorize opens a pending flow (hcf_, 10 min) in the shared Kv, then the consent page approves or
|
|
7
|
+
// denies it as the signed-in user (cookie only); approve mints a one-time code (hco_, 60 s, stored by hash).
|
|
8
|
+
// - POST /connect/token (form-encoded, client_secret_basic|post + PKCE S256) turns a code into a connection on the
|
|
9
|
+
// user's agent data (`connections`, hashes only) with an access token (hca_<connection>_, 1 h) and a rotating
|
|
10
|
+
// refresh token (hcr_<connection>_, 30 days sliding). The just-rotated refresh token answers the same new pair for
|
|
11
|
+
// 30 s (sealed with a key derived from it, never stored in clear); any other reuse revokes the connection, as does
|
|
12
|
+
// a second use of a code.
|
|
13
|
+
// - POST /connect/revoke (RFC 7009) ends the connection; so does the user (Settings) or a client change. Ending one
|
|
14
|
+
// calls `ended` (the hub deletes the app's grants and webhooks).
|
|
15
|
+
// - The access-token verifier resolves to the app actor whose tokenID is the connection ID (stable across refresh).
|
|
16
|
+
// Codes, flows, secrets and tokens are never logged or audited; only IDs and scope names are.
|
|
17
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes, timingSafeEqual } from "node:crypto"
|
|
18
|
+
import {
|
|
19
|
+
ACCESS_TOKEN_TTL_MS,
|
|
20
|
+
AUTHORIZATION_CODE_TTL_MS,
|
|
21
|
+
CLIENT_AUDIENCES,
|
|
22
|
+
type ClientAudience,
|
|
23
|
+
CONNECT_FLOW_TTL_MS,
|
|
24
|
+
CONNECT_LIMITS,
|
|
25
|
+
CONNECT_PATHS,
|
|
26
|
+
CONNECT_TOKEN_PATTERNS,
|
|
27
|
+
parseConnectToken,
|
|
28
|
+
parseScopeParam,
|
|
29
|
+
pkceChallengeProblem,
|
|
30
|
+
pkceVerifierProblem,
|
|
31
|
+
REFRESH_GRACE_MS,
|
|
32
|
+
REFRESH_TOKEN_IDLE_MS,
|
|
33
|
+
redirectUriMatches,
|
|
34
|
+
redirectUriProblem,
|
|
35
|
+
TOKEN_ERROR_STATUS,
|
|
36
|
+
type TokenError,
|
|
37
|
+
} from "../../protocol"
|
|
38
|
+
import type { AppClientRecord, ConnectionRecord } from "../agent-data"
|
|
39
|
+
import type { Counters, Kv } from "../backend/types"
|
|
40
|
+
import { hashToken, type Store, type UserRecord } from "../store"
|
|
41
|
+
import type { AppActor, BearerVerifier, VerifyResult } from "./actor"
|
|
42
|
+
import { boundedText } from "./body"
|
|
43
|
+
import { AgentError } from "./errors"
|
|
44
|
+
|
|
45
|
+
/** Longest form body the token and revoke endpoints read. */
|
|
46
|
+
const FORM_MAX = 16 * 1024
|
|
47
|
+
/** How long a spent code remembers the connection it issued (a second use revokes it). */
|
|
48
|
+
const CODE_USED_TTL_MS = 10 * 60_000
|
|
49
|
+
const RATE_WINDOW_MS = 60_000
|
|
50
|
+
const NO_STORE = { "cache-control": "no-store" }
|
|
51
|
+
|
|
52
|
+
const KEYS = {
|
|
53
|
+
flow: (id: string) => `connect:flow:${id}`,
|
|
54
|
+
code: (hash: string) => `connect:code:${hash}`,
|
|
55
|
+
codeUsed: (hash: string) => `connect:code-used:${hash}`,
|
|
56
|
+
authorizeRate: (clientID: string, address: string) => `connect:authorize-rate:${clientID}:${address}`,
|
|
57
|
+
tokenRate: (clientID: string) => `connect:token-rate:${clientID}`,
|
|
58
|
+
authFailures: (clientID: string, address: string) => `connect:auth-failures:${clientID}:${address}`,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface Flow {
|
|
62
|
+
readonly id: string
|
|
63
|
+
readonly clientID: string
|
|
64
|
+
readonly redirectURI: string
|
|
65
|
+
readonly scopes: readonly string[]
|
|
66
|
+
readonly state: string
|
|
67
|
+
readonly challenge: string
|
|
68
|
+
readonly created: number
|
|
69
|
+
readonly expiresAt: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface Code {
|
|
73
|
+
readonly clientID: string
|
|
74
|
+
readonly redirectURI: string
|
|
75
|
+
readonly challenge: string
|
|
76
|
+
readonly userID: string
|
|
77
|
+
readonly scopes: readonly string[]
|
|
78
|
+
readonly expiresAt: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A client as Settings shows it (never the secret or its hash). */
|
|
82
|
+
export interface AppClientView {
|
|
83
|
+
readonly id: string
|
|
84
|
+
readonly name: string
|
|
85
|
+
readonly redirectURIs: readonly string[]
|
|
86
|
+
readonly scopes: readonly string[]
|
|
87
|
+
readonly created: number
|
|
88
|
+
readonly createdBy: string
|
|
89
|
+
readonly audience: ClientAudience
|
|
90
|
+
readonly disabled?: true
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ConnectionView {
|
|
94
|
+
readonly id: string
|
|
95
|
+
readonly clientID: string
|
|
96
|
+
readonly clientName: string
|
|
97
|
+
readonly scopes: readonly string[]
|
|
98
|
+
readonly created: number
|
|
99
|
+
readonly lastUsed?: number
|
|
100
|
+
readonly refreshExpiresAt: number
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ConnectFlowView {
|
|
104
|
+
readonly id: string
|
|
105
|
+
readonly client: { readonly id: string; readonly name: string; readonly redirectOrigin: string }
|
|
106
|
+
readonly scopes: readonly string[]
|
|
107
|
+
readonly expiresAt: number
|
|
108
|
+
readonly connection?: { readonly id: string; readonly scopes: readonly string[] }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface AppConnectDeps {
|
|
112
|
+
readonly store: Store
|
|
113
|
+
readonly kv: Kv
|
|
114
|
+
readonly counters: Counters
|
|
115
|
+
/** The hub's public URL: the issuer. */
|
|
116
|
+
publicURL(): string
|
|
117
|
+
/** What a client may ask for (APP_SCOPES). */
|
|
118
|
+
readonly scopes: readonly string[]
|
|
119
|
+
readonly now?: () => number
|
|
120
|
+
/** A connection ended (revoked, reused, lapsed, its client changed): the hub deletes its grants and webhooks. */
|
|
121
|
+
ended?(userID: string, connectionID: string): Promise<void>
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface Found {
|
|
125
|
+
readonly owner: UserRecord
|
|
126
|
+
readonly record: AppClientRecord
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The answer of the token endpoint (RFC 6749 §5.1). */
|
|
130
|
+
interface TokenBody {
|
|
131
|
+
readonly access_token: string
|
|
132
|
+
readonly token_type: "Bearer"
|
|
133
|
+
readonly expires_in: number
|
|
134
|
+
readonly refresh_token: string
|
|
135
|
+
readonly scope: string
|
|
136
|
+
readonly connection_id: string
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
class OAuthError extends Error {
|
|
140
|
+
constructor(
|
|
141
|
+
readonly error: TokenError,
|
|
142
|
+
message: string,
|
|
143
|
+
readonly headers: Record<string, string> = {},
|
|
144
|
+
) {
|
|
145
|
+
super(message)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const sha256 = (value: string) => createHash("sha256").update(value).digest()
|
|
150
|
+
/** Constant-time comparison of two hex digests. */
|
|
151
|
+
function sameHash(a: string | undefined, b: string): boolean {
|
|
152
|
+
if (!a) return false
|
|
153
|
+
const x = Buffer.from(a, "hex")
|
|
154
|
+
const y = Buffer.from(b, "hex")
|
|
155
|
+
return x.length === y.length && x.length > 0 && timingSafeEqual(x, y)
|
|
156
|
+
}
|
|
157
|
+
const secret = () => randomBytes(32).toString("base64url")
|
|
158
|
+
const strings = (value: unknown): string[] => (Array.isArray(value) ? value.filter((s): s is string => typeof s === "string") : [])
|
|
159
|
+
const audienceOf = (record: AppClientRecord): ClientAudience => (record.audience === "user" ? "user" : "hub")
|
|
160
|
+
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
161
|
+
|
|
162
|
+
/** The new pair, sealed for the refresh grace with a key only the presenter of the old refresh token can derive. */
|
|
163
|
+
function seal(body: TokenBody, oldRefresh: string): string {
|
|
164
|
+
const iv = randomBytes(12)
|
|
165
|
+
const cipher = createCipheriv("aes-256-gcm", sha256(`hub-connect-grace\n${oldRefresh}`), iv)
|
|
166
|
+
const data = Buffer.concat([cipher.update(JSON.stringify(body), "utf8"), cipher.final()])
|
|
167
|
+
return Buffer.concat([iv, cipher.getAuthTag(), data]).toString("base64url")
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function unseal(sealed: string, oldRefresh: string): TokenBody | undefined {
|
|
171
|
+
try {
|
|
172
|
+
const raw = Buffer.from(sealed, "base64url")
|
|
173
|
+
const decipher = createDecipheriv("aes-256-gcm", sha256(`hub-connect-grace\n${oldRefresh}`), raw.subarray(0, 12))
|
|
174
|
+
decipher.setAuthTag(raw.subarray(12, 28))
|
|
175
|
+
return JSON.parse(Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString("utf8")) as TokenBody
|
|
176
|
+
} catch {
|
|
177
|
+
return undefined
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const escapeHTML = (text: string) => text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)
|
|
182
|
+
|
|
183
|
+
/** The authorize endpoint's error page (never a redirect: no open redirector); it cannot be framed. */
|
|
184
|
+
function errorPage(message: string): Response {
|
|
185
|
+
const html = `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Cannot connect</title><h1>Cannot connect this app</h1><p>${escapeHTML(message)}</p><p>Start again from the app, or ask whoever runs it to check its Hub registration.</p></html>`
|
|
186
|
+
return new Response(html, {
|
|
187
|
+
status: 400,
|
|
188
|
+
headers: { "content-type": "text/html; charset=utf-8", "content-security-policy": "default-src 'none'; frame-ancestors 'none'", "x-frame-options": "DENY", ...NO_STORE },
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function oauthResponse(cause: OAuthError): Response {
|
|
193
|
+
return Response.json({ error: cause.error, error_description: cause.message.slice(0, 200) }, { status: TOKEN_ERROR_STATUS[cause.error], headers: { ...NO_STORE, pragma: "no-cache", ...cause.headers } })
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export class AppConnect {
|
|
197
|
+
private readonly now: () => number
|
|
198
|
+
|
|
199
|
+
constructor(private readonly deps: AppConnectDeps) {
|
|
200
|
+
this.now = deps.now ?? Date.now
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------- metadata ----------
|
|
204
|
+
|
|
205
|
+
/** RFC 8414 authorization server metadata. */
|
|
206
|
+
metadata(): Response {
|
|
207
|
+
const issuer = this.deps.publicURL()
|
|
208
|
+
return Response.json(
|
|
209
|
+
{
|
|
210
|
+
issuer,
|
|
211
|
+
authorization_endpoint: `${issuer}${CONNECT_PATHS.authorize}`,
|
|
212
|
+
token_endpoint: `${issuer}${CONNECT_PATHS.token}`,
|
|
213
|
+
revocation_endpoint: `${issuer}${CONNECT_PATHS.revoke}`,
|
|
214
|
+
response_types_supported: ["code"],
|
|
215
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
216
|
+
code_challenge_methods_supported: ["S256"],
|
|
217
|
+
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
|
|
218
|
+
scopes_supported: [...this.deps.scopes],
|
|
219
|
+
authorization_response_iss_parameter_supported: true,
|
|
220
|
+
},
|
|
221
|
+
{ headers: { "cache-control": "public, max-age=60" } },
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------- clients ----------
|
|
226
|
+
|
|
227
|
+
/** The client of an ID, wherever it is stored. */
|
|
228
|
+
private find(id: string): Found | undefined {
|
|
229
|
+
if (!CONNECT_TOKEN_PATTERNS.clientID.test(id)) return undefined
|
|
230
|
+
for (const owner of this.deps.store.users()) {
|
|
231
|
+
const record = owner.agent?.appClients?.find((c) => c.id === id)
|
|
232
|
+
if (record) return { owner, record }
|
|
233
|
+
}
|
|
234
|
+
return undefined
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** A client that may connect now: enabled, and a personal one's owner active. */
|
|
238
|
+
private usable(id: string): Found | undefined {
|
|
239
|
+
const found = this.find(id)
|
|
240
|
+
if (!found || found.record.disabled) return undefined
|
|
241
|
+
if (audienceOf(found.record) === "user" && found.owner.disabled) return undefined
|
|
242
|
+
return found
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Whether `user` may see (and connect to) the client: hub-wide ones, and their own personal ones. */
|
|
246
|
+
private visible(found: Found, userID: string): boolean {
|
|
247
|
+
return audienceOf(found.record) === "hub" || found.owner.id === userID
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private allClients(): Found[] {
|
|
251
|
+
return this.deps.store.users().flatMap((owner) => (owner.agent?.appClients ?? []).map((record) => ({ owner, record })))
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private clientView({ owner, record }: Found): AppClientView {
|
|
255
|
+
return {
|
|
256
|
+
id: record.id,
|
|
257
|
+
name: typeof record.name === "string" ? record.name : record.id,
|
|
258
|
+
redirectURIs: strings(record.redirectURIs),
|
|
259
|
+
scopes: strings(record.scopes),
|
|
260
|
+
created: typeof record.created === "number" ? record.created : 0,
|
|
261
|
+
createdBy: owner.id,
|
|
262
|
+
audience: audienceOf(record),
|
|
263
|
+
...(record.disabled ? { disabled: true as const } : {}),
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The clients `user` sees: hub-wide ones and their own personal ones, newest first. */
|
|
268
|
+
clients(user: UserRecord): AppClientView[] {
|
|
269
|
+
return this.allClients()
|
|
270
|
+
.filter((found) => this.visible(found, user.id))
|
|
271
|
+
.map((found) => this.clientView(found))
|
|
272
|
+
.sort((a, b) => b.created - a.created || (a.id < b.id ? -1 : 1))
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** A client `user` may change: hub-wide ones for admins (forbidden for others), personal ones for their owner only. */
|
|
276
|
+
private manageable(user: UserRecord, id: string): Found {
|
|
277
|
+
const found = this.find(id)
|
|
278
|
+
if (!found || !this.visible(found, user.id)) throw new AgentError("not_found", "No such app client")
|
|
279
|
+
if (audienceOf(found.record) === "hub" && user.role !== "admin") throw new AgentError("forbidden", "Only an admin changes a hub-wide app client")
|
|
280
|
+
return found
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private newID(prefix: string, taken: (id: string) => boolean): string {
|
|
284
|
+
for (;;) {
|
|
285
|
+
const id = `${prefix}${randomBytes(8).toString("hex")}`
|
|
286
|
+
if (!taken(id)) return id
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async register(user: UserRecord, body: unknown): Promise<{ client: AppClientView; secret: string }> {
|
|
291
|
+
const input = this.clientInput(body, false)
|
|
292
|
+
const audience = input.audience ?? (user.role === "admin" ? "hub" : "user")
|
|
293
|
+
if (audience === "hub" && user.role !== "admin") throw new AgentError("forbidden", "Only an admin registers a hub-wide app client", { hint: 'Register it for your own account (audience "user")' })
|
|
294
|
+
if (audience === "hub" && this.allClients().filter((c) => audienceOf(c.record) === "hub").length >= CONNECT_LIMITS.clients) {
|
|
295
|
+
throw new AgentError("rate_limited", `At most ${CONNECT_LIMITS.clients} hub-wide app clients`, { hint: "Delete unused clients" })
|
|
296
|
+
}
|
|
297
|
+
if (audience === "user" && (user.agent?.appClients ?? this.deps.store.agentData(user.id).appClients ?? []).filter((c) => audienceOf(c) === "user").length >= CONNECT_LIMITS.personalClientsPerUser) {
|
|
298
|
+
throw new AgentError("rate_limited", `At most ${CONNECT_LIMITS.personalClientsPerUser} personal app clients per user`, { hint: "Delete unused clients" })
|
|
299
|
+
}
|
|
300
|
+
const id = this.newID("hcl_", (candidate) => this.find(candidate) !== undefined)
|
|
301
|
+
const plain = `hcs_${secret()}`
|
|
302
|
+
const record: AppClientRecord = { id, secretHash: hashToken(plain), name: input.name!, redirectURIs: [...input.redirectURIs!], scopes: [...input.scopes!], audience, created: this.now() }
|
|
303
|
+
await this.deps.store.updateAgentData(user.id, (data) => {
|
|
304
|
+
data.appClients = [...(data.appClients ?? []).filter((c) => c.id !== id), record]
|
|
305
|
+
})
|
|
306
|
+
await this.deps.store.audit({ actor: user.id, action: "app-client.create", target: id, detail: audience })
|
|
307
|
+
return { client: this.clientView({ owner: this.deps.store.user(user.id) ?? user, record }), secret: plain }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async update(user: UserRecord, id: string, body: unknown): Promise<AppClientView> {
|
|
311
|
+
const found = this.manageable(user, id)
|
|
312
|
+
const input = this.clientInput(body, true)
|
|
313
|
+
let next: AppClientRecord | undefined
|
|
314
|
+
await this.deps.store.updateAgentData(found.owner.id, (data) => {
|
|
315
|
+
next = undefined
|
|
316
|
+
data.appClients = (data.appClients ?? []).map((c) => {
|
|
317
|
+
if (c.id !== id) return c
|
|
318
|
+
const { disabled: _drop, ...rest } = c
|
|
319
|
+
next = {
|
|
320
|
+
...rest,
|
|
321
|
+
...(input.name !== undefined ? { name: input.name } : {}),
|
|
322
|
+
...(input.redirectURIs ? { redirectURIs: [...input.redirectURIs] } : {}),
|
|
323
|
+
...(input.scopes ? { scopes: [...input.scopes] } : {}),
|
|
324
|
+
...((input.disabled ?? c.disabled === true) ? { disabled: true as const } : {}),
|
|
325
|
+
updated: this.now(),
|
|
326
|
+
}
|
|
327
|
+
return next
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
if (!next) throw new AgentError("not_found", "No such app client")
|
|
331
|
+
await this.deps.store.audit({ actor: user.id, action: "app-client.update", target: id, ...(input.disabled !== undefined ? { detail: input.disabled ? "disabled" : "enabled" } : {}) })
|
|
332
|
+
if (next.disabled) await this.endClient(id, user.id, "client disabled")
|
|
333
|
+
else if (input.scopes) await this.narrowClient(id, input.scopes, user.id)
|
|
334
|
+
return this.clientView({ owner: found.owner, record: next })
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async rotate(user: UserRecord, id: string): Promise<{ client: AppClientView; secret: string }> {
|
|
338
|
+
const found = this.manageable(user, id)
|
|
339
|
+
const plain = `hcs_${secret()}`
|
|
340
|
+
let next: AppClientRecord | undefined
|
|
341
|
+
await this.deps.store.updateAgentData(found.owner.id, (data) => {
|
|
342
|
+
next = undefined
|
|
343
|
+
data.appClients = (data.appClients ?? []).map((c) => (c.id === id ? (next = { ...c, secretHash: hashToken(plain), updated: this.now() }) : c))
|
|
344
|
+
})
|
|
345
|
+
if (!next) throw new AgentError("not_found", "No such app client")
|
|
346
|
+
await this.deps.store.audit({ actor: user.id, action: "app-client.secret", target: id })
|
|
347
|
+
return { client: this.clientView({ owner: found.owner, record: next }), secret: plain }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async remove(user: UserRecord, id: string): Promise<void> {
|
|
351
|
+
const found = this.manageable(user, id)
|
|
352
|
+
let removed = false
|
|
353
|
+
await this.deps.store.updateAgentData(found.owner.id, (data) => {
|
|
354
|
+
const before = data.appClients ?? []
|
|
355
|
+
removed = before.some((c) => c.id === id)
|
|
356
|
+
data.appClients = before.filter((c) => c.id !== id)
|
|
357
|
+
})
|
|
358
|
+
if (!removed) throw new AgentError("not_found", "No such app client")
|
|
359
|
+
await this.deps.store.audit({ actor: user.id, action: "app-client.delete", target: id })
|
|
360
|
+
await this.endClient(id, user.id, "client deleted")
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Register/PATCH bodies: name 1–100, redirect URIs (redirectUriProblem), known scopes, audience; PATCH also disabled. */
|
|
364
|
+
private clientInput(body: unknown, partial: boolean): { name?: string; redirectURIs?: string[]; scopes?: string[]; audience?: ClientAudience; disabled?: boolean } {
|
|
365
|
+
if (!isObject(body)) throw new AgentError("invalid", "Body must be a JSON object")
|
|
366
|
+
const allowed = partial ? ["name", "redirectURIs", "scopes", "disabled"] : ["name", "redirectURIs", "scopes", "audience"]
|
|
367
|
+
const extra = Object.keys(body).find((key) => !allowed.includes(key))
|
|
368
|
+
if (extra !== undefined) throw new AgentError("invalid", `Unknown field: ${extra.slice(0, 40)}`)
|
|
369
|
+
if (partial && Object.keys(body).length === 0) throw new AgentError("invalid", "Nothing to change")
|
|
370
|
+
const out: { name?: string; redirectURIs?: string[]; scopes?: string[]; audience?: ClientAudience; disabled?: boolean } = {}
|
|
371
|
+
if (body.name !== undefined || !partial) {
|
|
372
|
+
const name = typeof body.name === "string" ? body.name.trim().replace(/\s+/g, " ") : ""
|
|
373
|
+
if (!name || name.length > CONNECT_LIMITS.clientNameMax) throw new AgentError("invalid", `name is 1–${CONNECT_LIMITS.clientNameMax} characters`)
|
|
374
|
+
out.name = name
|
|
375
|
+
}
|
|
376
|
+
if (body.redirectURIs !== undefined || !partial) {
|
|
377
|
+
const uris = body.redirectURIs
|
|
378
|
+
if (!Array.isArray(uris) || uris.length === 0 || uris.length > CONNECT_LIMITS.redirectURIsPerClient) throw new AgentError("invalid", `redirectURIs lists 1–${CONNECT_LIMITS.redirectURIsPerClient} URIs`)
|
|
379
|
+
for (const uri of uris) {
|
|
380
|
+
const problem = redirectUriProblem(uri)
|
|
381
|
+
if (problem) throw new AgentError("invalid", `redirectURIs: ${problem}`)
|
|
382
|
+
}
|
|
383
|
+
out.redirectURIs = [...new Set(uris as string[])]
|
|
384
|
+
}
|
|
385
|
+
if (body.scopes !== undefined || !partial) {
|
|
386
|
+
const scopes = body.scopes
|
|
387
|
+
if (!Array.isArray(scopes) || scopes.length === 0 || !scopes.every((s): s is string => typeof s === "string")) throw new AgentError("invalid", "scopes lists at least one scope")
|
|
388
|
+
const unknown = scopes.find((s) => !this.deps.scopes.includes(s))
|
|
389
|
+
if (unknown !== undefined) throw new AgentError("invalid", `Unknown scope: ${unknown.slice(0, 64)}`, { extra: { scopes: this.deps.scopes } })
|
|
390
|
+
out.scopes = [...new Set(scopes)]
|
|
391
|
+
}
|
|
392
|
+
if (body.audience !== undefined) {
|
|
393
|
+
if (!CLIENT_AUDIENCES.includes(body.audience as ClientAudience)) throw new AgentError("invalid", 'audience is "hub" or "user"')
|
|
394
|
+
out.audience = body.audience as ClientAudience
|
|
395
|
+
}
|
|
396
|
+
if (body.disabled !== undefined) {
|
|
397
|
+
if (typeof body.disabled !== "boolean") throw new AgentError("invalid", "disabled is true or false")
|
|
398
|
+
out.disabled = body.disabled
|
|
399
|
+
}
|
|
400
|
+
return out
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ---------- connections ----------
|
|
404
|
+
|
|
405
|
+
/** The connection of an ID and its user, wherever it is stored. */
|
|
406
|
+
private connectionOf(id: string): { user: UserRecord; connection: ConnectionRecord } | undefined {
|
|
407
|
+
for (const user of this.deps.store.users()) {
|
|
408
|
+
const connection = user.agent?.connections?.find((c) => c.id === id)
|
|
409
|
+
if (connection) return { user, connection }
|
|
410
|
+
}
|
|
411
|
+
return undefined
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Whether the user's connection exists now (grants and webhooks of app:<id>). Synchronous: the Store's cache. */
|
|
415
|
+
alive(userID: string, id: string): boolean {
|
|
416
|
+
return (this.deps.store.agentData(userID).connections ?? []).some((c) => c.id === id)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** The scopes a connection has now (its own, within its client's ceiling). */
|
|
420
|
+
scopesOf(userID: string, id: string): string[] | undefined {
|
|
421
|
+
const connection = (this.deps.store.agentData(userID).connections ?? []).find((c) => c.id === id)
|
|
422
|
+
if (!connection) return undefined
|
|
423
|
+
const ceiling = strings(this.find(connection.clientID)?.record.scopes)
|
|
424
|
+
return strings(connection.scopes).filter((s) => ceiling.includes(s))
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** The user's connections (lapsed ones left out), newest first. */
|
|
428
|
+
connections(userID: string): ConnectionView[] {
|
|
429
|
+
const at = this.now()
|
|
430
|
+
return (this.deps.store.agentData(userID).connections ?? [])
|
|
431
|
+
.filter((c) => (c.refreshExpiresAt ?? 0) >= at)
|
|
432
|
+
.map((c) => {
|
|
433
|
+
const client = this.find(c.clientID)
|
|
434
|
+
return {
|
|
435
|
+
id: c.id,
|
|
436
|
+
clientID: c.clientID,
|
|
437
|
+
clientName: typeof client?.record.name === "string" ? client.record.name : c.clientID,
|
|
438
|
+
scopes: this.scopesOf(userID, c.id) ?? [],
|
|
439
|
+
created: c.created ?? 0,
|
|
440
|
+
...(typeof c.lastUsed === "number" ? { lastUsed: c.lastUsed } : {}),
|
|
441
|
+
refreshExpiresAt: c.refreshExpiresAt ?? 0,
|
|
442
|
+
}
|
|
443
|
+
})
|
|
444
|
+
.sort((a, b) => b.created - a.created || (a.id < b.id ? -1 : 1))
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Ends a connection (both tokens at once) and tells the hub (grants, webhooks); false when it did not exist.
|
|
449
|
+
* `by` is the audit actor; `action` connect.revoke or connect.reuse.
|
|
450
|
+
*/
|
|
451
|
+
async end(userID: string, id: string, options: { readonly by: string; readonly why: string; readonly action?: "connect.revoke" | "connect.reuse" }): Promise<boolean> {
|
|
452
|
+
let removed = false
|
|
453
|
+
await this.deps.store.updateAgentData(userID, (data) => {
|
|
454
|
+
const before = data.connections ?? []
|
|
455
|
+
removed = before.some((c) => c.id === id)
|
|
456
|
+
if (removed) data.connections = before.filter((c) => c.id !== id)
|
|
457
|
+
})
|
|
458
|
+
if (!removed) return false
|
|
459
|
+
await this.deps.store.audit({ actor: options.by, action: options.action ?? "connect.revoke", target: id, detail: options.why })
|
|
460
|
+
try {
|
|
461
|
+
await this.deps.ended?.(userID, id)
|
|
462
|
+
} catch (cause) {
|
|
463
|
+
console.error(`[hub] cleaning up connection ${id} failed: ${(cause as Error).message}`)
|
|
464
|
+
}
|
|
465
|
+
return true
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private async endClient(clientID: string, by: string, why: string): Promise<void> {
|
|
469
|
+
for (const user of this.deps.store.users()) {
|
|
470
|
+
for (const c of user.agent?.connections ?? []) if (c.clientID === clientID) await this.end(user.id, c.id, { by, why })
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** A client's ceiling narrowed: every connection keeps only what is still allowed (none left: it ends). */
|
|
475
|
+
private async narrowClient(clientID: string, ceiling: readonly string[], by: string): Promise<void> {
|
|
476
|
+
for (const user of this.deps.store.users()) {
|
|
477
|
+
for (const c of user.agent?.connections ?? []) {
|
|
478
|
+
if (c.clientID !== clientID) continue
|
|
479
|
+
const kept = strings(c.scopes).filter((s) => ceiling.includes(s))
|
|
480
|
+
if (!kept.length) await this.end(user.id, c.id, { by, why: "client scopes" })
|
|
481
|
+
else await this.deps.store.updateAgentData(user.id, (data) => void (data.connections = (data.connections ?? []).map((x) => (x.id === c.id ? { ...x, scopes: kept } : x))))
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ---------- authorize and consent ----------
|
|
487
|
+
|
|
488
|
+
/** GET /connect/authorize: a flow and a redirect to the consent page, an error redirect, or an error page. */
|
|
489
|
+
async authorize(url: URL, address = "unknown"): Promise<Response> {
|
|
490
|
+
const q = url.searchParams
|
|
491
|
+
const one = (key: string): string | undefined => (q.getAll(key).length === 1 ? q.get(key)! : undefined)
|
|
492
|
+
const clientID = one("client_id") ?? ""
|
|
493
|
+
const redirectURI = one("redirect_uri")
|
|
494
|
+
const found = this.usable(clientID)
|
|
495
|
+
if (!found) return errorPage("This app is not registered with this hub, or it is disabled.")
|
|
496
|
+
if (!redirectUriMatches(strings(found.record.redirectURIs), redirectURI)) return errorPage("The app asked to come back to an address it did not register.")
|
|
497
|
+
const rawState = one("state")
|
|
498
|
+
const state = rawState && rawState.length <= CONNECT_LIMITS.stateMax ? rawState : undefined
|
|
499
|
+
const back = (error: string, description: string) => {
|
|
500
|
+
const target = new URL(redirectURI!)
|
|
501
|
+
target.searchParams.set("error", error)
|
|
502
|
+
target.searchParams.set("error_description", description.slice(0, 200))
|
|
503
|
+
if (state !== undefined) target.searchParams.set("state", state)
|
|
504
|
+
target.searchParams.set("iss", this.deps.publicURL())
|
|
505
|
+
return new Response(null, { status: 302, headers: { location: target.toString(), ...NO_STORE } })
|
|
506
|
+
}
|
|
507
|
+
if (one("response_type") !== "code") return back("unsupported_response_type", "response_type must be code")
|
|
508
|
+
if (state === undefined) return back("invalid_request", `state is required (1–${CONNECT_LIMITS.stateMax} characters)`)
|
|
509
|
+
const challenge = one("code_challenge")
|
|
510
|
+
const pkce = pkceChallengeProblem(challenge, one("code_challenge_method"))
|
|
511
|
+
if (pkce) return back("invalid_request", pkce)
|
|
512
|
+
const parsed = parseScopeParam(one("scope"), this.deps.scopes)
|
|
513
|
+
if ("problem" in parsed) return back("invalid_scope", parsed.problem)
|
|
514
|
+
const ceiling = strings(found.record.scopes)
|
|
515
|
+
const beyond = parsed.scopes.find((s) => !ceiling.includes(s))
|
|
516
|
+
if (beyond !== undefined) return back("invalid_scope", `This app may not ask for ${beyond}`)
|
|
517
|
+
if ((await this.deps.counters.incr(KEYS.authorizeRate(clientID, address), RATE_WINDOW_MS)) > CONNECT_LIMITS.authorizePerMinute) return back("temporarily_unavailable", "Too many requests; try again in a minute")
|
|
518
|
+
const created = this.now()
|
|
519
|
+
const flow: Flow = { id: `hcf_${randomBytes(12).toString("hex")}`, clientID, redirectURI: redirectURI!, scopes: parsed.scopes, state, challenge: challenge!, created, expiresAt: created + CONNECT_FLOW_TTL_MS }
|
|
520
|
+
await this.deps.kv.set(KEYS.flow(flow.id), JSON.stringify(flow), { px: CONNECT_FLOW_TTL_MS + 1 })
|
|
521
|
+
// Not audited here (anyone may call it): the user's consent or denial is.
|
|
522
|
+
return new Response(null, { status: 302, headers: { location: `/connect/consent/${flow.id}`, ...NO_STORE } })
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** The pending flow `user` may decide (personal clients: their owner only); not_found otherwise. */
|
|
526
|
+
private async flow(user: UserRecord, id: string): Promise<{ flow: Flow; found: Found }> {
|
|
527
|
+
const missing = () => new AgentError("not_found", "This request expired: start again from the app")
|
|
528
|
+
if (!CONNECT_TOKEN_PATTERNS.flow.test(id)) throw missing()
|
|
529
|
+
const raw = await this.deps.kv.get(KEYS.flow(id))
|
|
530
|
+
const flow = raw ? (JSON.parse(raw) as Flow) : undefined
|
|
531
|
+
if (!flow || flow.expiresAt < this.now()) throw missing()
|
|
532
|
+
const found = this.usable(flow.clientID)
|
|
533
|
+
if (!found || !this.visible(found, user.id)) throw missing()
|
|
534
|
+
return { flow, found }
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** GET /api/connect/flows/:flow: what the consent page shows. */
|
|
538
|
+
async flowView(user: UserRecord, id: string): Promise<ConnectFlowView> {
|
|
539
|
+
const { flow, found } = await this.flow(user, id)
|
|
540
|
+
const connection = (this.deps.store.agentData(user.id).connections ?? []).find((c) => c.clientID === flow.clientID && (c.refreshExpiresAt ?? 0) >= this.now())
|
|
541
|
+
return {
|
|
542
|
+
id: flow.id,
|
|
543
|
+
client: { id: found.record.id, name: this.clientView(found).name, redirectOrigin: new URL(flow.redirectURI).origin },
|
|
544
|
+
scopes: [...flow.scopes],
|
|
545
|
+
expiresAt: flow.expiresAt,
|
|
546
|
+
...(connection ? { connection: { id: connection.id, scopes: this.scopesOf(user.id, connection.id) ?? [] } } : {}),
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Approve (with the scopes the user kept: a non-empty subset of the request) or deny: where the page goes next. */
|
|
551
|
+
async decide(user: UserRecord, id: string, decision: "approve" | "deny", scopes?: unknown): Promise<{ redirect: string }> {
|
|
552
|
+
const { flow } = await this.flow(user, id)
|
|
553
|
+
let approved: string[] = []
|
|
554
|
+
if (decision === "approve") {
|
|
555
|
+
if (!Array.isArray(scopes) || !scopes.every((s): s is string => typeof s === "string") || scopes.length === 0) throw new AgentError("invalid", "Choose at least one scope")
|
|
556
|
+
const extra = scopes.find((s) => !flow.scopes.includes(s))
|
|
557
|
+
if (extra !== undefined) throw new AgentError("invalid", `The app did not ask for ${extra.slice(0, 64)}`)
|
|
558
|
+
approved = flow.scopes.filter((s) => scopes.includes(s))
|
|
559
|
+
const mine = this.deps.store.agentData(user.id).connections ?? []
|
|
560
|
+
if (!mine.some((c) => c.clientID === flow.clientID) && mine.length >= CONNECT_LIMITS.connectionsPerUser) {
|
|
561
|
+
throw new AgentError("rate_limited", `At most ${CONNECT_LIMITS.connectionsPerUser} connected apps per user`, { hint: "Revoke unused connections in Settings → Agents" })
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
// The first decision wins (GETDEL), on any pod.
|
|
565
|
+
if (!(await this.deps.kv.getdel(KEYS.flow(flow.id)))) throw new AgentError("not_found", "This request expired: start again from the app")
|
|
566
|
+
const target = new URL(flow.redirectURI)
|
|
567
|
+
if (decision === "approve") {
|
|
568
|
+
const code = `hco_${secret()}`
|
|
569
|
+
const record: Code = { clientID: flow.clientID, redirectURI: flow.redirectURI, challenge: flow.challenge, userID: user.id, scopes: approved, expiresAt: this.now() + AUTHORIZATION_CODE_TTL_MS }
|
|
570
|
+
await this.deps.kv.set(KEYS.code(hashToken(code)), JSON.stringify(record), { px: AUTHORIZATION_CODE_TTL_MS + 1 })
|
|
571
|
+
target.searchParams.set("code", code)
|
|
572
|
+
await this.deps.store.audit({ actor: user.id, action: "connect.consent", target: flow.clientID, detail: approved.join(" ") })
|
|
573
|
+
} else {
|
|
574
|
+
target.searchParams.set("error", "access_denied")
|
|
575
|
+
await this.deps.store.audit({ actor: user.id, action: "connect.deny", target: flow.clientID })
|
|
576
|
+
}
|
|
577
|
+
target.searchParams.set("state", flow.state)
|
|
578
|
+
target.searchParams.set("iss", this.deps.publicURL())
|
|
579
|
+
return { redirect: target.toString() }
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ---------- token and revoke endpoints ----------
|
|
583
|
+
|
|
584
|
+
/** The form of a token/revoke request: form-encoded only, each parameter at most once. */
|
|
585
|
+
private async form(request: Request): Promise<URLSearchParams> {
|
|
586
|
+
const type = (request.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase()
|
|
587
|
+
if (type !== "application/x-www-form-urlencoded") throw new OAuthError("invalid_request", "Send application/x-www-form-urlencoded")
|
|
588
|
+
let text: string
|
|
589
|
+
try {
|
|
590
|
+
text = await boundedText(request, FORM_MAX)
|
|
591
|
+
} catch {
|
|
592
|
+
throw new OAuthError("invalid_request", "The request body is too large")
|
|
593
|
+
}
|
|
594
|
+
const form = new URLSearchParams(text)
|
|
595
|
+
for (const key of new Set(form.keys())) if (form.getAll(key).length > 1) throw new OAuthError("invalid_request", `${key.slice(0, 40)} is given more than once`)
|
|
596
|
+
return form
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* client_secret_basic or client_secret_post (not both), then the secret. A failure counts against (client,
|
|
601
|
+
* address): past CONNECT_LIMITS.failedClientAuthPerMinute that address's failures answer 429, while a request with
|
|
602
|
+
* the right secret is never refused for them. Only authenticated requests count against the per-client limit.
|
|
603
|
+
*/
|
|
604
|
+
private async authenticate(request: Request, form: URLSearchParams, address: string): Promise<Found> {
|
|
605
|
+
const header = request.headers.get("authorization") ?? ""
|
|
606
|
+
const basic = /^\s*Basic\s+([A-Za-z0-9+/=]+)\s*$/i.exec(header)
|
|
607
|
+
let id: string | undefined
|
|
608
|
+
let presented: string | undefined
|
|
609
|
+
if (basic) {
|
|
610
|
+
if (form.has("client_secret")) throw new OAuthError("invalid_request", "Use one client authentication method")
|
|
611
|
+
const decoded = Buffer.from(basic[1]!, "base64").toString("utf8")
|
|
612
|
+
const colon = decoded.indexOf(":")
|
|
613
|
+
try {
|
|
614
|
+
id = decodeURIComponent(decoded.slice(0, Math.max(0, colon)).replace(/\+/g, " "))
|
|
615
|
+
presented = decodeURIComponent(decoded.slice(colon + 1).replace(/\+/g, " "))
|
|
616
|
+
} catch {
|
|
617
|
+
id = undefined
|
|
618
|
+
}
|
|
619
|
+
if (form.has("client_id") && form.get("client_id") !== id) throw new OAuthError("invalid_request", "client_id does not match the authentication")
|
|
620
|
+
} else {
|
|
621
|
+
id = form.get("client_id") ?? undefined
|
|
622
|
+
presented = form.get("client_secret") ?? undefined
|
|
623
|
+
}
|
|
624
|
+
const refused = (message: string) => new OAuthError("invalid_client", message, basic ? { "www-authenticate": 'Basic realm="hub"' } : {})
|
|
625
|
+
const busy = () => new OAuthError("temporarily_unavailable", "Too many requests; try again in a minute", { "retry-after": String(RATE_WINDOW_MS / 1000) })
|
|
626
|
+
const found = id ? this.find(id) : undefined
|
|
627
|
+
if (!found || presented === undefined || !sameHash(found.record.secretHash, hashToken(presented)) || (audienceOf(found.record) === "user" && found.owner.disabled)) {
|
|
628
|
+
const key = id && CONNECT_TOKEN_PATTERNS.clientID.test(id) ? id : "other"
|
|
629
|
+
if ((await this.deps.counters.incr(KEYS.authFailures(key, address), RATE_WINDOW_MS)) > CONNECT_LIMITS.failedClientAuthPerMinute) throw busy()
|
|
630
|
+
throw refused("Client authentication failed")
|
|
631
|
+
}
|
|
632
|
+
if ((await this.deps.counters.incr(KEYS.tokenRate(found.record.id), RATE_WINDOW_MS)) > CONNECT_LIMITS.tokenPerMinute) throw busy()
|
|
633
|
+
if (found.record.disabled) throw new OAuthError("unauthorized_client", "This app client is disabled")
|
|
634
|
+
return found
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** POST /connect/token. */
|
|
638
|
+
async token(request: Request, address = "unknown"): Promise<Response> {
|
|
639
|
+
try {
|
|
640
|
+
const form = await this.form(request)
|
|
641
|
+
const grantType = form.get("grant_type")
|
|
642
|
+
if (grantType !== "authorization_code" && grantType !== "refresh_token") {
|
|
643
|
+
// Authenticate first: an unknown client learns nothing more.
|
|
644
|
+
await this.authenticate(request, form, address)
|
|
645
|
+
throw new OAuthError(grantType ? "unsupported_grant_type" : "invalid_request", grantType ? "grant_type is authorization_code or refresh_token" : "grant_type is required")
|
|
646
|
+
}
|
|
647
|
+
const client = await this.authenticate(request, form, address)
|
|
648
|
+
const body = grantType === "authorization_code" ? await this.exchange(client, form) : await this.refresh(client, form)
|
|
649
|
+
return Response.json(body, { headers: { ...NO_STORE, pragma: "no-cache" } })
|
|
650
|
+
} catch (cause) {
|
|
651
|
+
if (cause instanceof OAuthError) return oauthResponse(cause)
|
|
652
|
+
throw cause
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private fresh(connectionID: string): { access: string; refresh: string } {
|
|
657
|
+
return { access: `hca_${connectionID}_${secret()}`, refresh: `hcr_${connectionID}_${secret()}` }
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private body(connectionID: string, pair: { access: string; refresh: string }, scopes: readonly string[]): TokenBody {
|
|
661
|
+
return { access_token: pair.access, token_type: "Bearer", expires_in: ACCESS_TOKEN_TTL_MS / 1000, refresh_token: pair.refresh, scope: scopes.join(" "), connection_id: connectionID }
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** grant_type=authorization_code: the code (once), the redirect URI and the PKCE verifier → the user's connection. */
|
|
665
|
+
private async exchange(client: Found, form: URLSearchParams): Promise<TokenBody> {
|
|
666
|
+
const code = form.get("code")
|
|
667
|
+
const redirectURI = form.get("redirect_uri")
|
|
668
|
+
const verifier = form.get("code_verifier")
|
|
669
|
+
if (!code || !redirectURI) throw new OAuthError("invalid_request", "code and redirect_uri are required")
|
|
670
|
+
const problem = pkceVerifierProblem(verifier)
|
|
671
|
+
if (problem) throw new OAuthError("invalid_request", problem)
|
|
672
|
+
const codeHash = hashToken(code)
|
|
673
|
+
// The binding (client, redirect URI) is checked before the code is spent: another client presenting an
|
|
674
|
+
// intercepted code neither consumes nor revokes anything.
|
|
675
|
+
const peeked = await this.deps.kv.get(KEYS.code(codeHash))
|
|
676
|
+
if (peeked) {
|
|
677
|
+
const bound = JSON.parse(peeked) as Code
|
|
678
|
+
if (bound.clientID !== client.record.id) throw new OAuthError("invalid_grant", "The code was issued to another client")
|
|
679
|
+
if (bound.redirectURI !== redirectURI) throw new OAuthError("invalid_grant", "redirect_uri does not match the authorization request")
|
|
680
|
+
}
|
|
681
|
+
const raw = peeked ? await this.deps.kv.getdel(KEYS.code(codeHash)) : undefined
|
|
682
|
+
if (!raw) {
|
|
683
|
+
const used = await this.deps.kv.get(KEYS.codeUsed(codeHash))
|
|
684
|
+
const issued = used ? (JSON.parse(used) as { userID: string; connectionID: string; clientID: string }) : undefined
|
|
685
|
+
if (issued && issued.clientID === client.record.id) {
|
|
686
|
+
await this.end(issued.userID, issued.connectionID, { by: "system", why: "authorization code reused", action: "connect.reuse" })
|
|
687
|
+
}
|
|
688
|
+
throw new OAuthError("invalid_grant", "The code is unknown, expired or already used")
|
|
689
|
+
}
|
|
690
|
+
const grant = JSON.parse(raw) as Code
|
|
691
|
+
if (grant.expiresAt < this.now()) throw new OAuthError("invalid_grant", "The code expired")
|
|
692
|
+
if (grant.clientID !== client.record.id) throw new OAuthError("invalid_grant", "The code was issued to another client")
|
|
693
|
+
if (grant.redirectURI !== redirectURI) throw new OAuthError("invalid_grant", "redirect_uri does not match the authorization request")
|
|
694
|
+
const expected = Buffer.from(grant.challenge)
|
|
695
|
+
const actual = Buffer.from(createHash("sha256").update(verifier!).digest("base64url"))
|
|
696
|
+
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) throw new OAuthError("invalid_grant", "code_verifier does not match the code challenge")
|
|
697
|
+
const user = this.deps.store.user(grant.userID)
|
|
698
|
+
if (!user || user.disabled) throw new OAuthError("invalid_grant", "The user cannot connect apps now")
|
|
699
|
+
if (audienceOf(client.record) === "user" && client.owner.id !== user.id) throw new OAuthError("invalid_grant", "This client is not this user's")
|
|
700
|
+
|
|
701
|
+
const existing = (user.agent?.connections ?? []).find((c) => c.clientID === client.record.id)
|
|
702
|
+
const connectionID = existing?.id ?? this.newID("", (id) => this.taken(id))
|
|
703
|
+
await this.deps.kv.set(KEYS.codeUsed(codeHash), JSON.stringify({ userID: user.id, connectionID, clientID: client.record.id }), { px: CODE_USED_TTL_MS })
|
|
704
|
+
const pair = this.fresh(connectionID)
|
|
705
|
+
const at = this.now()
|
|
706
|
+
let full = false
|
|
707
|
+
await this.deps.store.updateAgentData(user.id, (data) => {
|
|
708
|
+
const list = data.connections ?? []
|
|
709
|
+
const current = list.find((c) => c.id === connectionID)
|
|
710
|
+
full = !current && list.length >= CONNECT_LIMITS.connectionsPerUser
|
|
711
|
+
if (full) return
|
|
712
|
+
const record: ConnectionRecord = {
|
|
713
|
+
id: connectionID,
|
|
714
|
+
clientID: client.record.id,
|
|
715
|
+
scopes: [...grant.scopes],
|
|
716
|
+
created: current?.created ?? at,
|
|
717
|
+
lastUsed: at,
|
|
718
|
+
accessHash: hashToken(pair.access),
|
|
719
|
+
accessExpiresAt: at + ACCESS_TOKEN_TTL_MS,
|
|
720
|
+
refreshHash: hashToken(pair.refresh),
|
|
721
|
+
refreshExpiresAt: at + REFRESH_TOKEN_IDLE_MS,
|
|
722
|
+
}
|
|
723
|
+
data.connections = [...list.filter((c) => c.id !== connectionID), record]
|
|
724
|
+
})
|
|
725
|
+
if (full) throw new OAuthError("invalid_grant", `At most ${CONNECT_LIMITS.connectionsPerUser} connected apps per user`)
|
|
726
|
+
await this.deps.store.audit({ actor: user.id, action: "connect.token", target: connectionID, detail: "authorization_code" })
|
|
727
|
+
return this.body(connectionID, pair, grant.scopes)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** A connection ID the hub never used: not a connection's, not a hand-made app token's. */
|
|
731
|
+
private taken(id: string): boolean {
|
|
732
|
+
return this.deps.store.users().some((u) => (u.agent?.connections ?? []).some((c) => c.id === id) || (u.agent?.appTokens ?? []).some((t) => t.id === id))
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** grant_type=refresh_token: rotate; the just-rotated token within the grace answers the same pair; else reuse. */
|
|
736
|
+
private async refresh(client: Found, form: URLSearchParams): Promise<TokenBody> {
|
|
737
|
+
const presented = form.get("refresh_token")
|
|
738
|
+
if (!presented) throw new OAuthError("invalid_request", "refresh_token is required")
|
|
739
|
+
const parsed = parseConnectToken(presented)
|
|
740
|
+
const invalid = () => new OAuthError("invalid_grant", "The refresh token is unknown, expired or revoked")
|
|
741
|
+
if (!parsed || parsed.kind !== "refresh") throw invalid()
|
|
742
|
+
const located = this.connectionOf(parsed.connectionID)
|
|
743
|
+
if (!located) throw invalid()
|
|
744
|
+
await this.deps.store.recheck({ users: [located.user.id] })
|
|
745
|
+
const user = this.deps.store.user(located.user.id)
|
|
746
|
+
const connection = user?.agent?.connections?.find((c) => c.id === parsed.connectionID)
|
|
747
|
+
if (!user || !connection || connection.clientID !== client.record.id) throw invalid()
|
|
748
|
+
if (user.disabled) throw invalid()
|
|
749
|
+
let narrowed: string[] | undefined
|
|
750
|
+
const scope = form.get("scope")
|
|
751
|
+
if (scope !== null) {
|
|
752
|
+
const result = parseScopeParam(scope, strings(connection.scopes))
|
|
753
|
+
if ("problem" in result) throw new OAuthError("invalid_scope", result.problem)
|
|
754
|
+
narrowed = result.scopes
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const hash = hashToken(presented)
|
|
758
|
+
const pair = this.fresh(connection.id)
|
|
759
|
+
const at = this.now()
|
|
760
|
+
type Outcome = { kind: "rotated"; body: TokenBody } | { kind: "grace"; sealed: string } | { kind: "lapsed" } | { kind: "reuse" } | { kind: "gone" }
|
|
761
|
+
let outcome = { kind: "gone" } as Outcome
|
|
762
|
+
await this.deps.store.updateAgentData(user.id, (data) => {
|
|
763
|
+
const current = (data.connections ?? []).find((c) => c.id === connection.id)
|
|
764
|
+
outcome = { kind: "gone" }
|
|
765
|
+
if (!current) return
|
|
766
|
+
if (sameHash(current.refreshHash, hash)) {
|
|
767
|
+
if ((current.refreshExpiresAt ?? 0) < at) {
|
|
768
|
+
outcome = { kind: "lapsed" }
|
|
769
|
+
return
|
|
770
|
+
}
|
|
771
|
+
const scopes = narrowed ?? strings(current.scopes)
|
|
772
|
+
const body = this.body(current.id, pair, scopes)
|
|
773
|
+
outcome = { kind: "rotated", body }
|
|
774
|
+
const next: ConnectionRecord = {
|
|
775
|
+
...current,
|
|
776
|
+
scopes: [...scopes],
|
|
777
|
+
lastUsed: at,
|
|
778
|
+
accessHash: hashToken(pair.access),
|
|
779
|
+
accessExpiresAt: at + ACCESS_TOKEN_TTL_MS,
|
|
780
|
+
refreshHash: hashToken(pair.refresh),
|
|
781
|
+
refreshExpiresAt: at + REFRESH_TOKEN_IDLE_MS,
|
|
782
|
+
previous: { refreshHash: hash, accessHash: current.accessHash ?? "", until: at + REFRESH_GRACE_MS, accessUntil: Math.min(current.accessExpiresAt ?? 0, at + REFRESH_GRACE_MS), sealed: seal(body, presented) },
|
|
783
|
+
}
|
|
784
|
+
data.connections = (data.connections ?? []).map((c) => (c.id === current.id ? next : c))
|
|
785
|
+
return
|
|
786
|
+
}
|
|
787
|
+
const previous = current.previous
|
|
788
|
+
if (previous && sameHash(previous.refreshHash, hash) && at <= previous.until) outcome = { kind: "grace", sealed: previous.sealed }
|
|
789
|
+
else outcome = { kind: "reuse" }
|
|
790
|
+
})
|
|
791
|
+
const result: Outcome = outcome
|
|
792
|
+
switch (result.kind) {
|
|
793
|
+
case "rotated":
|
|
794
|
+
await this.deps.store.audit({ actor: user.id, action: "connect.token", target: connection.id, detail: "refresh_token" })
|
|
795
|
+
return result.body
|
|
796
|
+
case "grace": {
|
|
797
|
+
const body = unseal(result.sealed, presented)
|
|
798
|
+
if (body) return body
|
|
799
|
+
throw invalid()
|
|
800
|
+
}
|
|
801
|
+
case "lapsed":
|
|
802
|
+
await this.end(user.id, connection.id, { by: "system", why: "refresh token idle 30 days" })
|
|
803
|
+
throw invalid()
|
|
804
|
+
case "reuse":
|
|
805
|
+
await this.end(user.id, connection.id, { by: "system", why: "refresh token reused", action: "connect.reuse" })
|
|
806
|
+
throw new OAuthError("invalid_grant", "The refresh token was already used: the connection is revoked")
|
|
807
|
+
case "gone":
|
|
808
|
+
throw invalid()
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** POST /connect/revoke (RFC 7009): either token of the client's connection ends it; 200 for anything else. */
|
|
813
|
+
async revoke(request: Request, address = "unknown"): Promise<Response> {
|
|
814
|
+
try {
|
|
815
|
+
const form = await this.form(request)
|
|
816
|
+
const client = await this.authenticate(request, form, address)
|
|
817
|
+
const value = form.get("token")
|
|
818
|
+
if (!value) throw new OAuthError("invalid_request", "token is required")
|
|
819
|
+
const parsed = parseConnectToken(value)
|
|
820
|
+
const located = parsed ? this.connectionOf(parsed.connectionID) : undefined
|
|
821
|
+
if (located && located.connection.clientID === client.record.id) {
|
|
822
|
+
const hash = hashToken(value)
|
|
823
|
+
const c = located.connection
|
|
824
|
+
if ([c.accessHash, c.refreshHash, c.previous?.accessHash, c.previous?.refreshHash].some((h) => sameHash(h, hash))) {
|
|
825
|
+
await this.end(located.user.id, c.id, { by: `app:${c.id}`, why: "revoked by the app" })
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
return new Response(null, { status: 200, headers: NO_STORE })
|
|
829
|
+
} catch (cause) {
|
|
830
|
+
if (cause instanceof OAuthError) return oauthResponse(cause)
|
|
831
|
+
throw cause
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// ---------- the access token verifier ----------
|
|
836
|
+
|
|
837
|
+
/** hca_<connection>_<secret> → the connected app's actor; past its hour "expired"; anything else undefined. */
|
|
838
|
+
readonly verifier: BearerVerifier = {
|
|
839
|
+
kind: "app",
|
|
840
|
+
verify: async (value: string): Promise<VerifyResult> => {
|
|
841
|
+
const parsed = parseConnectToken(value)
|
|
842
|
+
if (!parsed || parsed.kind !== "access") return undefined
|
|
843
|
+
const located = this.connectionOf(parsed.connectionID)
|
|
844
|
+
if (!located) return undefined
|
|
845
|
+
// A revocation on another pod applies at once: read the user again (a no-op for the file store).
|
|
846
|
+
await this.deps.store.recheck({ users: [located.user.id] })
|
|
847
|
+
const connection = this.deps.store.agentData(located.user.id).connections?.find((c) => c.id === parsed.connectionID)
|
|
848
|
+
if (!connection) return undefined
|
|
849
|
+
const client = this.usable(connection.clientID)
|
|
850
|
+
if (!client) return undefined
|
|
851
|
+
const hash = hashToken(value)
|
|
852
|
+
const at = this.now()
|
|
853
|
+
if (sameHash(connection.accessHash, hash)) {
|
|
854
|
+
if ((connection.accessExpiresAt ?? 0) < at) return "expired"
|
|
855
|
+
} else if (!(connection.previous && sameHash(connection.previous.accessHash, hash) && at <= connection.previous.accessUntil)) return undefined
|
|
856
|
+
const actor: AppActor = {
|
|
857
|
+
kind: "app",
|
|
858
|
+
userID: located.user.id,
|
|
859
|
+
tokenID: connection.id,
|
|
860
|
+
scopes: this.scopesOf(located.user.id, connection.id) ?? [],
|
|
861
|
+
label: this.clientView(client).name,
|
|
862
|
+
clientID: client.record.id,
|
|
863
|
+
connection: true,
|
|
864
|
+
}
|
|
865
|
+
return actor
|
|
866
|
+
},
|
|
867
|
+
}
|
|
868
|
+
}
|