@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.
- package/README.md +1227 -0
- package/package.json +75 -0
- package/packages/core/src/collections/index.js +249 -0
- package/packages/core/src/errors.js +186 -0
- package/packages/core/src/identity/discovery.js +210 -0
- package/packages/core/src/identity/index.js +188 -0
- package/packages/core/src/identity/jwt.js +199 -0
- package/packages/core/src/index.js +104 -0
- package/packages/core/src/indexers/contribution.js +115 -0
- package/packages/core/src/indexers/registry.js +162 -0
- package/packages/core/src/indexing.js +340 -0
- package/packages/core/src/issues.js +150 -0
- package/packages/core/src/kv.js +0 -0
- package/packages/core/src/links.js +141 -0
- package/packages/core/src/metadata/cursor.js +73 -0
- package/packages/core/src/metadata/interface.js +244 -0
- package/packages/core/src/metadata/memory.js +270 -0
- package/packages/core/src/metadata/sqlite.js +412 -0
- package/packages/core/src/notifications/index.js +139 -0
- package/packages/core/src/notifications/webpush.js +217 -0
- package/packages/core/src/plugins/contributions.js +177 -0
- package/packages/core/src/plugins/identity.js +98 -0
- package/packages/core/src/plugins/index.js +225 -0
- package/packages/core/src/plugins/indexers.js +142 -0
- package/packages/core/src/plugins/installStore.js +134 -0
- package/packages/core/src/plugins/package.js +102 -0
- package/packages/core/src/plugins/packageStore.js +61 -0
- package/packages/core/src/plugins/runtime.js +101 -0
- package/packages/core/src/plugins/sql.js +52 -0
- package/packages/core/src/retry.js +74 -0
- package/packages/core/src/scan.js +302 -0
- package/packages/core/src/search/embeddings.js +128 -0
- package/packages/core/src/search/index.js +200 -0
- package/packages/core/src/search/keywordStore.js +107 -0
- package/packages/core/src/search/sqliteStores.js +455 -0
- package/packages/core/src/search/tagMatch.js +59 -0
- package/packages/core/src/search/transformer.js +195 -0
- package/packages/core/src/search/vectorStore.js +274 -0
- package/packages/core/src/search/vectorize.js +249 -0
- package/packages/core/src/sidecar/document.js +213 -0
- package/packages/core/src/sidecar/index.js +174 -0
- package/packages/core/src/sidecar/manager.js +239 -0
- package/packages/core/src/sidecar/store.js +46 -0
- package/packages/core/src/signedUrls.js +170 -0
- package/packages/core/src/sqlite-d1.js +162 -0
- package/packages/core/src/sqlite-driver.js +42 -0
- package/packages/core/src/sqlite.js +162 -0
- package/packages/core/src/storage/filesystem.js +283 -0
- package/packages/core/src/storage/interface.js +222 -0
- package/packages/core/src/storage/memory.js +113 -0
- package/packages/core/src/storage/prefixed.js +75 -0
- package/packages/core/src/storage/s3.js +316 -0
- package/packages/core/src/storage/s3sigv4.js +185 -0
- package/packages/core/src/tasks.js +228 -0
- package/packages/core/src/uploads.js +386 -0
- package/packages/core/src/util.js +125 -0
- package/packages/core/src/vfs.js +666 -0
- package/packages/plugin-sdk/src/browser.js +316 -0
- package/packages/plugin-sdk/src/index.js +32 -0
- package/packages/plugin-sdk/src/protocol.js +59 -0
- package/packages/plugin-sdk/src/rpc.js +95 -0
- package/packages/server/src/adapters/bun.js +78 -0
- package/packages/server/src/adapters/node.js +115 -0
- package/packages/server/src/adapters/staticAssets.js +123 -0
- package/packages/server/src/adapters/webDist.js +70 -0
- package/packages/server/src/adapters/worker-tasks.js +206 -0
- package/packages/server/src/adapters/worker.js +159 -0
- package/packages/server/src/cachePolicy.js +34 -0
- package/packages/server/src/engine/README.md +88 -0
- package/packages/server/src/engine/actions/scanCollection.js +114 -0
- package/packages/server/src/engine/index.js +95 -0
- package/packages/server/src/engine/lazy.js +25 -0
- package/packages/server/src/engine/providers/access.js +363 -0
- package/packages/server/src/engine/providers/core.js +405 -0
- package/packages/server/src/engine/providers/scan.js +67 -0
- package/packages/server/src/index.js +698 -0
- package/packages/server/src/manifest.js +98 -0
- package/packages/server/src/mcp/auth.js +40 -0
- package/packages/server/src/mcp/index.js +213 -0
- package/packages/server/src/mcp/protocol.js +181 -0
- package/packages/server/src/mcp/tools.js +351 -0
- package/packages/server/src/router.js +229 -0
- package/packages/server/src/routes.js +1066 -0
- package/packages/server/src/scope.js +43 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
- package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
- package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
- package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
- package/packages/web/dist/icon.svg +11 -0
- package/packages/web/dist/index.html +16 -0
- package/packages/web/dist/sql-wasm.wasm +0 -0
- package/packages/web/dist/sw.js +186 -0
- package/packages/web/src/bl/actions.js +410 -0
- package/packages/web/src/bl/activity.js +306 -0
- package/packages/web/src/bl/commands.js +274 -0
- package/packages/web/src/bl/fileType.js +49 -0
- package/packages/web/src/bl/index.js +70 -0
- package/packages/web/src/bl/links.js +54 -0
- package/packages/web/src/bl/offline.js +268 -0
- package/packages/web/src/bl/openers.js +71 -0
- package/packages/web/src/bl/pluginInstall.js +59 -0
- package/packages/web/src/bl/services.js +143 -0
- package/packages/web/src/bl/social.js +234 -0
- package/packages/web/src/bl/tagQuery.js +44 -0
- package/packages/web/src/main.js +10 -0
- package/packages/web/src/platform/api.js +529 -0
- package/packages/web/src/platform/commands.js +89 -0
- package/packages/web/src/platform/context.js +77 -0
- package/packages/web/src/platform/contributions.js +156 -0
- package/packages/web/src/platform/index.js +150 -0
- package/packages/web/src/platform/keybindings.js +199 -0
- package/packages/web/src/platform/mediaUrls.js +137 -0
- package/packages/web/src/platform/navigation.js +131 -0
- package/packages/web/src/platform/notifications.js +50 -0
- package/packages/web/src/platform/overlay.js +81 -0
- package/packages/web/src/platform/pluginClientDb.js +132 -0
- package/packages/web/src/platform/pluginDock.js +141 -0
- package/packages/web/src/platform/pluginFrames.js +194 -0
- package/packages/web/src/platform/pluginHost.js +648 -0
- package/packages/web/src/platform/pluginMedia.js +62 -0
- package/packages/web/src/platform/pluginModules.js +90 -0
- package/packages/web/src/platform/pluginNet.js +71 -0
- package/packages/web/src/platform/pluginPackage.js +247 -0
- package/packages/web/src/platform/pluginRpc.js +377 -0
- package/packages/web/src/platform/pluginSigning.js +168 -0
- package/packages/web/src/platform/pluginStore.js +67 -0
- package/packages/web/src/platform/settings.js +101 -0
- package/packages/web/src/platform/spatialNav.js +286 -0
- package/packages/web/src/platform/viewport.js +123 -0
- package/packages/web/src/platform/voice.js +133 -0
- package/packages/web/src/platform/voiceSearch.js +155 -0
- package/packages/web/src/platform/whenclause.js +162 -0
- package/packages/web/src/platform/workbench.js +156 -0
- package/packages/web/src/runtime.js +73 -0
- package/packages/web/src/styles.css +1382 -0
- package/packages/web/src/ui/components/activityBar.js +35 -0
- package/packages/web/src/ui/components/activityPanel.js +132 -0
- package/packages/web/src/ui/components/commandPalette.js +154 -0
- package/packages/web/src/ui/components/editorArea.js +75 -0
- package/packages/web/src/ui/components/launcher.js +392 -0
- package/packages/web/src/ui/components/openers/index.js +212 -0
- package/packages/web/src/ui/components/openers/markdown.js +222 -0
- package/packages/web/src/ui/components/overlays.js +255 -0
- package/packages/web/src/ui/components/phoneChrome.js +188 -0
- package/packages/web/src/ui/components/pluginReview.js +151 -0
- package/packages/web/src/ui/components/pluginsView.js +120 -0
- package/packages/web/src/ui/components/settingsView.js +258 -0
- package/packages/web/src/ui/components/social.js +290 -0
- package/packages/web/src/ui/components/statusBar.js +198 -0
- package/packages/web/src/ui/components/views/grid.js +115 -0
- package/packages/web/src/ui/components/views/index.js +155 -0
- package/packages/web/src/ui/components/views/list.js +50 -0
- package/packages/web/src/ui/components/views/parts.js +58 -0
- package/packages/web/src/ui/compositions/workbench.js +125 -0
- package/packages/web/src/ui/format.js +33 -0
- package/packages/web/src/ui/icon.js +81 -0
- package/packages/web/src/ui/media.js +114 -0
- package/packages/web/src/ui/sanitize.js +86 -0
- 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
|
+
}
|