@coffer-org/server 1.7.1 → 1.9.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/dist/auth-api.d.ts +1 -0
- package/dist/auth-api.js +20 -10
- package/dist/background-scheduler.d.ts +22 -0
- package/dist/background-scheduler.js +101 -0
- package/dist/collection-io.d.ts +7 -7
- package/dist/collection-io.js +2 -2
- package/dist/connector-identity.d.ts +5 -0
- package/dist/connector-identity.js +32 -0
- package/dist/embed-openai.d.ts +10 -0
- package/dist/embed-openai.js +28 -0
- package/dist/entity-schema.d.ts +9 -5
- package/dist/entity-schema.js +64 -10
- package/dist/extend-io.d.ts +5 -3
- package/dist/extend-io.js +17 -10
- package/dist/extend-table.d.ts +4 -3
- package/dist/extend-table.js +10 -18
- package/dist/field-masking.d.ts +7 -0
- package/dist/field-masking.js +60 -0
- package/dist/index-signal.d.ts +3 -0
- package/dist/index-signal.js +14 -0
- package/dist/index.js +68 -160
- package/dist/local-api.d.ts +3 -3
- package/dist/local-api.js +6 -6
- package/dist/mcp-http.d.ts +5 -0
- package/dist/mcp-http.js +37 -0
- package/dist/mcp-http.test-helpers.d.ts +17 -0
- package/dist/mcp-http.test-helpers.js +117 -0
- package/dist/mcp-local.d.ts +10 -0
- package/dist/mcp-local.js +57 -0
- package/dist/mcp-tools.d.ts +61 -0
- package/dist/mcp-tools.js +225 -0
- package/dist/msg-log.d.ts +0 -1
- package/dist/msg-log.js +2 -2
- package/dist/mutate.d.ts +7 -5
- package/dist/mutate.js +20 -8
- package/dist/oauth-api.d.ts +2 -0
- package/dist/oauth-api.js +281 -0
- package/dist/oauth-store.d.ts +56 -0
- package/dist/oauth-store.js +159 -0
- package/dist/plugin-hooks.d.ts +10 -0
- package/dist/plugin-hooks.js +8 -0
- package/dist/plugin-runtime.d.ts +1 -0
- package/dist/plugin-runtime.js +30 -6
- package/dist/plugins-api.d.ts +1 -1
- package/dist/plugins-api.js +21 -11
- package/dist/public-url.d.ts +4 -0
- package/dist/public-url.js +35 -0
- package/dist/records-api.d.ts +8 -8
- package/dist/records-api.js +35 -32
- package/dist/registry-context.d.ts +1 -1
- package/dist/registry-context.js +2 -2
- package/dist/schema-api.js +5 -5
- package/dist/settings-write.d.ts +19 -0
- package/dist/settings-write.js +63 -0
- package/dist/temporal.d.ts +3 -3
- package/dist/temporal.js +1 -1
- package/dist/thread-store.d.ts +20 -0
- package/dist/thread-store.js +27 -0
- package/dist/uploads.d.ts +1 -0
- package/dist/uploads.js +4 -0
- package/package.json +6 -6
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { createCode, findClient, issueTokens, listGrants, redeemCode, registerClient, revokeGrant, rotateRefresh, } from "./oauth-store.js";
|
|
2
|
+
import { resolveRequestUser, startPasswordSession } from "./auth-api.js";
|
|
3
|
+
import { baseUrl, mcpResource } from "./public-url.js";
|
|
4
|
+
import { getLogger } from "./log.js";
|
|
5
|
+
const log = getLogger('oauth');
|
|
6
|
+
const DEFAULT_REDIRECT_ALLOW = [
|
|
7
|
+
'https://claude.ai/api/mcp/auth_callback',
|
|
8
|
+
'https://claude.com/api/mcp/auth_callback',
|
|
9
|
+
];
|
|
10
|
+
function redirectAllowlist() {
|
|
11
|
+
const extra = (process.env['OAUTH_REDIRECT_ALLOW'] ?? '')
|
|
12
|
+
.split(',')
|
|
13
|
+
.map((s) => s.trim())
|
|
14
|
+
.filter(Boolean);
|
|
15
|
+
return [...DEFAULT_REDIRECT_ALLOW, ...extra];
|
|
16
|
+
}
|
|
17
|
+
function allowInsecure() {
|
|
18
|
+
return process.env['OAUTH_ALLOW_INSECURE'] === '1';
|
|
19
|
+
}
|
|
20
|
+
const hits = new Map();
|
|
21
|
+
const RATE_WINDOW_MS = 60_000;
|
|
22
|
+
function rateLimited(key, max) {
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
const cur = hits.get(key);
|
|
25
|
+
if (!cur || cur.resetAt < now) {
|
|
26
|
+
hits.set(key, { count: 1, resetAt: now + RATE_WINDOW_MS });
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
cur.count += 1;
|
|
30
|
+
return cur.count > max;
|
|
31
|
+
}
|
|
32
|
+
function esc(s) {
|
|
33
|
+
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
34
|
+
}
|
|
35
|
+
function page(title, body) {
|
|
36
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
37
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
38
|
+
<title>${esc(title)}</title>
|
|
39
|
+
<style>
|
|
40
|
+
body{font:16px/1.5 system-ui,sans-serif;background:#f6f6f5;color:#1a1a19;margin:0;
|
|
41
|
+
display:flex;min-height:100vh;align-items:center;justify-content:center}
|
|
42
|
+
.card{background:#fff;border:1px solid #e3e3e0;border-radius:12px;padding:28px;max-width:26rem;width:calc(100% - 2rem)}
|
|
43
|
+
h1{font-size:1.15rem;margin:0 0 .75rem}
|
|
44
|
+
p{margin:.5rem 0;color:#57564f}
|
|
45
|
+
label{display:block;margin:.75rem 0 .25rem;font-size:.85rem;color:#57564f}
|
|
46
|
+
input{width:100%;box-sizing:border-box;padding:.55rem .65rem;border:1px solid #d5d4cf;border-radius:8px;font:inherit}
|
|
47
|
+
button{margin-top:1.25rem;width:100%;padding:.6rem;border:0;border-radius:8px;background:#1a1a19;color:#fff;font:inherit;cursor:pointer}
|
|
48
|
+
.muted{font-size:.85rem}
|
|
49
|
+
.err{color:#b42318}
|
|
50
|
+
@media(prefers-color-scheme:dark){body{background:#1a1a19;color:#f6f6f5}.card{background:#232320;border-color:#3a3a36}
|
|
51
|
+
p,label{color:#a8a79f}input{background:#1a1a19;color:#f6f6f5;border-color:#3a3a36}button{background:#f6f6f5;color:#1a1a19}}
|
|
52
|
+
</style></head><body><div class="card">${body}</div></body></html>`;
|
|
53
|
+
}
|
|
54
|
+
function hidden(params) {
|
|
55
|
+
return Object.entries(params)
|
|
56
|
+
.map(([k, v]) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`)
|
|
57
|
+
.join('');
|
|
58
|
+
}
|
|
59
|
+
function readAuthzParams(src) {
|
|
60
|
+
const s = (k) => (typeof src[k] === 'string' ? src[k] : '');
|
|
61
|
+
return {
|
|
62
|
+
client_id: s('client_id'),
|
|
63
|
+
redirect_uri: s('redirect_uri'),
|
|
64
|
+
state: s('state'),
|
|
65
|
+
code_challenge: s('code_challenge'),
|
|
66
|
+
resource: s('resource'),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function loginForm(p, clientName, error) {
|
|
70
|
+
return page('Sign in to Coffer', `<h1>Sign in to Coffer</h1>
|
|
71
|
+
<p><b>${esc(clientName)}</b> wants access to your data. Sign in to continue.</p>
|
|
72
|
+
${error ? `<p class="err">${esc(error)}</p>` : ''}
|
|
73
|
+
<form method="post" action="/oauth/authorize">
|
|
74
|
+
${hidden({ ...p, action: 'login' })}
|
|
75
|
+
<label for="login">Login</label><input id="login" name="login" autocomplete="username" autofocus>
|
|
76
|
+
<label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password">
|
|
77
|
+
<button type="submit">Sign in</button>
|
|
78
|
+
</form>`);
|
|
79
|
+
}
|
|
80
|
+
function consentForm(p, clientName, user) {
|
|
81
|
+
const roleLine = user.role === 'admin'
|
|
82
|
+
? 'Your account is an <b>administrator</b> — the app will also get the admin-only tools of installed plugins, including settings.'
|
|
83
|
+
: 'Your account is a <b>member</b> — the app gets read/write access to your records, but no admin tools.';
|
|
84
|
+
return page('Authorize access', `<h1>Authorize ${esc(clientName)}</h1>
|
|
85
|
+
<p>It will be able to read, create, update and delete records in your Coffer as <b>${esc(user.login)}</b>.</p>
|
|
86
|
+
<p class="muted">${roleLine}</p>
|
|
87
|
+
<form method="post" action="/oauth/authorize">
|
|
88
|
+
${hidden({ ...p, action: 'approve' })}
|
|
89
|
+
<button type="submit">Authorize</button>
|
|
90
|
+
</form>
|
|
91
|
+
<p class="muted">You can revoke this at any time in Settings → Connected apps.</p>`);
|
|
92
|
+
}
|
|
93
|
+
function redirectWithError(reply, p, error) {
|
|
94
|
+
const url = new URL(p.redirect_uri);
|
|
95
|
+
url.searchParams.set('error', error);
|
|
96
|
+
if (p.state)
|
|
97
|
+
url.searchParams.set('state', p.state);
|
|
98
|
+
return reply.redirect(url.toString(), 302);
|
|
99
|
+
}
|
|
100
|
+
export function registerOAuthApi(app) {
|
|
101
|
+
app.addContentTypeParser('application/x-www-form-urlencoded', { parseAs: 'string' }, (_req, body, done) => {
|
|
102
|
+
try {
|
|
103
|
+
done(null, Object.fromEntries(new URLSearchParams(body)));
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
done(e, undefined);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
app.get('/.well-known/oauth-protected-resource', async (req) => ({
|
|
110
|
+
resource: await mcpResource(req),
|
|
111
|
+
authorization_servers: [await baseUrl(req)],
|
|
112
|
+
bearer_methods_supported: ['header'],
|
|
113
|
+
}));
|
|
114
|
+
app.get('/.well-known/oauth-protected-resource/mcp', async (req) => ({
|
|
115
|
+
resource: await mcpResource(req),
|
|
116
|
+
authorization_servers: [await baseUrl(req)],
|
|
117
|
+
bearer_methods_supported: ['header'],
|
|
118
|
+
}));
|
|
119
|
+
const asMetadata = async (req) => {
|
|
120
|
+
const base = await baseUrl(req);
|
|
121
|
+
return {
|
|
122
|
+
issuer: base,
|
|
123
|
+
authorization_endpoint: `${base}/oauth/authorize`,
|
|
124
|
+
token_endpoint: `${base}/oauth/token`,
|
|
125
|
+
registration_endpoint: `${base}/oauth/register`,
|
|
126
|
+
response_types_supported: ['code'],
|
|
127
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
128
|
+
code_challenge_methods_supported: ['S256'],
|
|
129
|
+
token_endpoint_auth_methods_supported: ['none'],
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
app.get('/.well-known/oauth-authorization-server', async (req) => asMetadata(req));
|
|
133
|
+
app.get('/.well-known/oauth-authorization-server/mcp', async (req) => asMetadata(req));
|
|
134
|
+
app.post('/oauth/register', async (req, reply) => {
|
|
135
|
+
if (rateLimited(`reg:${req.ip}`, 10))
|
|
136
|
+
return reply.code(429).send({ error: 'rate_limited' });
|
|
137
|
+
const body = (req.body ?? {});
|
|
138
|
+
const uris = Array.isArray(body.redirect_uris) ? body.redirect_uris.filter((u) => typeof u === 'string') : [];
|
|
139
|
+
if (uris.length === 0) {
|
|
140
|
+
return reply.code(400).send({ error: 'invalid_redirect_uri', error_description: 'redirect_uris is required' });
|
|
141
|
+
}
|
|
142
|
+
const allow = redirectAllowlist();
|
|
143
|
+
const bad = uris.find((u) => !allow.includes(u));
|
|
144
|
+
if (bad) {
|
|
145
|
+
log.warn(`registration rejected: redirect_uri not allowlisted (${bad})`);
|
|
146
|
+
return reply
|
|
147
|
+
.code(400)
|
|
148
|
+
.send({ error: 'invalid_redirect_uri', error_description: `redirect_uri not allowed: ${bad}` });
|
|
149
|
+
}
|
|
150
|
+
const client = await registerClient({ clientName: body.client_name?.slice(0, 200) || 'MCP client', redirectUris: uris });
|
|
151
|
+
log.info(`registered client ${client.clientId} (${client.clientName})`);
|
|
152
|
+
return reply.code(201).send({
|
|
153
|
+
client_id: client.clientId,
|
|
154
|
+
client_name: client.clientName,
|
|
155
|
+
redirect_uris: client.redirectUris,
|
|
156
|
+
token_endpoint_auth_method: 'none',
|
|
157
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
158
|
+
response_types: ['code'],
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
async function validateAuthz(p) {
|
|
162
|
+
if (!p.client_id || !p.redirect_uri) {
|
|
163
|
+
return { ok: false, html: page('Invalid request', '<h1>Invalid request</h1><p>Missing client_id or redirect_uri.</p>') };
|
|
164
|
+
}
|
|
165
|
+
const client = await findClient(p.client_id);
|
|
166
|
+
if (!client) {
|
|
167
|
+
return { ok: false, html: page('Unknown client', '<h1>Unknown client</h1><p>This application is not registered.</p>') };
|
|
168
|
+
}
|
|
169
|
+
if (!client.redirectUris.includes(p.redirect_uri)) {
|
|
170
|
+
return { ok: false, html: page('Invalid redirect', '<h1>Invalid redirect</h1><p>redirect_uri does not match this client.</p>') };
|
|
171
|
+
}
|
|
172
|
+
return { ok: true, clientName: client.clientName };
|
|
173
|
+
}
|
|
174
|
+
app.get('/oauth/authorize', async (req, reply) => {
|
|
175
|
+
const p = readAuthzParams((req.query ?? {}));
|
|
176
|
+
const check = await validateAuthz(p);
|
|
177
|
+
if (!check.ok)
|
|
178
|
+
return reply.code(400).type('text/html').send(check.html);
|
|
179
|
+
const q = (req.query ?? {});
|
|
180
|
+
if (q['response_type'] !== 'code')
|
|
181
|
+
return redirectWithError(reply, p, 'unsupported_response_type');
|
|
182
|
+
if (q['code_challenge_method'] !== 'S256' || !p.code_challenge) {
|
|
183
|
+
return redirectWithError(reply, p, 'invalid_request');
|
|
184
|
+
}
|
|
185
|
+
const user = await resolveRequestUser(req);
|
|
186
|
+
const html = user ? consentForm(p, check.clientName, user) : loginForm(p, check.clientName);
|
|
187
|
+
return reply.type('text/html').send(html);
|
|
188
|
+
});
|
|
189
|
+
app.post('/oauth/authorize', async (req, reply) => {
|
|
190
|
+
const body = (req.body ?? {});
|
|
191
|
+
const p = readAuthzParams(body);
|
|
192
|
+
const check = await validateAuthz(p);
|
|
193
|
+
if (!check.ok)
|
|
194
|
+
return reply.code(400).type('text/html').send(check.html);
|
|
195
|
+
if (body['action'] === 'login') {
|
|
196
|
+
if (rateLimited(`login:${req.ip}`, 20))
|
|
197
|
+
return reply.code(429).type('text/html').send(page('Slow down', '<h1>Too many attempts</h1>'));
|
|
198
|
+
const user = await startPasswordSession(reply, String(body['login'] ?? ''), String(body['password'] ?? ''));
|
|
199
|
+
if (!user)
|
|
200
|
+
return reply.code(401).type('text/html').send(loginForm(p, check.clientName, 'Wrong login or password.'));
|
|
201
|
+
return reply.type('text/html').send(consentForm(p, check.clientName, user));
|
|
202
|
+
}
|
|
203
|
+
const user = await resolveRequestUser(req);
|
|
204
|
+
if (!user)
|
|
205
|
+
return reply.code(401).type('text/html').send(loginForm(p, check.clientName, 'Your session expired. Sign in again.'));
|
|
206
|
+
if (body['action'] !== 'approve')
|
|
207
|
+
return redirectWithError(reply, p, 'access_denied');
|
|
208
|
+
if (!p.code_challenge)
|
|
209
|
+
return redirectWithError(reply, p, 'invalid_request');
|
|
210
|
+
const code = await createCode({
|
|
211
|
+
clientId: p.client_id,
|
|
212
|
+
userId: user.id,
|
|
213
|
+
redirectUri: p.redirect_uri,
|
|
214
|
+
codeChallenge: p.code_challenge,
|
|
215
|
+
resource: p.resource || (await mcpResource(req)),
|
|
216
|
+
});
|
|
217
|
+
const url = new URL(p.redirect_uri);
|
|
218
|
+
url.searchParams.set('code', code);
|
|
219
|
+
if (p.state)
|
|
220
|
+
url.searchParams.set('state', p.state);
|
|
221
|
+
log.info(`authorized client ${p.client_id} for user ${user.login}`);
|
|
222
|
+
return reply.redirect(url.toString(), 302);
|
|
223
|
+
});
|
|
224
|
+
app.get('/api/oauth/grants', async (req, reply) => {
|
|
225
|
+
if (!req.user)
|
|
226
|
+
return reply.code(401).send({ error: 'unauthorized' });
|
|
227
|
+
return listGrants(req.user.id);
|
|
228
|
+
});
|
|
229
|
+
app.delete('/api/oauth/grants/:clientId', async (req, reply) => {
|
|
230
|
+
if (!req.user)
|
|
231
|
+
return reply.code(401).send({ error: 'unauthorized' });
|
|
232
|
+
const { clientId } = req.params;
|
|
233
|
+
const revoked = await revokeGrant(req.user.id, clientId);
|
|
234
|
+
if (revoked === 0)
|
|
235
|
+
return reply.code(404).send({ error: 'not_found' });
|
|
236
|
+
log.info(`revoked ${revoked} token(s) of client ${clientId} for user ${req.user.login}`);
|
|
237
|
+
return { ok: true };
|
|
238
|
+
});
|
|
239
|
+
app.post('/oauth/token', async (req, reply) => {
|
|
240
|
+
reply.header('cache-control', 'no-store');
|
|
241
|
+
if (rateLimited(`token:${req.ip}`, 60))
|
|
242
|
+
return reply.code(429).send({ error: 'slow_down' });
|
|
243
|
+
if (!(await baseUrl(req)).startsWith('https://') && !allowInsecure()) {
|
|
244
|
+
log.error('refusing to issue tokens over plain HTTP (set PUBLIC_URL to the https origin, or OAUTH_ALLOW_INSECURE=1 for local testing)');
|
|
245
|
+
return reply.code(400).send({ error: 'invalid_request', error_description: 'https required' });
|
|
246
|
+
}
|
|
247
|
+
const body = (req.body ?? {});
|
|
248
|
+
const s = (k) => (typeof body[k] === 'string' ? body[k] : '');
|
|
249
|
+
const grant = s('grant_type');
|
|
250
|
+
if (grant === 'refresh_token') {
|
|
251
|
+
const tokens = await rotateRefresh(s('refresh_token'), s('client_id'));
|
|
252
|
+
if (!tokens)
|
|
253
|
+
return reply.code(400).send({ error: 'invalid_grant' });
|
|
254
|
+
return reply.send({
|
|
255
|
+
access_token: tokens.accessToken,
|
|
256
|
+
refresh_token: tokens.refreshToken,
|
|
257
|
+
token_type: 'Bearer',
|
|
258
|
+
expires_in: tokens.expiresIn,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
if (grant !== 'authorization_code') {
|
|
262
|
+
return reply.code(400).send({ error: 'unsupported_grant_type' });
|
|
263
|
+
}
|
|
264
|
+
const result = await redeemCode({
|
|
265
|
+
code: s('code'),
|
|
266
|
+
clientId: s('client_id'),
|
|
267
|
+
redirectUri: s('redirect_uri'),
|
|
268
|
+
codeVerifier: s('code_verifier'),
|
|
269
|
+
});
|
|
270
|
+
if (!result.ok)
|
|
271
|
+
return reply.code(400).send({ error: result.error });
|
|
272
|
+
const tokens = await issueTokens({ clientId: s('client_id'), userId: result.user.id, resource: result.resource });
|
|
273
|
+
return reply.send({
|
|
274
|
+
access_token: tokens.accessToken,
|
|
275
|
+
refresh_token: tokens.refreshToken,
|
|
276
|
+
token_type: 'Bearer',
|
|
277
|
+
expires_in: tokens.expiresIn,
|
|
278
|
+
scope: result.user.role,
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type AuthUser } from './auth-store.ts';
|
|
2
|
+
export declare const CODE_TTL_MS = 60000;
|
|
3
|
+
export declare const ACCESS_TTL_MS: number;
|
|
4
|
+
export declare const REFRESH_TTL_MS: number;
|
|
5
|
+
export interface OAuthClient {
|
|
6
|
+
clientId: string;
|
|
7
|
+
clientName: string;
|
|
8
|
+
redirectUris: string[];
|
|
9
|
+
createdAt: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function registerClient(input: {
|
|
12
|
+
clientName: string;
|
|
13
|
+
redirectUris: string[];
|
|
14
|
+
}): Promise<OAuthClient>;
|
|
15
|
+
export declare function findClient(clientId: string): Promise<OAuthClient | null>;
|
|
16
|
+
export declare function createCode(input: {
|
|
17
|
+
clientId: string;
|
|
18
|
+
userId: number;
|
|
19
|
+
redirectUri: string;
|
|
20
|
+
codeChallenge: string;
|
|
21
|
+
resource: string;
|
|
22
|
+
}): Promise<string>;
|
|
23
|
+
export type CodeRedemption = {
|
|
24
|
+
ok: true;
|
|
25
|
+
user: AuthUser;
|
|
26
|
+
resource: string;
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
error: 'invalid_grant';
|
|
30
|
+
};
|
|
31
|
+
export declare function redeemCode(input: {
|
|
32
|
+
code: string;
|
|
33
|
+
clientId: string;
|
|
34
|
+
redirectUri: string;
|
|
35
|
+
codeVerifier: string;
|
|
36
|
+
}): Promise<CodeRedemption>;
|
|
37
|
+
export interface IssuedTokens {
|
|
38
|
+
accessToken: string;
|
|
39
|
+
refreshToken: string;
|
|
40
|
+
expiresIn: number;
|
|
41
|
+
}
|
|
42
|
+
export declare function issueTokens(input: {
|
|
43
|
+
clientId: string;
|
|
44
|
+
userId: number;
|
|
45
|
+
resource: string;
|
|
46
|
+
}): Promise<IssuedTokens>;
|
|
47
|
+
export declare function resolveAccessToken(raw: string, expectedResource: string): Promise<AuthUser | null>;
|
|
48
|
+
export declare function rotateRefresh(raw: string, clientId: string): Promise<IssuedTokens | null>;
|
|
49
|
+
export interface Grant {
|
|
50
|
+
clientId: string;
|
|
51
|
+
clientName: string;
|
|
52
|
+
createdAt: string;
|
|
53
|
+
lastUsedAt: string | null;
|
|
54
|
+
}
|
|
55
|
+
export declare function listGrants(userId: number): Promise<Grant[]>;
|
|
56
|
+
export declare function revokeGrant(userId: number, clientId: string): Promise<number>;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { getEm } from "./db.js";
|
|
3
|
+
import { generateToken, hashToken } from "./auth-crypto.js";
|
|
4
|
+
import { findUserById } from "./auth-store.js";
|
|
5
|
+
export const CODE_TTL_MS = 60_000;
|
|
6
|
+
export const ACCESS_TTL_MS = 60 * 60_000;
|
|
7
|
+
export const REFRESH_TTL_MS = 30 * 24 * 60 * 60_000;
|
|
8
|
+
function toClient(row) {
|
|
9
|
+
return {
|
|
10
|
+
clientId: row.client_id,
|
|
11
|
+
clientName: row.client_name,
|
|
12
|
+
redirectUris: JSON.parse(row.redirect_uris),
|
|
13
|
+
createdAt: row.created_at,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export async function registerClient(input) {
|
|
17
|
+
const em = getEm().fork();
|
|
18
|
+
const row = {
|
|
19
|
+
client_id: randomUUID(),
|
|
20
|
+
client_name: input.clientName,
|
|
21
|
+
redirect_uris: JSON.stringify(input.redirectUris),
|
|
22
|
+
created_at: new Date().toISOString(),
|
|
23
|
+
};
|
|
24
|
+
em.create('_OAuthClient', row);
|
|
25
|
+
await em.flush();
|
|
26
|
+
return toClient(row);
|
|
27
|
+
}
|
|
28
|
+
export async function findClient(clientId) {
|
|
29
|
+
const em = getEm().fork();
|
|
30
|
+
const row = (await em.findOne('_OAuthClient', { client_id: clientId }));
|
|
31
|
+
return row ? toClient(row) : null;
|
|
32
|
+
}
|
|
33
|
+
export async function createCode(input) {
|
|
34
|
+
const em = getEm().fork();
|
|
35
|
+
const raw = generateToken();
|
|
36
|
+
em.create('_OAuthCode', {
|
|
37
|
+
code_hash: hashToken(raw),
|
|
38
|
+
client_id: input.clientId,
|
|
39
|
+
user_id: input.userId,
|
|
40
|
+
redirect_uri: input.redirectUri,
|
|
41
|
+
code_challenge: input.codeChallenge,
|
|
42
|
+
resource: input.resource,
|
|
43
|
+
expires_at: new Date(Date.now() + CODE_TTL_MS).toISOString(),
|
|
44
|
+
created_at: new Date().toISOString(),
|
|
45
|
+
});
|
|
46
|
+
await em.flush();
|
|
47
|
+
return raw;
|
|
48
|
+
}
|
|
49
|
+
function verifyPkce(verifier, challenge) {
|
|
50
|
+
const computed = createHash('sha256').update(verifier).digest('base64url');
|
|
51
|
+
const a = Buffer.from(computed);
|
|
52
|
+
const b = Buffer.from(challenge);
|
|
53
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
54
|
+
}
|
|
55
|
+
export async function redeemCode(input) {
|
|
56
|
+
const em = getEm().fork();
|
|
57
|
+
const row = (await em.findOne('_OAuthCode', { code_hash: hashToken(input.code) }));
|
|
58
|
+
if (!row)
|
|
59
|
+
return { ok: false, error: 'invalid_grant' };
|
|
60
|
+
em.remove(row);
|
|
61
|
+
await em.flush();
|
|
62
|
+
if (row.client_id !== input.clientId)
|
|
63
|
+
return { ok: false, error: 'invalid_grant' };
|
|
64
|
+
if (row.redirect_uri !== input.redirectUri)
|
|
65
|
+
return { ok: false, error: 'invalid_grant' };
|
|
66
|
+
if (new Date(row.expires_at).getTime() < Date.now())
|
|
67
|
+
return { ok: false, error: 'invalid_grant' };
|
|
68
|
+
if (!verifyPkce(input.codeVerifier, row.code_challenge))
|
|
69
|
+
return { ok: false, error: 'invalid_grant' };
|
|
70
|
+
const user = await findUserById(row.user_id);
|
|
71
|
+
if (!user || user.disabled)
|
|
72
|
+
return { ok: false, error: 'invalid_grant' };
|
|
73
|
+
return { ok: true, user, resource: row.resource };
|
|
74
|
+
}
|
|
75
|
+
export async function issueTokens(input) {
|
|
76
|
+
const em = getEm().fork();
|
|
77
|
+
const now = new Date().toISOString();
|
|
78
|
+
const accessToken = generateToken();
|
|
79
|
+
const refreshToken = generateToken();
|
|
80
|
+
for (const [raw, kind, ttl] of [
|
|
81
|
+
[accessToken, 'access', ACCESS_TTL_MS],
|
|
82
|
+
[refreshToken, 'refresh', REFRESH_TTL_MS],
|
|
83
|
+
]) {
|
|
84
|
+
em.create('_OAuthToken', {
|
|
85
|
+
token_hash: hashToken(raw),
|
|
86
|
+
kind,
|
|
87
|
+
client_id: input.clientId,
|
|
88
|
+
user_id: input.userId,
|
|
89
|
+
resource: input.resource,
|
|
90
|
+
expires_at: new Date(Date.now() + ttl).toISOString(),
|
|
91
|
+
created_at: now,
|
|
92
|
+
last_used_at: null,
|
|
93
|
+
revoked: 0,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
await em.flush();
|
|
97
|
+
return { accessToken, refreshToken, expiresIn: Math.floor(ACCESS_TTL_MS / 1000) };
|
|
98
|
+
}
|
|
99
|
+
async function findLiveToken(raw, kind) {
|
|
100
|
+
const em = getEm().fork();
|
|
101
|
+
const row = (await em.findOne('_OAuthToken', { token_hash: hashToken(raw), kind, revoked: 0 }));
|
|
102
|
+
if (!row)
|
|
103
|
+
return null;
|
|
104
|
+
if (new Date(row.expires_at).getTime() < Date.now())
|
|
105
|
+
return null;
|
|
106
|
+
return row;
|
|
107
|
+
}
|
|
108
|
+
export async function resolveAccessToken(raw, expectedResource) {
|
|
109
|
+
const row = await findLiveToken(raw, 'access');
|
|
110
|
+
if (!row)
|
|
111
|
+
return null;
|
|
112
|
+
if (row.resource !== expectedResource)
|
|
113
|
+
return null;
|
|
114
|
+
const user = await findUserById(row.user_id);
|
|
115
|
+
if (!user || user.disabled)
|
|
116
|
+
return null;
|
|
117
|
+
await getEm()
|
|
118
|
+
.fork()
|
|
119
|
+
.nativeUpdate('_OAuthToken', { token_hash: row.token_hash }, { last_used_at: new Date().toISOString() });
|
|
120
|
+
return user;
|
|
121
|
+
}
|
|
122
|
+
export async function rotateRefresh(raw, clientId) {
|
|
123
|
+
const row = await findLiveToken(raw, 'refresh');
|
|
124
|
+
if (!row || row.client_id !== clientId)
|
|
125
|
+
return null;
|
|
126
|
+
const user = await findUserById(row.user_id);
|
|
127
|
+
if (!user || user.disabled)
|
|
128
|
+
return null;
|
|
129
|
+
await getEm()
|
|
130
|
+
.fork()
|
|
131
|
+
.nativeUpdate('_OAuthToken', { token_hash: row.token_hash }, { revoked: 1 });
|
|
132
|
+
return issueTokens({ clientId, userId: row.user_id, resource: row.resource });
|
|
133
|
+
}
|
|
134
|
+
export async function listGrants(userId) {
|
|
135
|
+
const em = getEm().fork();
|
|
136
|
+
const rows = (await em.find('_OAuthToken', { user_id: userId, revoked: 0 }));
|
|
137
|
+
const byClient = new Map();
|
|
138
|
+
for (const r of rows) {
|
|
139
|
+
const prev = byClient.get(r.client_id);
|
|
140
|
+
const lastUsed = [prev?.lastUsedAt, r.last_used_at].filter(Boolean).sort().pop() ?? null;
|
|
141
|
+
byClient.set(r.client_id, {
|
|
142
|
+
clientId: r.client_id,
|
|
143
|
+
clientName: prev?.clientName ?? r.client_id,
|
|
144
|
+
createdAt: prev && prev.createdAt < r.created_at ? prev.createdAt : r.created_at,
|
|
145
|
+
lastUsedAt: lastUsed,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
for (const [clientId, grant] of byClient) {
|
|
149
|
+
const client = await findClient(clientId);
|
|
150
|
+
if (client)
|
|
151
|
+
grant.clientName = client.clientName;
|
|
152
|
+
}
|
|
153
|
+
return [...byClient.values()];
|
|
154
|
+
}
|
|
155
|
+
export async function revokeGrant(userId, clientId) {
|
|
156
|
+
return getEm()
|
|
157
|
+
.fork()
|
|
158
|
+
.nativeUpdate('_OAuthToken', { user_id: userId, client_id: clientId, revoked: 0 }, { revoked: 1 });
|
|
159
|
+
}
|
package/dist/plugin-hooks.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { EntityManager } from '@mikro-orm/core';
|
|
|
2
2
|
import type { z } from 'zod';
|
|
3
3
|
import type { ColumnType } from '@coffer-org/sdk/fields';
|
|
4
4
|
import { type Logger } from '@coffer-org/sdk/logger';
|
|
5
|
+
export type { BackgroundTask } from './background-scheduler.ts';
|
|
5
6
|
export interface TableOps {
|
|
6
7
|
renameColumn(from: string, to: string): Promise<void>;
|
|
7
8
|
fill(column: string, value: string | number | boolean | null, opts?: {
|
|
@@ -28,21 +29,30 @@ export interface Seed {
|
|
|
28
29
|
name: string;
|
|
29
30
|
run(ctx: PluginCtx): Promise<void> | void;
|
|
30
31
|
}
|
|
32
|
+
export type AuthRole = 'admin' | 'member';
|
|
31
33
|
export interface AgentTool {
|
|
32
34
|
name: string;
|
|
33
35
|
description: string;
|
|
34
36
|
inputSchema: z.ZodRawShape;
|
|
35
37
|
handler: (args: Record<string, unknown>, ctx: PluginCtx) => Promise<unknown> | unknown;
|
|
38
|
+
role?: AuthRole;
|
|
36
39
|
}
|
|
37
40
|
export interface AgentContribution {
|
|
38
41
|
instructions?: string | ((ctx: PluginCtx) => string | Promise<string>);
|
|
39
42
|
tools?: AgentTool[];
|
|
40
43
|
}
|
|
44
|
+
export type PluginAction = (body: Record<string, unknown>) => Promise<unknown>;
|
|
45
|
+
export declare class HttpError extends Error {
|
|
46
|
+
status: number;
|
|
47
|
+
constructor(status: number, message: string);
|
|
48
|
+
}
|
|
41
49
|
export interface PluginHooks {
|
|
42
50
|
migrations?: Migration[];
|
|
43
51
|
seed?: Seed[];
|
|
44
52
|
init?(ctx: PluginCtx): Promise<void> | void;
|
|
45
53
|
teardown?(ctx: PluginCtx): Promise<void> | void;
|
|
46
54
|
agent?: AgentContribution;
|
|
55
|
+
backgroundTasks?: import('./background-scheduler.ts').BackgroundTask[];
|
|
56
|
+
actions?: Record<string, PluginAction>;
|
|
47
57
|
}
|
|
48
58
|
export declare const pluginHooks: Record<string, PluginHooks>;
|
package/dist/plugin-hooks.js
CHANGED
|
@@ -2,4 +2,12 @@ import { getLogger } from '@coffer-org/sdk/logger';
|
|
|
2
2
|
export function pluginCtx(id, em) {
|
|
3
3
|
return { em, log: getLogger(id) };
|
|
4
4
|
}
|
|
5
|
+
export class HttpError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
constructor(status, message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.name = 'HttpError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
5
13
|
export const pluginHooks = {};
|
package/dist/plugin-runtime.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { PluginManifest } from '@coffer-org/sdk/plugin';
|
|
|
3
3
|
export declare function getPlugins(): Promise<PluginManifest[]>;
|
|
4
4
|
export declare function readDisabled(): Promise<Set<string>>;
|
|
5
5
|
export declare function getPluginSettings(pluginId: string): Promise<Record<string, unknown>>;
|
|
6
|
+
export declare function requireSettings<K extends string>(pluginId: string, keys: readonly K[]): Promise<Record<K, string>>;
|
|
6
7
|
export declare function initStorage(): Promise<Set<string>>;
|
|
7
8
|
export declare function initPlugins(): Promise<Registry>;
|
|
8
9
|
export declare function teardownPlugins(): Promise<void>;
|
package/dist/plugin-runtime.js
CHANGED
|
@@ -3,13 +3,14 @@ import { composeRegistry } from '@coffer-org/core/compose';
|
|
|
3
3
|
import { getLogger } from '@coffer-org/sdk/logger';
|
|
4
4
|
import { initDb, getOrm, getEm } from "./db.js";
|
|
5
5
|
import { syncSchema } from "./schema-sync.js";
|
|
6
|
-
import { systemEntities, buildPluginEntities,
|
|
7
|
-
import { pluginHooks, pluginCtx } from "./plugin-hooks.js";
|
|
6
|
+
import { systemEntities, buildPluginEntities, shelfTableName } from "./entity-schema.js";
|
|
7
|
+
import { pluginHooks, pluginCtx, HttpError } from "./plugin-hooks.js";
|
|
8
8
|
import { discoverPlugins, loadServerHooks } from "./plugin-discovery.js";
|
|
9
9
|
import { runMigrations, assertSafeRequired } from "./migrations.js";
|
|
10
10
|
import { runSeeds } from "./seeds.js";
|
|
11
11
|
import { setActiveRegistry } from "./registry-context.js";
|
|
12
12
|
import { migrateEmbeddingVectorsToBlob } from "./embeddings.js";
|
|
13
|
+
import { startScheduler, stopScheduler } from "./background-scheduler.js";
|
|
13
14
|
const log = getLogger('plugins');
|
|
14
15
|
let _plugins = null;
|
|
15
16
|
export async function getPlugins() {
|
|
@@ -36,6 +37,22 @@ export async function getPluginSettings(pluginId) {
|
|
|
36
37
|
return {};
|
|
37
38
|
}
|
|
38
39
|
}
|
|
40
|
+
export async function requireSettings(pluginId, keys) {
|
|
41
|
+
const s = await getPluginSettings(pluginId);
|
|
42
|
+
const out = {};
|
|
43
|
+
const missing = [];
|
|
44
|
+
for (const k of keys) {
|
|
45
|
+
const v = s[k];
|
|
46
|
+
if (v == null || v === '')
|
|
47
|
+
missing.push(k);
|
|
48
|
+
else
|
|
49
|
+
out[k] = String(v);
|
|
50
|
+
}
|
|
51
|
+
if (missing.length) {
|
|
52
|
+
throw new HttpError(400, `${pluginId} settings not configured: ${missing.join(', ')}. Save them first.`);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
39
56
|
async function seedPluginRows() {
|
|
40
57
|
const fork = getEm().fork();
|
|
41
58
|
const existing = new Set((await fork.find('_Plugin', {})).map((r) => r.id));
|
|
@@ -74,6 +91,7 @@ export async function initPlugins() {
|
|
|
74
91
|
setActiveRegistry(reg);
|
|
75
92
|
Object.assign(pluginHooks, await loadServerHooks());
|
|
76
93
|
await runSeeds({ em: getEm().fork(), plugins: reg.order, hooks: pluginHooks });
|
|
94
|
+
const bgTasks = [];
|
|
77
95
|
for (const p of reg.order) {
|
|
78
96
|
const h = pluginHooks[p.id];
|
|
79
97
|
try {
|
|
@@ -81,17 +99,23 @@ export async function initPlugins() {
|
|
|
81
99
|
await h.init(pluginCtx(p.id, getEm().fork()));
|
|
82
100
|
log.debug(`${p.id}: init ✓`);
|
|
83
101
|
}
|
|
102
|
+
for (const task of h?.backgroundTasks ?? [])
|
|
103
|
+
bgTasks.push(task);
|
|
84
104
|
}
|
|
85
105
|
catch (err) {
|
|
86
106
|
log.error(`${p.id}: initialization failure`, err);
|
|
87
107
|
throw err;
|
|
88
108
|
}
|
|
89
109
|
}
|
|
110
|
+
startScheduler(bgTasks);
|
|
111
|
+
if (bgTasks.length)
|
|
112
|
+
log.debug(`scheduler: ${bgTasks.length} background task(s): ${bgTasks.map((t) => t.name).join(' ')}`);
|
|
90
113
|
log.info(`active: ${reg.order.map((p) => p.id).join(' ')}` +
|
|
91
114
|
(disabled.size ? ` | disabled: ${[...disabled].join(' ')}` : ''));
|
|
92
115
|
return reg;
|
|
93
116
|
}
|
|
94
117
|
export async function teardownPlugins() {
|
|
118
|
+
stopScheduler();
|
|
95
119
|
Object.assign(pluginHooks, await loadServerHooks());
|
|
96
120
|
const disabled = await readDisabled();
|
|
97
121
|
const reg = composeRegistry(await getPlugins(), { disabled });
|
|
@@ -115,15 +139,15 @@ export async function teardownPlugin(id) {
|
|
|
115
139
|
log.debug(`${id}: teardown ✓`);
|
|
116
140
|
}
|
|
117
141
|
}
|
|
118
|
-
function
|
|
142
|
+
function pluginShelves(p) {
|
|
119
143
|
return [
|
|
120
|
-
...(p.
|
|
121
|
-
...(p.
|
|
144
|
+
...(p.libraries ?? []).flatMap((v) => v.shelves.map((m) => ({ library: m.library, shelf: m.shelf }))),
|
|
145
|
+
...(p.libraryShelves ?? []).map((m) => ({ library: m.library, shelf: m.shelf })),
|
|
122
146
|
];
|
|
123
147
|
}
|
|
124
148
|
function pluginTables(p) {
|
|
125
149
|
return [
|
|
126
|
-
...
|
|
150
|
+
...pluginShelves(p).map(({ library, shelf }) => shelfTableName(library, shelf)),
|
|
127
151
|
...(p.extends_ ?? []).map((e) => `extend__${e.id}`),
|
|
128
152
|
...(p.settings && p.settings.fields.length > 0 ? [`_settings__${p.id}`] : []),
|
|
129
153
|
];
|