@3sln/trove 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/README.md +1227 -0
  2. package/package.json +75 -0
  3. package/packages/core/src/collections/index.js +249 -0
  4. package/packages/core/src/errors.js +186 -0
  5. package/packages/core/src/identity/discovery.js +210 -0
  6. package/packages/core/src/identity/index.js +188 -0
  7. package/packages/core/src/identity/jwt.js +199 -0
  8. package/packages/core/src/index.js +104 -0
  9. package/packages/core/src/indexers/contribution.js +115 -0
  10. package/packages/core/src/indexers/registry.js +162 -0
  11. package/packages/core/src/indexing.js +340 -0
  12. package/packages/core/src/issues.js +150 -0
  13. package/packages/core/src/kv.js +0 -0
  14. package/packages/core/src/links.js +141 -0
  15. package/packages/core/src/metadata/cursor.js +73 -0
  16. package/packages/core/src/metadata/interface.js +244 -0
  17. package/packages/core/src/metadata/memory.js +270 -0
  18. package/packages/core/src/metadata/sqlite.js +412 -0
  19. package/packages/core/src/notifications/index.js +139 -0
  20. package/packages/core/src/notifications/webpush.js +217 -0
  21. package/packages/core/src/plugins/contributions.js +177 -0
  22. package/packages/core/src/plugins/identity.js +98 -0
  23. package/packages/core/src/plugins/index.js +225 -0
  24. package/packages/core/src/plugins/indexers.js +142 -0
  25. package/packages/core/src/plugins/installStore.js +134 -0
  26. package/packages/core/src/plugins/package.js +102 -0
  27. package/packages/core/src/plugins/packageStore.js +61 -0
  28. package/packages/core/src/plugins/runtime.js +101 -0
  29. package/packages/core/src/plugins/sql.js +52 -0
  30. package/packages/core/src/retry.js +74 -0
  31. package/packages/core/src/scan.js +302 -0
  32. package/packages/core/src/search/embeddings.js +128 -0
  33. package/packages/core/src/search/index.js +200 -0
  34. package/packages/core/src/search/keywordStore.js +107 -0
  35. package/packages/core/src/search/sqliteStores.js +455 -0
  36. package/packages/core/src/search/tagMatch.js +59 -0
  37. package/packages/core/src/search/transformer.js +195 -0
  38. package/packages/core/src/search/vectorStore.js +274 -0
  39. package/packages/core/src/search/vectorize.js +249 -0
  40. package/packages/core/src/sidecar/document.js +213 -0
  41. package/packages/core/src/sidecar/index.js +174 -0
  42. package/packages/core/src/sidecar/manager.js +239 -0
  43. package/packages/core/src/sidecar/store.js +46 -0
  44. package/packages/core/src/signedUrls.js +170 -0
  45. package/packages/core/src/sqlite-d1.js +162 -0
  46. package/packages/core/src/sqlite-driver.js +42 -0
  47. package/packages/core/src/sqlite.js +162 -0
  48. package/packages/core/src/storage/filesystem.js +283 -0
  49. package/packages/core/src/storage/interface.js +222 -0
  50. package/packages/core/src/storage/memory.js +113 -0
  51. package/packages/core/src/storage/prefixed.js +75 -0
  52. package/packages/core/src/storage/s3.js +316 -0
  53. package/packages/core/src/storage/s3sigv4.js +185 -0
  54. package/packages/core/src/tasks.js +228 -0
  55. package/packages/core/src/uploads.js +386 -0
  56. package/packages/core/src/util.js +125 -0
  57. package/packages/core/src/vfs.js +666 -0
  58. package/packages/plugin-sdk/src/browser.js +316 -0
  59. package/packages/plugin-sdk/src/index.js +32 -0
  60. package/packages/plugin-sdk/src/protocol.js +59 -0
  61. package/packages/plugin-sdk/src/rpc.js +95 -0
  62. package/packages/server/src/adapters/bun.js +78 -0
  63. package/packages/server/src/adapters/node.js +115 -0
  64. package/packages/server/src/adapters/staticAssets.js +123 -0
  65. package/packages/server/src/adapters/webDist.js +70 -0
  66. package/packages/server/src/adapters/worker-tasks.js +206 -0
  67. package/packages/server/src/adapters/worker.js +159 -0
  68. package/packages/server/src/cachePolicy.js +34 -0
  69. package/packages/server/src/engine/README.md +88 -0
  70. package/packages/server/src/engine/actions/scanCollection.js +114 -0
  71. package/packages/server/src/engine/index.js +95 -0
  72. package/packages/server/src/engine/lazy.js +25 -0
  73. package/packages/server/src/engine/providers/access.js +363 -0
  74. package/packages/server/src/engine/providers/core.js +405 -0
  75. package/packages/server/src/engine/providers/scan.js +67 -0
  76. package/packages/server/src/index.js +698 -0
  77. package/packages/server/src/manifest.js +98 -0
  78. package/packages/server/src/mcp/auth.js +40 -0
  79. package/packages/server/src/mcp/index.js +213 -0
  80. package/packages/server/src/mcp/protocol.js +181 -0
  81. package/packages/server/src/mcp/tools.js +351 -0
  82. package/packages/server/src/router.js +229 -0
  83. package/packages/server/src/routes.js +1066 -0
  84. package/packages/server/src/scope.js +43 -0
  85. package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
  86. package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
  87. package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
  88. package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
  89. package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
  90. package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
  91. package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
  92. package/packages/web/dist/icon.svg +11 -0
  93. package/packages/web/dist/index.html +16 -0
  94. package/packages/web/dist/sql-wasm.wasm +0 -0
  95. package/packages/web/dist/sw.js +186 -0
  96. package/packages/web/src/bl/actions.js +410 -0
  97. package/packages/web/src/bl/activity.js +306 -0
  98. package/packages/web/src/bl/commands.js +274 -0
  99. package/packages/web/src/bl/fileType.js +49 -0
  100. package/packages/web/src/bl/index.js +70 -0
  101. package/packages/web/src/bl/links.js +54 -0
  102. package/packages/web/src/bl/offline.js +268 -0
  103. package/packages/web/src/bl/openers.js +71 -0
  104. package/packages/web/src/bl/pluginInstall.js +59 -0
  105. package/packages/web/src/bl/services.js +143 -0
  106. package/packages/web/src/bl/social.js +234 -0
  107. package/packages/web/src/bl/tagQuery.js +44 -0
  108. package/packages/web/src/main.js +10 -0
  109. package/packages/web/src/platform/api.js +529 -0
  110. package/packages/web/src/platform/commands.js +89 -0
  111. package/packages/web/src/platform/context.js +77 -0
  112. package/packages/web/src/platform/contributions.js +156 -0
  113. package/packages/web/src/platform/index.js +150 -0
  114. package/packages/web/src/platform/keybindings.js +199 -0
  115. package/packages/web/src/platform/mediaUrls.js +137 -0
  116. package/packages/web/src/platform/navigation.js +131 -0
  117. package/packages/web/src/platform/notifications.js +50 -0
  118. package/packages/web/src/platform/overlay.js +81 -0
  119. package/packages/web/src/platform/pluginClientDb.js +132 -0
  120. package/packages/web/src/platform/pluginDock.js +141 -0
  121. package/packages/web/src/platform/pluginFrames.js +194 -0
  122. package/packages/web/src/platform/pluginHost.js +648 -0
  123. package/packages/web/src/platform/pluginMedia.js +62 -0
  124. package/packages/web/src/platform/pluginModules.js +90 -0
  125. package/packages/web/src/platform/pluginNet.js +71 -0
  126. package/packages/web/src/platform/pluginPackage.js +247 -0
  127. package/packages/web/src/platform/pluginRpc.js +377 -0
  128. package/packages/web/src/platform/pluginSigning.js +168 -0
  129. package/packages/web/src/platform/pluginStore.js +67 -0
  130. package/packages/web/src/platform/settings.js +101 -0
  131. package/packages/web/src/platform/spatialNav.js +286 -0
  132. package/packages/web/src/platform/viewport.js +123 -0
  133. package/packages/web/src/platform/voice.js +133 -0
  134. package/packages/web/src/platform/voiceSearch.js +155 -0
  135. package/packages/web/src/platform/whenclause.js +162 -0
  136. package/packages/web/src/platform/workbench.js +156 -0
  137. package/packages/web/src/runtime.js +73 -0
  138. package/packages/web/src/styles.css +1382 -0
  139. package/packages/web/src/ui/components/activityBar.js +35 -0
  140. package/packages/web/src/ui/components/activityPanel.js +132 -0
  141. package/packages/web/src/ui/components/commandPalette.js +154 -0
  142. package/packages/web/src/ui/components/editorArea.js +75 -0
  143. package/packages/web/src/ui/components/launcher.js +392 -0
  144. package/packages/web/src/ui/components/openers/index.js +212 -0
  145. package/packages/web/src/ui/components/openers/markdown.js +222 -0
  146. package/packages/web/src/ui/components/overlays.js +255 -0
  147. package/packages/web/src/ui/components/phoneChrome.js +188 -0
  148. package/packages/web/src/ui/components/pluginReview.js +151 -0
  149. package/packages/web/src/ui/components/pluginsView.js +120 -0
  150. package/packages/web/src/ui/components/settingsView.js +258 -0
  151. package/packages/web/src/ui/components/social.js +290 -0
  152. package/packages/web/src/ui/components/statusBar.js +198 -0
  153. package/packages/web/src/ui/components/views/grid.js +115 -0
  154. package/packages/web/src/ui/components/views/index.js +155 -0
  155. package/packages/web/src/ui/components/views/list.js +50 -0
  156. package/packages/web/src/ui/components/views/parts.js +58 -0
  157. package/packages/web/src/ui/compositions/workbench.js +125 -0
  158. package/packages/web/src/ui/format.js +33 -0
  159. package/packages/web/src/ui/icon.js +81 -0
  160. package/packages/web/src/ui/media.js +114 -0
  161. package/packages/web/src/ui/sanitize.js +86 -0
  162. package/packages/web/src/workbench.js +205 -0
@@ -0,0 +1,210 @@
1
+ // Telling a client where to sign in.
2
+ //
3
+ // Trove doesn't run a login system — it verifies tokens somebody else issued. So every
4
+ // unauthenticated request has the same problem: the client has been refused, and has no
5
+ // idea where to go and get a credential. A bare 401 is a dead end for a browser and an
6
+ // absolute dead end for an agent, which has no human to ask.
7
+ //
8
+ // OAuth 2.0 Protected Resource Metadata (RFC 9728) is the standard answer, and it is
9
+ // what the MCP authorization spec builds on — so implementing it once serves both. A
10
+ // refused request carries a pointer:
11
+ //
12
+ // WWW-Authenticate: Bearer resource_metadata="https://drive/.well-known/oauth-protected-resource"
13
+ //
14
+ // and that document names the authorization server. ONE authorization server, for the
15
+ // whole drive. It is a property of the deployment — which identity provider sits in
16
+ // front of this thing — not of any particular endpoint, so the MCP endpoint and the JSON
17
+ // API answer with the same value and cannot drift apart.
18
+ //
19
+ // The deployment supplies it (env, or a field on the server config). Where it is not
20
+ // supplied but the JWT issuer is, that is used: for essentially every OIDC provider the
21
+ // issuer URL IS the authorization server, and making someone state the same URL twice
22
+ // is a way to have them disagree.
23
+
24
+ /** Normalize one-or-many authorization server URLs, trimming trailing slashes. */
25
+ export function normalizeServers(value) {
26
+ if (!value) return [];
27
+ const list = Array.isArray(value) ? value : String(value).split(',');
28
+ return list.map((s) => String(s).trim().replace(/\/+$/, '')).filter(Boolean);
29
+ }
30
+
31
+ /**
32
+ * The public origin of this request, as the outside world sees it.
33
+ *
34
+ * Behind a reverse proxy the socket says `http://10.0.0.4:8080`, which is not an
35
+ * identifier any token was ever issued for. The forwarded headers say what the client
36
+ * actually asked for.
37
+ */
38
+ export function publicOrigin(req, cfg = {}) {
39
+ // An explicit public URL wins, and is the only form that is safe without a trusted
40
+ // proxy in front — see below.
41
+ if (cfg.publicUrl) {
42
+ try { return new URL(cfg.publicUrl).origin; } catch { /* fall through to detection */ }
43
+ }
44
+ const url = new URL(req.url);
45
+ // X-Forwarded-* is set by a proxy — and by anyone else who feels like it. This origin
46
+ // ends up in the WWW-Authenticate challenge and the discovery document, so trusting a
47
+ // spoofed one hands a client a sign-in URL on somebody else's host. Only honoured when
48
+ // the deployment says it IS behind a proxy (TROVE_TRUST_PROXY), which is the same
49
+ // thing every other server makes you opt into.
50
+ if (cfg.trustProxy) {
51
+ const proto = req.headers.get('x-forwarded-proto') || url.protocol.replace(':', '');
52
+ const host = req.headers.get('x-forwarded-host') || req.headers.get('host') || url.host;
53
+ return `${proto}://${host}`;
54
+ }
55
+ return `${url.protocol.replace(':', '')}://${req.headers.get('host') || url.host}`;
56
+ }
57
+
58
+ /**
59
+ * Where the metadata document for a resource lives.
60
+ *
61
+ * RFC 9728 inserts the well-known segment BETWEEN the host and the path rather than
62
+ * appending it, so a resource at `https://d/mcp` is described at
63
+ * `https://d/.well-known/oauth-protected-resource/mcp`, and the drive itself at
64
+ * `https://d/.well-known/oauth-protected-resource`. Appending instead is a common enough
65
+ * mistake that clients have had to work around it.
66
+ */
67
+ export function metadataUrl(resource) {
68
+ const u = new URL(resource);
69
+ const path = u.pathname.replace(/\/+$/, '');
70
+ return `${u.origin}/.well-known/oauth-protected-resource${path}`;
71
+ }
72
+
73
+ /**
74
+ * The RFC 9728 document.
75
+ *
76
+ * @param {string} resource the canonical URI of what is being protected
77
+ * @param {object} auth the drive's auth discovery config
78
+ */
79
+ export function protectedResourceMetadata(resource, auth = {}) {
80
+ const servers = normalizeServers(auth.authorizationServers);
81
+ return {
82
+ resource,
83
+ // Optional in the RFC, but a client can do nothing without it — so when it is
84
+ // missing the field is omitted rather than published empty, which would read as
85
+ // "there are none" instead of "nobody configured this", and the challenge below
86
+ // says so in words.
87
+ ...(servers.length ? { authorization_servers: servers } : {}),
88
+ scopes_supported: auth.scopes?.length ? auth.scopes : ['trove:read', 'trove:write'],
89
+ bearer_methods_supported: ['header'],
90
+ resource_name: auth.resourceName || 'Trove',
91
+ ...(auth.documentation ? { resource_documentation: auth.documentation } : {}),
92
+ };
93
+ }
94
+
95
+ /**
96
+ * The challenge that turns a 401 into directions.
97
+ *
98
+ * `error_description` carries the operator-facing half. A client ignores it, but the
99
+ * person reading a failed connection in a log is the one who can fix a missing
100
+ * authorization server, and "401" on its own tells them nothing.
101
+ */
102
+ export function challengeHeaders(resource, auth = {}, { error = 'invalid_token', description } = {}) {
103
+ const servers = normalizeServers(auth.authorizationServers);
104
+ // The missing-server note is APPENDED rather than used as a fallback. Whatever went
105
+ // wrong with this particular request, "there is nowhere configured to get a token" is
106
+ // the thing that will keep going wrong until someone fixes it, and it must not be
107
+ // displaced by a more specific message about the immediate failure.
108
+ const detail = [
109
+ description || 'Present a bearer token from the configured authorization server.',
110
+ servers.length ? null
111
+ : 'No authorization server is configured on this drive. Set TROVE_AUTH_SERVER to the '
112
+ + 'issuer URL of your identity provider (it defaults to TROVE_JWT_ISSUER when that is set).',
113
+ ].filter(Boolean).join(' ');
114
+ return {
115
+ 'www-authenticate': `Bearer realm="Trove", error="${error}", `
116
+ + `error_description="${headerSafe(detail)}", `
117
+ + `resource_metadata="${metadataUrl(resource)}"`,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Make a string safe to put in an HTTP header value.
123
+ *
124
+ * Header values are bytes, not text: a curly quote or an em dash — the kind of thing
125
+ * that shows up the moment a message is written for a person to read — makes the whole
126
+ * response throw at construction time, turning a helpful 401 into a 500. Quotes would
127
+ * also end the quoted-string early, so those go too.
128
+ */
129
+ export function headerSafe(text) {
130
+ return String(text)
131
+ .replace(/[\u2018\u2019]/g, "'").replace(/[\u201c\u201d]/g, "'")
132
+ .replace(/[\u2013\u2014]/g, '-').replace(/\u2026/g, '...')
133
+ .replace(/["\\]/g, "'")
134
+ .replace(/[^\x20-\x7e]/g, ' ')
135
+ .replace(/\s+/g, ' ')
136
+ .trim();
137
+ }
138
+
139
+ /**
140
+ * Can this string be published as an authorization server?
141
+ *
142
+ * A client will fetch `<value>/.well-known/oauth-authorization-server` and then send a
143
+ * user — and eventually a bearer token — wherever that leads. So it has to be an
144
+ * absolute http(s) URL. Plaintext is allowed only on the loopback host, where it is
145
+ * someone developing rather than a token crossing a network.
146
+ */
147
+ export function usableAuthServer(value) {
148
+ let u;
149
+ try { u = new URL(String(value)); } catch { return false; }
150
+ if (u.protocol === 'https:') return true;
151
+ return u.protocol === 'http:' && (u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]');
152
+ }
153
+
154
+ /**
155
+ * Resolve the drive's auth-discovery settings from server config.
156
+ *
157
+ * The fallback to the JWT issuer is the useful part: a deployment that already told
158
+ * Trove which issuer to trust has already told it where the authorization server is,
159
+ * and asking for the same URL under a second name is a way to get two different answers.
160
+ *
161
+ * But it is only sound when the issuer is a URL. A JWT `iss` is StringOrURI — `iss` may
162
+ * legitimately be `my-gateway` or a URN, and a deployment minting its own tokens often
163
+ * makes it exactly that. Publishing one of those as an authorization server is WORSE
164
+ * than publishing nothing: an absent field makes a client report "no authorization
165
+ * server configured", while a garbage one makes it fail somewhere inside a fetch. So the
166
+ * inference is filtered, and the reason is reported rather than swallowed.
167
+ *
168
+ * @returns {{authorizationServers: string[], source: string, warnings: string[]}}
169
+ */
170
+ export function resolveAuthDiscovery(config = {}) {
171
+ const explicit = normalizeServers(config.auth?.authorizationServers ?? config.authServer);
172
+ const issuer = normalizeServers(config.identity?.jwt?.issuer);
173
+ const warnings = [];
174
+
175
+ // Explicitly configured wins, and is honoured as given — the operator said what they
176
+ // meant. Plaintext is still called out: the OAuth flow, and the token at the end of
177
+ // it, travel over whatever this names.
178
+ const badExplicit = explicit.filter((s) => !usableAuthServer(s));
179
+ for (const s of badExplicit) {
180
+ warnings.push(`TROVE_AUTH_SERVER is "${s}", which is not an https URL. Clients will be sent there `
181
+ + 'to sign in, and a bearer token will travel over it.');
182
+ }
183
+ if (explicit.length) {
184
+ return { authorizationServers: explicit, source: 'configured', warnings, ...passthrough(config) };
185
+ }
186
+
187
+ const inferable = issuer.filter(usableAuthServer);
188
+ if (issuer.length && !inferable.length) {
189
+ warnings.push(`TROVE_JWT_ISSUER is "${issuer[0]}", which is not a URL, so it cannot double as an `
190
+ + 'authorization server. Clients will be told there is nowhere to sign in until you set '
191
+ + 'TROVE_AUTH_SERVER.');
192
+ }
193
+ return {
194
+ authorizationServers: inferable,
195
+ // Recorded so a UI can explain where the value came from — "we inferred this from
196
+ // your JWT issuer" is a different fact from "you set this", and an operator
197
+ // debugging a mismatch needs to know which.
198
+ source: inferable.length ? 'jwt-issuer' : 'none',
199
+ warnings,
200
+ ...passthrough(config),
201
+ };
202
+ }
203
+
204
+ function passthrough(config) {
205
+ return {
206
+ scopes: config.auth?.scopes,
207
+ resourceName: config.auth?.resourceName,
208
+ documentation: config.auth?.documentation,
209
+ };
210
+ }
@@ -0,0 +1,188 @@
1
+ // Identity — Trove does not run a login system. It expects a trusted identity
2
+ // provider (Cloudflare Access / Zero Trust, an oauth2-proxy, an API gateway…) to
3
+ // authenticate the user and attach proof to each request, and it builds a
4
+ // *profile* (a Principal) around that. An IdentityProvider turns a request into a
5
+ // Principal (or null when anonymous access is allowed).
6
+ //
7
+ // Providers (all injectable into the server):
8
+ // - JwtIdentityProvider verify a bearer / Cf-Access-Jwt-Assertion JWT (JWKS)
9
+ // - HeaderIdentityProvider trust a header a verifying proxy already set
10
+ // - AnonymousIdentityProvider everyone is one shared anonymous user (dev)
11
+ //
12
+ // A Principal: { id, email?, name?, picture?, roles?, claims }.
13
+
14
+ import { TroveError } from '../errors.js';
15
+ import { verifyJwt, JwksClient, StaticJwks } from './jwt.js';
16
+
17
+ export class IdentityProvider {
18
+ /** @returns {Promise<Principal|null>} */
19
+ async authenticate(request) {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ /** Normalize varied IdP claim shapes into a Principal. */
25
+ export function principalFromClaims(claims) {
26
+ const id = claims.sub || claims.email || claims.user_id || claims.id;
27
+ if (!id) return null;
28
+ return {
29
+ id: String(id),
30
+ email: claims.email || null,
31
+ name: claims.name || claims.given_name || claims.preferred_username || (claims.email ? claims.email.split('@')[0] : null),
32
+ picture: claims.picture || null,
33
+ roles: claims.roles || claims.groups || [],
34
+ claims,
35
+ };
36
+ }
37
+
38
+ /**
39
+ * The credential on a request.
40
+ *
41
+ * `Cf-Access-Jwt-Assertion` is checked FIRST, and that order matters twice.
42
+ *
43
+ * Correctness: with Cloudflare's managed OAuth an agent holds an OPAQUE token, not a
44
+ * JWT. It sends that in `Authorization: Bearer`, Access resolves it at the edge, and
45
+ * the origin receives the real signed JWT in the assertion header. Reading Authorization
46
+ * first means picking up the opaque string, failing to decode it, and refusing a request
47
+ * that arrived with a perfectly good assertion attached.
48
+ *
49
+ * Security: the assertion header is set by the edge that just authenticated the request.
50
+ * The Authorization header is whatever the client typed. When both are present, the one
51
+ * we did not have to trust the client for is the one to believe.
52
+ */
53
+ function bearer(request) {
54
+ const assertion = request.headers.get('cf-access-jwt-assertion');
55
+ if (assertion) return assertion;
56
+ const auth = request.headers.get('authorization') || '';
57
+ const m = /^Bearer\s+(.+)$/i.exec(auth);
58
+ return m ? m[1] : null;
59
+ }
60
+
61
+ /**
62
+ * Everything Cloudflare Access needs, derived from the team name.
63
+ *
64
+ * Access is the deployment Trove was designed around, and configuring it by hand means
65
+ * writing the team domain into three settings that must agree: a JWKS URL with a
66
+ * `/cdn-cgi/access/certs` path nobody remembers, an issuer, and — since Access became an
67
+ * OAuth authorization server for agents — the authorization server too. They are all the
68
+ * same domain, so ask for it once.
69
+ *
70
+ * The audience is the Access **application** AUD tag, which is per-application and the
71
+ * one value that genuinely cannot be derived. Without it a token minted for any other
72
+ * app in the same Access account would be accepted here, so it is worth the argument.
73
+ *
74
+ * @param {{team: string, audience?: string|string[], required?: boolean}} cfg
75
+ */
76
+ export function cloudflareAccess({ team, audience, required = true, ...rest } = {}) {
77
+ const host = accessHost(team);
78
+ if (!host) throw TroveError.invalid('cloudflareAccess requires a team name, e.g. "myteam" or "myteam.cloudflareaccess.com"');
79
+ const issuer = `https://${host}`;
80
+ return {
81
+ jwksUrl: `${issuer}/cdn-cgi/access/certs`,
82
+ issuer,
83
+ audience: audience || undefined,
84
+ // Behind Access, every request has already been authenticated at the edge. An
85
+ // "anonymous" fallthrough would only fire when something is misconfigured, and
86
+ // silently serving the drive to a shared anonymous user is the wrong way to find out.
87
+ required,
88
+ ...rest,
89
+ };
90
+ }
91
+
92
+ /** Normalize `myteam`, `myteam.cloudflareaccess.com`, or the full URL to a hostname. */
93
+ export function accessHost(team) {
94
+ if (!team) return null;
95
+ let t = String(team).trim().replace(/^https?:\/\//, '').replace(/\/.*$/, '').toLowerCase();
96
+ if (!t) return null;
97
+ if (!t.includes('.')) t = `${t}.cloudflareaccess.com`;
98
+ // A team domain that isn't Cloudflare's is a typo we should not paper over: it would
99
+ // send token verification, and now agent sign-in, to somewhere unintended.
100
+ if (!t.endsWith('.cloudflareaccess.com')) {
101
+ throw TroveError.invalid(`"${team}" is not a Cloudflare Access team domain (expected <team>.cloudflareaccess.com)`);
102
+ }
103
+ return t;
104
+ }
105
+
106
+ export class JwtIdentityProvider extends IdentityProvider {
107
+ /**
108
+ * @param {object} cfg
109
+ * @param {string} [cfg.jwksUrl] e.g. https://<team>.cloudflareaccess.com/cdn-cgi/access/certs
110
+ * @param {object|object[]} [cfg.jwks] a JWKS document (or bare JWK array) to trust
111
+ * directly — the keychain for a deployment that mints its own tokens and has no
112
+ * JWKS endpoint to point at. Takes precedence over `jwksUrl`.
113
+ * @param {string|Uint8Array} [cfg.secret] for HS256 (dev)
114
+ * @param {string} [cfg.issuer]
115
+ * @param {string|string[]} [cfg.audience] the Access application AUD
116
+ * @param {boolean} [cfg.required] reject anonymous requests (default false)
117
+ * @param {(req)=>string|null} [cfg.getToken] override token extraction
118
+ */
119
+ constructor(cfg = {}) {
120
+ super();
121
+ this.cfg = cfg;
122
+ // A held key set beats a fetched one: if you named the keys explicitly, that is the
123
+ // stronger statement of intent, and it can't fail because a network hop did.
124
+ this.jwks = cfg.jwks ? new StaticJwks(cfg.jwks)
125
+ : cfg.jwksUrl ? new JwksClient(cfg.jwksUrl, { fetch: cfg.fetch })
126
+ : null;
127
+ this.getToken = cfg.getToken || bearer;
128
+ }
129
+ async authenticate(request) {
130
+ const token = this.getToken(request);
131
+ if (!token) {
132
+ if (this.cfg.required) throw TroveError.unauthorized('Authentication required');
133
+ return null;
134
+ }
135
+ let claims;
136
+ try {
137
+ claims = await verifyJwt(token, {
138
+ jwks: this.jwks, secret: this.cfg.secret,
139
+ issuer: this.cfg.issuer, audience: this.cfg.audience,
140
+ algorithms: this.cfg.algorithms, now: this.cfg.now,
141
+ });
142
+ } catch (err) {
143
+ // A credential we can't parse is an AUTHENTICATION failure, not a malformed
144
+ // request: `decodeJwt` reports a garbled token as `invalid` (400), which tells a
145
+ // client "you sent a bad request" when the truth is "sign in again". A transient
146
+ // failure (the JWKS endpoint being down) is left alone — that is not the user's
147
+ // token being wrong, and retrying is the right response to it.
148
+ if (err?.code === 'transient') throw err;
149
+ throw TroveError.unauthorized(err?.message || 'Authentication failed', { cause: err });
150
+ }
151
+ const principal = principalFromClaims(claims);
152
+ if (!principal) throw TroveError.unauthorized('JWT has no subject');
153
+ return principal;
154
+ }
155
+ }
156
+
157
+ /** Trust a header set by a verifying reverse proxy (already authenticated). */
158
+ export class HeaderIdentityProvider extends IdentityProvider {
159
+ /** @param {{idHeader?: string, emailHeader?: string, nameHeader?: string, required?: boolean}} cfg */
160
+ constructor(cfg = {}) {
161
+ super();
162
+ this.cfg = { idHeader: 'x-auth-user-id', emailHeader: 'x-auth-email', nameHeader: 'x-auth-name', ...cfg };
163
+ }
164
+ async authenticate(request) {
165
+ const id = request.headers.get(this.cfg.idHeader) || request.headers.get(this.cfg.emailHeader);
166
+ if (!id) {
167
+ if (this.cfg.required) throw TroveError.unauthorized('Authentication required');
168
+ return null;
169
+ }
170
+ const email = request.headers.get(this.cfg.emailHeader);
171
+ return principalFromClaims({ sub: id, email, name: request.headers.get(this.cfg.nameHeader) });
172
+ }
173
+ }
174
+
175
+ /** Everyone is the same anonymous user — the zero-config default. */
176
+ export class AnonymousIdentityProvider extends IdentityProvider {
177
+ constructor({ id = 'anonymous', name = 'Anonymous' } = {}) {
178
+ super();
179
+ // `anonymous: true` is what lets everything downstream tell "one shared unnamed
180
+ // user" apart from "a person who signed in". Without it the shape is identical to a
181
+ // real principal, and the UI ends up showing a profile for somebody who doesn't
182
+ // exist — an avatar, a name, a menu, all describing nobody.
183
+ this.principal = { id, email: null, name, picture: null, roles: [], anonymous: true, claims: {} };
184
+ }
185
+ async authenticate() {
186
+ return this.principal;
187
+ }
188
+ }
@@ -0,0 +1,199 @@
1
+ // JWT verification on Web Crypto — no node crypto, no deps, so it runs on Node,
2
+ // Bun, and Cloudflare Workers. Supports the algorithms an identity provider like
3
+ // Cloudflare Access / Zero Trust actually issues (RS256, ES256 via a JWKS
4
+ // endpoint) plus HS256 (shared secret) for local/dev. Trove never issues tokens;
5
+ // it only VERIFIES the one a trusted IdP put on the request, then builds a
6
+ // profile from the claims.
7
+
8
+ import { TroveError } from '../errors.js';
9
+
10
+ const enc = new TextEncoder();
11
+ // How long a `kid` miss suppresses another JWKS refetch. Long enough that a flood of
12
+ // unknown kids costs one request, short enough that a real key rotation is picked up
13
+ // well inside a token's lifetime.
14
+ const MISS_REFRESH_COOLDOWN_MS = 30_000;
15
+
16
+ export function base64urlToBytes(str) {
17
+ const b64 = str.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(str.length / 4) * 4, '=');
18
+ const bin = atob(b64);
19
+ const out = new Uint8Array(bin.length);
20
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
21
+ return out;
22
+ }
23
+ function base64urlToString(str) {
24
+ return new TextDecoder().decode(base64urlToBytes(str));
25
+ }
26
+
27
+ /** Split & decode without verifying (header + payload only). */
28
+ export function decodeJwt(token) {
29
+ const parts = token.split('.');
30
+ if (parts.length !== 3) throw TroveError.invalid('Malformed JWT');
31
+ let header, payload;
32
+ try {
33
+ header = JSON.parse(base64urlToString(parts[0]));
34
+ payload = JSON.parse(base64urlToString(parts[1]));
35
+ } catch {
36
+ throw TroveError.invalid('JWT is not valid JSON');
37
+ }
38
+ return { header, payload, parts };
39
+ }
40
+
41
+ const ALGS = {
42
+ RS256: { import: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, verify: { name: 'RSASSA-PKCS1-v1_5' } },
43
+ ES256: { import: { name: 'ECDSA', namedCurve: 'P-256' }, verify: { name: 'ECDSA', hash: 'SHA-256' } },
44
+ HS256: { import: { name: 'HMAC', hash: 'SHA-256' }, verify: { name: 'HMAC' } },
45
+ };
46
+
47
+ /** A JWKS resolver with a small in-memory cache; refetches on a kid miss. */
48
+ export class JwksClient {
49
+ constructor(url, { fetch: f = globalThis.fetch?.bind(globalThis), ttlMs = 3600_000 } = {}) {
50
+ if (!url) throw TroveError.invalid('JwksClient requires a url');
51
+ this.url = url;
52
+ this._fetch = f;
53
+ this.ttlMs = ttlMs;
54
+ this.keys = new Map(); // kid -> jwk
55
+ this.fetchedAt = 0;
56
+ }
57
+ async #refresh(force) {
58
+ const now = this._now();
59
+ if (!force && this.keys.size && now - this.fetchedAt < this.ttlMs) return;
60
+ let res;
61
+ try {
62
+ res = await this._fetch(this.url);
63
+ } catch (err) {
64
+ throw TroveError.transient('Could not fetch JWKS', { cause: err });
65
+ }
66
+ if (!res.ok) throw TroveError.transient(`JWKS fetch failed (${res.status})`);
67
+ const json = await res.json();
68
+ this.keys = new Map((json.keys || []).map((k) => [k.kid, k]));
69
+ this.fetchedAt = now;
70
+ }
71
+ _now() {
72
+ // Date.now() is unavailable in some sandboxes; tolerate its absence.
73
+ try {
74
+ return Date.now();
75
+ } catch {
76
+ return this.fetchedAt || 0;
77
+ }
78
+ }
79
+ async getJwk(kid) {
80
+ await this.#refresh(false);
81
+ // A miss forces a refetch so a freshly-rotated key is picked up — but ONLY if we
82
+ // haven't just done that. Unbounded, this ran during authentication, before any
83
+ // credential was checked, so anyone who could reach the API pinned one outbound
84
+ // HTTPS request to the IdP per inbound request simply by varying `kid`.
85
+ if (!this.keys.has(kid) && Date.now() - (this._lastMissRefresh || 0) > MISS_REFRESH_COOLDOWN_MS) {
86
+ this._lastMissRefresh = Date.now();
87
+ await this.#refresh(true);
88
+ }
89
+ return this.keys.get(kid) || null;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * A fixed set of trusted keys — a JWKS you hold rather than one you fetch.
95
+ *
96
+ * Same interface as JwksClient, so `verifyJwt` can't tell them apart. It exists because
97
+ * a JWKS URL assumes someone is running an endpoint to serve it, and plenty of
98
+ * deployments simply mint their own tokens: a small team, a script, a gateway that
99
+ * signs with a key you already have. Pointing those at a URL means standing up an HTTP
100
+ * server whose entire job is to hand back a JSON document you could have pasted in.
101
+ *
102
+ * A token whose `kid` isn't in the set is refused. A set with exactly one key accepts a
103
+ * token with no `kid` at all, since there is no ambiguity about which key was meant —
104
+ * but with several, an unlabelled token is rejected rather than tried against each,
105
+ * because "try every key until one verifies" turns key rotation into key confusion.
106
+ */
107
+ export class StaticJwks {
108
+ /** @param {{keys: object[]}|object[]} jwks a JWKS document or a bare array of JWKs */
109
+ constructor(jwks) {
110
+ const keys = Array.isArray(jwks) ? jwks : jwks?.keys;
111
+ if (!Array.isArray(keys) || !keys.length) throw TroveError.invalid('StaticJwks requires at least one JWK');
112
+ this.list = keys;
113
+ this.keys = new Map(keys.filter((k) => k.kid).map((k) => [k.kid, k]));
114
+ }
115
+ async getJwk(kid) {
116
+ if (kid) return this.keys.get(kid) || null;
117
+ return this.list.length === 1 ? this.list[0] : null;
118
+ }
119
+ }
120
+
121
+ async function importVerifyKey(alg, key) {
122
+ const spec = ALGS[alg];
123
+ if (!spec) throw TroveError.unsupported(`Unsupported JWT alg ${alg}`);
124
+ if (alg === 'HS256') {
125
+ const raw = typeof key === 'string' ? enc.encode(key) : key;
126
+ return crypto.subtle.importKey('raw', raw, spec.import, false, ['verify']);
127
+ }
128
+ // key is a JWK object.
129
+ return crypto.subtle.importKey('jwk', key, spec.import, false, ['verify']);
130
+ }
131
+
132
+ /**
133
+ * Verify a JWT and return its payload, or throw a TroveError.
134
+ * @param {string} token
135
+ * @param {object} opts
136
+ * @param {JwksClient} [opts.jwks] for RS256/ES256
137
+ * @param {string|Uint8Array} [opts.secret] for HS256
138
+ * @param {string} [opts.issuer] required `iss`
139
+ * @param {string|string[]} [opts.audience] required `aud` (any match)
140
+ * @param {string[]} [opts.algorithms] allow-list (default derived from key material)
141
+ * @param {number} [opts.clockToleranceSec]
142
+ * @param {number|null} [opts.now] ms epoch; pass null to say there is no clock
143
+ */
144
+ export async function verifyJwt(token, opts = {}) {
145
+ const { header, payload, parts } = decodeJwt(token);
146
+ const alg = header.alg;
147
+ const allowed = opts.algorithms || (opts.secret ? ['HS256'] : ['RS256', 'ES256']);
148
+ if (!allowed.includes(alg)) throw TroveError.unauthorized(`JWT alg ${alg} not allowed`);
149
+
150
+ let key;
151
+ if (alg === 'HS256') {
152
+ if (!opts.secret) throw TroveError.unauthorized('No secret configured for HS256');
153
+ key = await importVerifyKey(alg, opts.secret);
154
+ } else {
155
+ if (!opts.jwks) throw TroveError.unauthorized('No JWKS configured');
156
+ const jwk = await opts.jwks.getJwk(header.kid);
157
+ if (!jwk) throw TroveError.unauthorized(`No JWKS key for kid ${header.kid}`);
158
+ key = await importVerifyKey(alg, jwk);
159
+ }
160
+
161
+ const signingInput = enc.encode(parts[0] + '.' + parts[1]);
162
+ const signature = base64urlToBytes(parts[2]);
163
+ const ok = await crypto.subtle.verify(ALGS[alg].verify, key, signature, signingInput);
164
+ if (!ok) throw TroveError.unauthorized('JWT signature is invalid');
165
+
166
+ // Claims.
167
+ // undefined means "not specified" (use the real clock); null means "there is no
168
+ // clock". `??` would collapse the two, hiding the very case being configured — and
169
+ // callers routinely pass `now: cfg.now` with cfg.now undefined.
170
+ const nowMs = opts.now === undefined ? safeNow() : opts.now;
171
+ const skew = opts.clockToleranceSec ?? 60;
172
+ // No clock means no way to honour `exp`. Refusing is the only safe answer: treating
173
+ // an unreadable clock as "not expired yet" would accept a token that expired last
174
+ // year, which is precisely the failure expiry exists to prevent. Tokens carrying no
175
+ // time claims are unaffected — there is nothing to check.
176
+ if (nowMs == null && (payload.exp != null || payload.nbf != null)) {
177
+ throw TroveError.unauthorized('JWT carries time claims but this runtime has no clock to check them against');
178
+ }
179
+ const now = Math.floor(nowMs / 1000);
180
+ if (payload.exp != null && now > payload.exp + skew) throw TroveError.unauthorized('JWT expired');
181
+ if (payload.nbf != null && now + skew < payload.nbf) throw TroveError.unauthorized('JWT not yet valid');
182
+ if (opts.issuer && payload.iss !== opts.issuer) throw TroveError.unauthorized('JWT issuer mismatch');
183
+ if (opts.audience) {
184
+ const auds = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
185
+ const want = Array.isArray(opts.audience) ? opts.audience : [opts.audience];
186
+ if (!auds.some((a) => want.includes(a))) throw TroveError.unauthorized('JWT audience mismatch');
187
+ }
188
+ return payload;
189
+ }
190
+
191
+ /** ms epoch, or null when the runtime has no clock. Null, NOT 0 — see verifyJwt. */
192
+ function safeNow() {
193
+ try {
194
+ const t = Date.now();
195
+ return Number.isFinite(t) && t > 0 ? t : null;
196
+ } catch {
197
+ return null;
198
+ }
199
+ }