@bevel-software/platform-core-backend 0.34.0 → 0.35.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/agent-guide/access-control.md +146 -300
- package/agent-guide/conventions.md +23 -25
- package/agent-guide/directory-structure.md +72 -120
- package/agent-guide/drafts-and-change-requests.md +92 -0
- package/agent-guide/for-git-users.md +47 -0
- package/agent-guide/glossary.md +36 -0
- package/agent-guide/how-agents-find-things.md +49 -0
- package/agent-guide/html-views.md +16 -11
- package/agent-guide/new-knowledge-base.md +9 -8
- package/agent-guide/skills.md +67 -42
- package/agent-guide/start-here.md +65 -0
- package/agent-guide/tool-manuals.md +132 -154
- package/agent-guide/tools.md +67 -0
- package/agent-guide/where-a-new-file-goes.md +45 -28
- package/dist/core/create-core-server.js +1 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +6 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +7 -4
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +22 -0
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +3 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +75 -0
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +23 -4
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/index.d.ts +1 -1
- package/dist/modules/access/index.d.ts.map +1 -1
- package/dist/modules/agent-guide/agent-guide.d.ts +47 -7
- package/dist/modules/agent-guide/agent-guide.d.ts.map +1 -1
- package/dist/modules/agent-guide/agent-guide.js +103 -13
- package/dist/modules/agent-guide/agent-guide.js.map +1 -1
- package/dist/modules/agent-guide/agent-guide.tools.d.ts +6 -2
- package/dist/modules/agent-guide/agent-guide.tools.d.ts.map +1 -1
- package/dist/modules/agent-guide/agent-guide.tools.js +45 -31
- package/dist/modules/agent-guide/agent-guide.tools.js.map +1 -1
- package/dist/modules/agent-guide/guide-budget.d.ts +48 -0
- package/dist/modules/agent-guide/guide-budget.d.ts.map +1 -0
- package/dist/modules/agent-guide/guide-budget.js +67 -0
- package/dist/modules/agent-guide/guide-budget.js.map +1 -0
- package/dist/modules/agent-guide/index.d.ts +2 -1
- package/dist/modules/agent-guide/index.d.ts.map +1 -1
- package/dist/modules/agent-guide/index.js +2 -1
- package/dist/modules/agent-guide/index.js.map +1 -1
- package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
- package/dist/modules/agent-instructions/compose.js +4 -0
- package/dist/modules/agent-instructions/compose.js.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.js +10 -8
- package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.js +2 -2
- package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
- package/dist/modules/connection-probe/connection-probe.service.d.ts +0 -12
- package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -1
- package/dist/modules/connection-probe/connection-probe.service.js +1 -38
- package/dist/modules/connection-probe/connection-probe.service.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +6 -35
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts +11 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js +159 -24
- package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
- package/dist/modules/secrets-vault/index.d.ts +1 -1
- package/dist/modules/secrets-vault/index.d.ts.map +1 -1
- package/dist/modules/secrets-vault/index.js +1 -1
- package/dist/modules/secrets-vault/index.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.contract.d.ts +53 -11
- package/dist/modules/secrets-vault/secrets-vault.contract.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.contract.js +27 -0
- package/dist/modules/secrets-vault/secrets-vault.contract.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +85 -39
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/settings/oidc-check.d.ts.map +1 -1
- package/dist/modules/settings/oidc-check.js +5 -17
- package/dist/modules/settings/oidc-check.js.map +1 -1
- package/dist/modules/skills/skills.tools.js +1 -1
- package/dist/modules/skills/skills.tools.js.map +1 -1
- package/dist/modules/tool-helpers/tool-def.js +1 -1
- package/dist/modules/tool-helpers/tool-def.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +7 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js +69 -38
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +27 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +12 -8
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +115 -44
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +8 -2
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +31 -10
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +22 -18
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workspace/agent-access.gate.d.ts +1 -1
- package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -1
- package/dist/modules/workspace/agent-access.gate.js +1 -1
- package/dist/modules/workspace/agent-access.gate.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +3 -2
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +71 -21
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/oauth-token-error.d.ts +23 -0
- package/dist/shared/oauth-token-error.d.ts.map +1 -0
- package/dist/shared/oauth-token-error.js +47 -0
- package/dist/shared/oauth-token-error.js.map +1 -0
- package/dist/shared/read-capped.d.ts +19 -0
- package/dist/shared/read-capped.d.ts.map +1 -0
- package/dist/shared/read-capped.js +45 -0
- package/dist/shared/read-capped.js.map +1 -0
- package/package.json +4 -3
- package/src/__tests__/hexis-catalog.ts +144 -0
- package/src/__tests__/real-disk.ts +21 -0
- package/src/__tests__/tokens.ts +19 -0
- package/src/__tests__/tool-manuals.ts +32 -0
- package/src/__tests__/tool-wiring.ts +25 -0
- package/src/core/__tests__/oauth-sign-in-real-file.test.ts +179 -0
- package/src/core/create-core-server.ts +1 -1
- package/src/core/create-core-services.ts +13 -3
- package/src/modules/access/__tests__/access.routes.prospective.test.ts +52 -12
- package/src/modules/access/__tests__/holder-changes.test.ts +127 -0
- package/src/modules/access/access-control.interface.ts +26 -0
- package/src/modules/access/access-control.service.ts +92 -0
- package/src/modules/access/access.routes.ts +23 -4
- package/src/modules/access/index.ts +1 -1
- package/src/modules/agent-guide/__tests__/agent-guide.test.ts +79 -25
- package/src/modules/agent-guide/__tests__/agent-guide.tools.test.ts +38 -11
- package/src/modules/agent-guide/__tests__/guide-budget.test.ts +95 -0
- package/src/modules/agent-guide/__tests__/guide-eval.test.ts +182 -0
- package/src/modules/agent-guide/__tests__/guide-matches-code.test.ts +263 -0
- package/src/modules/agent-guide/__tests__/tool-surface.json +514 -0
- package/src/modules/agent-guide/__tests__/tool-surface.test.ts +75 -0
- package/src/modules/agent-guide/agent-guide.tools.ts +49 -31
- package/src/modules/agent-guide/agent-guide.ts +120 -13
- package/src/modules/agent-guide/guide-budget.ts +73 -0
- package/src/modules/agent-guide/index.ts +12 -0
- package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +4 -4
- package/src/modules/agent-instructions/compose.ts +4 -0
- package/src/modules/agent-instructions/shared-file-rules.ts +10 -8
- package/src/modules/code-mode/code-mode.tool.ts +2 -2
- package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +5 -24
- package/src/modules/connection-probe/connection-probe.service.ts +1 -34
- package/src/modules/mcp/mcp.service.ts +5 -35
- package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +430 -3
- package/src/modules/secrets-vault/__tests__/oauth-live-declaration.route.test.ts +218 -0
- package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +39 -6
- package/src/modules/secrets-vault/db-secrets-vault.service.ts +170 -27
- package/src/modules/secrets-vault/index.ts +1 -1
- package/src/modules/secrets-vault/secrets-vault.contract.ts +61 -11
- package/src/modules/secrets-vault/secrets-vault.routes.ts +91 -39
- package/src/modules/settings/oidc-check.ts +5 -18
- package/src/modules/skills/skills.tools.ts +1 -1
- package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +19 -11
- package/src/modules/tool-helpers/__tests__/route-tool-schemas.catalog.test.ts +38 -0
- package/src/modules/tool-helpers/tool-def.ts +1 -1
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +197 -11
- package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +96 -5
- package/src/modules/tool-manuals/mcp-json-discovery.ts +73 -34
- package/src/modules/tool-manuals/tool-manuals.contract.ts +22 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +138 -46
- package/src/modules/tool-manuals/tool-manuals.tools.ts +53 -14
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +6 -113
- package/src/modules/workflow/agent-tools/workflow.tools.ts +22 -18
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +8 -10
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +156 -49
- package/src/modules/workspace/agent-access.gate.ts +1 -1
- package/src/modules/workspace/workspace.tools.ts +74 -21
- package/src/shared/__tests__/read-capped.test.ts +68 -0
- package/src/shared/oauth-token-error.ts +46 -0
- package/src/shared/read-capped.ts +40 -0
- package/agent-guide/finding-things.md +0 -7
- package/agent-guide/introduction.md +0 -27
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type { Server as HttpServer } from 'node:http';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { createSecretsVaultRoutes, InvalidSecretError, SecretOAuthError } from '../index.js';
|
|
5
|
+
import type { ToolManualSummary, ToolVariable } from '../../tool-manuals/index.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The tool-scoped sign-in start follows the tool file as it reads NOW
|
|
9
|
+
* (`liveDeclaration`), not the catalog's cached scan and not the copy the
|
|
10
|
+
* owner's client-secret save stored. A client the platform registered itself
|
|
11
|
+
* (oauth-auto) is described by no file and still starts from its stored row.
|
|
12
|
+
*
|
|
13
|
+
* The incident: the GTM plugin's HubSpot connector was replaced, the file got
|
|
14
|
+
* the new client id, the new secret was saved — and every sign-in still asked
|
|
15
|
+
* for consent under the old client id.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const USER = 'owner@x.com';
|
|
19
|
+
const ENDPOINTS = {
|
|
20
|
+
authorizationUrl: 'https://app.hubspot.example/oauth/authorize',
|
|
21
|
+
tokenUrl: 'https://api.hubspot.example/oauth/v1/token',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const manual = (variables: ToolVariable[], setup?: ToolManualSummary['setup']): ToolManualSummary => ({
|
|
25
|
+
slug: 'hubspot',
|
|
26
|
+
name: 'hubspot',
|
|
27
|
+
path: 'Plugins/GTM/.claude-plugin/plugin.json',
|
|
28
|
+
type: 'mcp',
|
|
29
|
+
variables,
|
|
30
|
+
...(setup ? { setup } : {}),
|
|
31
|
+
});
|
|
32
|
+
const signIn = (oauth?: ToolVariable['oauth']): ToolVariable => ({
|
|
33
|
+
name: 'HUBSPOT_TOKEN',
|
|
34
|
+
scope: 'user',
|
|
35
|
+
...(oauth ? { oauth } : {}),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
let httpServer: HttpServer | undefined;
|
|
39
|
+
afterEach(async () => {
|
|
40
|
+
await new Promise<void>((r) => (httpServer ? httpServer.close(() => r()) : r()));
|
|
41
|
+
httpServer = undefined;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/** One server whose catalog says `catalog` and whose file says `live`. */
|
|
45
|
+
async function serve(opts: {
|
|
46
|
+
catalog: ToolManualSummary[];
|
|
47
|
+
live: { manual: ToolManualSummary; variable?: ToolVariable } | null;
|
|
48
|
+
begin?: (input: Record<string, unknown>) => Promise<{ id: string; url: string }>;
|
|
49
|
+
canWrite?: boolean;
|
|
50
|
+
varName?: string;
|
|
51
|
+
/** The live read fails outright (the workspace cannot be opened) instead of answering. */
|
|
52
|
+
liveError?: Error;
|
|
53
|
+
}) {
|
|
54
|
+
const beginToolOAuthByKey = vi.fn(
|
|
55
|
+
opts.begin ?? (async () => ({ id: 'row-1', url: 'https://app.hubspot.example/oauth/authorize?client_id=x' })),
|
|
56
|
+
);
|
|
57
|
+
const putSharedOAuthClientSecret = vi.fn(async () => {});
|
|
58
|
+
const liveDeclaration = vi.fn(async () => {
|
|
59
|
+
if (opts.liveError) throw opts.liveError;
|
|
60
|
+
return opts.live;
|
|
61
|
+
});
|
|
62
|
+
const deps = {
|
|
63
|
+
secretsVault: { beginToolOAuthByKey, putSharedOAuthClientSecret },
|
|
64
|
+
toolManualService: { listAccessible: async () => opts.catalog, liveDeclaration },
|
|
65
|
+
accessControl: { canRead: async () => true, canWrite: async () => opts.canWrite ?? false },
|
|
66
|
+
connectionProbe: {},
|
|
67
|
+
kb: { defaultWorkspaceId: () => 'main' },
|
|
68
|
+
stateSecret: 'state-secret',
|
|
69
|
+
publicBackendUrl: 'http://localhost:3000',
|
|
70
|
+
publicFrontendUrl: 'http://localhost:5173',
|
|
71
|
+
} as unknown as Parameters<typeof createSecretsVaultRoutes>[0];
|
|
72
|
+
const app = express();
|
|
73
|
+
app.use(express.json());
|
|
74
|
+
app.use((req, _res, next) => {
|
|
75
|
+
req.userId = 'user-1';
|
|
76
|
+
req.userEmail = USER;
|
|
77
|
+
next();
|
|
78
|
+
});
|
|
79
|
+
app.use('/api', createSecretsVaultRoutes(deps));
|
|
80
|
+
httpServer = await new Promise<HttpServer>((resolve) => {
|
|
81
|
+
const s = app.listen(0, () => resolve(s));
|
|
82
|
+
});
|
|
83
|
+
const base = `http://127.0.0.1:${(httpServer.address() as { port: number }).port}/api/secrets/tools/hubspot/vars/${opts.varName ?? 'HUBSPOT_TOKEN'}`;
|
|
84
|
+
return {
|
|
85
|
+
beginToolOAuthByKey,
|
|
86
|
+
putSharedOAuthClientSecret,
|
|
87
|
+
liveDeclaration,
|
|
88
|
+
start: () => fetch(`${base}/oauth/start`, { method: 'POST' }),
|
|
89
|
+
saveSecret: () =>
|
|
90
|
+
fetch(`${base}/oauth/admin`, {
|
|
91
|
+
method: 'PUT',
|
|
92
|
+
headers: { 'Content-Type': 'application/json' },
|
|
93
|
+
body: JSON.stringify({ clientSecret: 'cs' }),
|
|
94
|
+
}),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe('POST …/oauth/start — a declared sign-in follows the live declaration', () => {
|
|
99
|
+
it('hands the vault the file’s client id, endpoints, PKCE and resource, even when the catalog still says otherwise', async () => {
|
|
100
|
+
const stale = manual([signIn({ clientId: 'client-A', ...ENDPOINTS })], { kind: 'oauth-manual' });
|
|
101
|
+
const fresh = manual([signIn({ clientId: 'client-B', ...ENDPOINTS, scopes: ['crm.read'], resource: 'https://mcp.hubspot.example/' })], {
|
|
102
|
+
kind: 'oauth-manual',
|
|
103
|
+
});
|
|
104
|
+
const s = await serve({ catalog: [stale], live: { manual: fresh, variable: fresh.variables![0] } });
|
|
105
|
+
|
|
106
|
+
const res = await s.start();
|
|
107
|
+
expect(res.status).toBe(200);
|
|
108
|
+
expect(s.liveDeclaration).toHaveBeenCalledWith(USER, 'hubspot', 'HUBSPOT_TOKEN');
|
|
109
|
+
expect(s.beginToolOAuthByKey).toHaveBeenCalledWith(
|
|
110
|
+
expect.objectContaining({
|
|
111
|
+
key: 'hubspot_HUBSPOT_TOKEN',
|
|
112
|
+
scopes: ['crm.read'],
|
|
113
|
+
provider: {
|
|
114
|
+
...ENDPOINTS,
|
|
115
|
+
clientId: 'client-B',
|
|
116
|
+
scopes: ['crm.read'],
|
|
117
|
+
authParams: undefined,
|
|
118
|
+
pkce: true,
|
|
119
|
+
resource: 'https://mcp.hubspot.example/',
|
|
120
|
+
},
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('a declaration opting out of PKCE is handed over as such', async () => {
|
|
126
|
+
const fresh = manual([signIn({ clientId: 'client-B', ...ENDPOINTS, pkce: false })]);
|
|
127
|
+
const s = await serve({ catalog: [fresh], live: { manual: fresh, variable: fresh.variables![0] } });
|
|
128
|
+
expect((await s.start()).status).toBe(200);
|
|
129
|
+
expect(s.beginToolOAuthByKey.mock.calls[0][0]).toMatchObject({ provider: { pkce: false } });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('refuses a declaration whose endpoints could not be discovered with the discovery reason — the save’s own wording', async () => {
|
|
133
|
+
const reason = 'no authorization-server metadata at https://mcp.hubspot.example';
|
|
134
|
+
const stuck = manual([signIn({ clientId: 'client-B' })], { kind: 'oauth-manual', reason });
|
|
135
|
+
const s = await serve({ catalog: [stuck], live: { manual: stuck, variable: stuck.variables![0] }, canWrite: true });
|
|
136
|
+
|
|
137
|
+
const res = await s.start();
|
|
138
|
+
expect(res.status).toBe(422);
|
|
139
|
+
const { error } = (await res.json()) as { error: string };
|
|
140
|
+
expect(error).toBe(`The sign-in endpoints for this server aren't known yet: ${reason}.`);
|
|
141
|
+
expect(s.beginToolOAuthByKey).not.toHaveBeenCalled();
|
|
142
|
+
|
|
143
|
+
const saved = await s.saveSecret();
|
|
144
|
+
expect(saved.status).toBe(422);
|
|
145
|
+
expect(((await saved.json()) as { error: string }).error).toBe(error);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('refuses with "owner hasn’t finished" when the oauth block was removed from the file, never using the stored row', async () => {
|
|
149
|
+
const stale = manual([signIn({ clientId: 'client-A', ...ENDPOINTS })], { kind: 'oauth-manual' });
|
|
150
|
+
const fresh = manual([signIn()]);
|
|
151
|
+
const s = await serve({ catalog: [stale], live: { manual: fresh, variable: fresh.variables![0] } });
|
|
152
|
+
|
|
153
|
+
const res = await s.start();
|
|
154
|
+
expect(res.status).toBe(409);
|
|
155
|
+
expect(((await res.json()) as { error: string }).error).toBe("This tool's owner hasn't finished setting this up");
|
|
156
|
+
expect(s.beginToolOAuthByKey).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('passes on the vault’s refusals: a missing secret, and an endpoint that fails validation', async () => {
|
|
160
|
+
const fresh = manual([signIn({ clientId: 'client-B', ...ENDPOINTS, tokenUrl: 'http://127.0.0.1/token' })]);
|
|
161
|
+
const refusing = await serve({
|
|
162
|
+
catalog: [fresh],
|
|
163
|
+
live: { manual: fresh, variable: fresh.variables![0] },
|
|
164
|
+
begin: async () => {
|
|
165
|
+
throw new InvalidSecretError('tokenUrl must use https');
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
const invalid = await refusing.start();
|
|
169
|
+
expect(invalid.status).toBe(422);
|
|
170
|
+
expect(((await invalid.json()) as { error: string }).error).toBe('tokenUrl must use https');
|
|
171
|
+
await new Promise<void>((r) => httpServer!.close(() => r()));
|
|
172
|
+
|
|
173
|
+
const unset = await serve({
|
|
174
|
+
catalog: [fresh],
|
|
175
|
+
live: { manual: fresh, variable: fresh.variables![0] },
|
|
176
|
+
begin: async () => {
|
|
177
|
+
throw new SecretOAuthError("This tool's owner hasn't finished setting this up");
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
const res = await unset.start();
|
|
181
|
+
expect(res.status).toBe(409);
|
|
182
|
+
expect(((await res.json()) as { error: string }).error).toBe("This tool's owner hasn't finished setting this up");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('a platform-registered sign-in (oauth-auto) starts from its stored row: no provider is handed over', async () => {
|
|
186
|
+
const auto = manual(
|
|
187
|
+
[{ name: 'MCP_OAUTH', scope: 'user', oauth: { clientId: 'dcr-1', ...ENDPOINTS } }],
|
|
188
|
+
{ kind: 'oauth-auto' },
|
|
189
|
+
);
|
|
190
|
+
// The file declares no such variable: the catalog's discovery synthesised it.
|
|
191
|
+
const s = await serve({ catalog: [auto], live: { manual: manual([]) }, varName: 'MCP_OAUTH' });
|
|
192
|
+
expect((await s.start()).status).toBe(200);
|
|
193
|
+
const input = s.beginToolOAuthByKey.mock.calls[0][0];
|
|
194
|
+
expect(input).toMatchObject({ key: 'hubspot_MCP_OAUTH' });
|
|
195
|
+
expect(input.provider).toBeUndefined();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('answers 404 when neither the file nor the catalog knows the tool', async () => {
|
|
199
|
+
const s = await serve({ catalog: [], live: null });
|
|
200
|
+
expect((await s.start()).status).toBe(404);
|
|
201
|
+
expect(s.beginToolOAuthByKey).not.toHaveBeenCalled();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('answers 500 when the tool files cannot be read, never a misconfiguration verdict from the catalog', async () => {
|
|
205
|
+
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
206
|
+
// The catalog still holds a declared sign-in; a live read that fails must
|
|
207
|
+
// not fall back to it and answer "owner hasn't finished" (409) or "not
|
|
208
|
+
// found" (404) for what is a server fault.
|
|
209
|
+
const stale = manual([signIn({ clientId: 'client-A', ...ENDPOINTS })], { kind: 'oauth-manual' });
|
|
210
|
+
const s = await serve({ catalog: [stale], live: null, liveError: new Error('workspace unavailable') });
|
|
211
|
+
|
|
212
|
+
const res = await s.start();
|
|
213
|
+
expect(res.status).toBe(500);
|
|
214
|
+
expect(((await res.json()) as { error: string }).error).toBe('Internal error');
|
|
215
|
+
expect(s.beginToolOAuthByKey).not.toHaveBeenCalled();
|
|
216
|
+
error.mockRestore();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -2,11 +2,11 @@ import type { Server as HttpServer } from 'node:http';
|
|
|
2
2
|
import { createHmac } from 'node:crypto';
|
|
3
3
|
import express from 'express';
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
} from '../secrets-vault.routes.js';
|
|
5
|
+
import { createSecretsVaultRoutes, createSecretsVaultPublicRoutes, SecretOAuthError } from '../index.js';
|
|
6
|
+
// The one deliberate white-box reach: the open-redirect predicate is pinned as
|
|
7
|
+
// a unit table as well as end-to-end (see below), and it is on no entry because
|
|
8
|
+
// nothing outside the routes should decide what a safe return path is.
|
|
9
|
+
import { isSafeReturnPath } from '../secrets-vault.routes.js';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* OAUTH `returnTo`: a sign-in started from a page other than /connect must land
|
|
@@ -54,6 +54,11 @@ const MANUALS = [
|
|
|
54
54
|
|
|
55
55
|
const toolManualService = {
|
|
56
56
|
listAccessible: async () => MANUALS,
|
|
57
|
+
// The sign-in start reads the declaration from the file; here it reads as the catalog does.
|
|
58
|
+
liveDeclaration: async (_who: string, slug: string, varName: string) => {
|
|
59
|
+
const manual = MANUALS.find((m) => m.slug === slug);
|
|
60
|
+
return manual ? { manual, variable: manual.variables.find((v) => v.name === varName) } : null;
|
|
61
|
+
},
|
|
57
62
|
} as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['toolManualService'];
|
|
58
63
|
|
|
59
64
|
const accessControl = {
|
|
@@ -254,7 +259,35 @@ describe('GET /api/secrets/oauth/callback — where the browser lands', () => {
|
|
|
254
259
|
const state = signState({ u: 'u1', i: 'secret-1', n: 'nonce', r: RETURN_TO });
|
|
255
260
|
const location = await callback(base, state);
|
|
256
261
|
expect(location.startsWith(`${FRONTEND}${RETURN_TO}#error=`)).toBe(true);
|
|
257
|
-
//
|
|
262
|
+
// A failure that is not the provider's word stays generic: only the
|
|
263
|
+
// vault's own error carries a reason that was scrubbed for the page.
|
|
264
|
+
expect(decodeURIComponent(location.split('#error=')[1])).toBe(
|
|
265
|
+
'Authorization failed. Check the provider configuration and try again.',
|
|
266
|
+
);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("carries the provider's error code and Hexis's sentence to the page when the exchange was refused", async () => {
|
|
270
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
271
|
+
const refusal =
|
|
272
|
+
'Token endpoint returned HTTP 400 (invalid_client). The client id or client secret this sign-in uses is not accepted by the provider. The tool owner should check the declared client id and set the client secret again.';
|
|
273
|
+
completeOAuth.mockImplementation(async () => {
|
|
274
|
+
throw new SecretOAuthError(refusal, 400);
|
|
275
|
+
});
|
|
276
|
+
const base = await baseUrl();
|
|
277
|
+
const state = signState({ u: 'u1', i: 'secret-1', n: 'nonce', r: RETURN_TO });
|
|
278
|
+
const location = await callback(base, state);
|
|
279
|
+
expect(decodeURIComponent(location.split('#error=')[1])).toBe(`Authorization failed. ${refusal}`);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("keeps the generic message for the vault's own TRANSPORT errors, which quote what fetch said", async () => {
|
|
283
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
284
|
+
completeOAuth.mockImplementation(async () => {
|
|
285
|
+
// No provider status: not a provider's word, and never scrubbed.
|
|
286
|
+
throw new SecretOAuthError('Token endpoint request failed: fetch failed https://user:pw@provider.example/token');
|
|
287
|
+
});
|
|
288
|
+
const base = await baseUrl();
|
|
289
|
+
const state = signState({ u: 'u1', i: 'secret-1', n: 'nonce', r: RETURN_TO });
|
|
290
|
+
const location = await callback(base, state);
|
|
258
291
|
expect(decodeURIComponent(location.split('#error=')[1])).toBe(
|
|
259
292
|
'Authorization failed. Check the provider configuration and try again.',
|
|
260
293
|
);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
3
|
import { logger } from '../../shared/logging.js';
|
|
3
4
|
|
|
4
5
|
const log = logger('vault');
|
|
@@ -7,6 +8,8 @@ import type { Database } from '../database/index.js';
|
|
|
7
8
|
import { secrets } from '../database/index.js';
|
|
8
9
|
import { TokenCrypto } from '../../shared/token-crypto.js';
|
|
9
10
|
import { assertSafeFetchUrl } from '../../shared/ssrf.js';
|
|
11
|
+
import { readCapped } from '../../shared/read-capped.js';
|
|
12
|
+
import { standardTokenErrorCode } from '../../shared/oauth-token-error.js';
|
|
10
13
|
import {
|
|
11
14
|
type ISecretsVaultService,
|
|
12
15
|
type SecretSummary,
|
|
@@ -16,6 +19,7 @@ import {
|
|
|
16
19
|
type PutSharedStaticSecretInput,
|
|
17
20
|
type CreateOAuthSecretInput,
|
|
18
21
|
type OAuthProviderConfig,
|
|
22
|
+
type DeclaredOAuthProvider,
|
|
19
23
|
type VariableScopeResolver,
|
|
20
24
|
type NamespaceSecretCount,
|
|
21
25
|
isKeyInNamespace,
|
|
@@ -32,6 +36,79 @@ const MAX_VALUE_LEN = 100_000;
|
|
|
32
36
|
const REFRESH_SKEW_MS = 60_000;
|
|
33
37
|
/** Upper bound on a token-endpoint round-trip, so a hung provider can't block callers. */
|
|
34
38
|
const TOKEN_REQUEST_TIMEOUT_MS = 10_000;
|
|
39
|
+
/** How much of a refusing token endpoint's body is read for its error code: an OAuth error object, never a whole error page. */
|
|
40
|
+
const TOKEN_ERROR_BODY_MAX_BYTES = 2048;
|
|
41
|
+
// Hexis's own sentences for a refused token request: what to change, in our
|
|
42
|
+
// words, never the provider's.
|
|
43
|
+
const HINT_CLIENT =
|
|
44
|
+
'The client id or client secret this sign-in uses is not accepted by the provider. The tool owner should check the declared client id and set the client secret again.';
|
|
45
|
+
const HINT_GRANT =
|
|
46
|
+
'The provider refused the grant: for a sign-in, a redirect URL mismatch or an expired or reused authorization code; for a refresh, a refresh token that has expired or been revoked. Sign in again.';
|
|
47
|
+
const HINT_SCOPE =
|
|
48
|
+
"The provider refused the scopes this sign-in asks for. The tool owner should check the declared scopes against the provider's app.";
|
|
49
|
+
const HINT_UNAUTHORIZED =
|
|
50
|
+
"The provider does not allow this client to use this grant. The tool owner should check the provider's app settings.";
|
|
51
|
+
const HINT_REQUEST =
|
|
52
|
+
'The provider found the token request malformed. The tool owner should check the declared endpoints and parameters.';
|
|
53
|
+
const HINT_PROVIDER = 'The provider could not serve the token request. Try again later.';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Hexis's sentence for each error code a refusing token endpoint may answer:
|
|
57
|
+
* the standard ones (`shared/oauth-token-error.ts` is the one list of those,
|
|
58
|
+
* read here and by the OIDC configuration check) in `error`, and the ones
|
|
59
|
+
* HubSpot's token endpoint answers in its `status` field. Only a code from
|
|
60
|
+
* those lists is ever taken, exactly, so no other string a provider puts in a
|
|
61
|
+
* field — a secret, a secret with a suffix, free text — can reach a message.
|
|
62
|
+
* Nothing else of the body is ever taken: no description, no message, no first
|
|
63
|
+
* line. That is why a message built here needs no scrubbing, cutting or
|
|
64
|
+
* decoding, and why every spelling a secret could reach a log in (escaped,
|
|
65
|
+
* encoded, cut) stays inexpressible. A standard code without a sentence here
|
|
66
|
+
* (`access_denied`) is named and left unexplained.
|
|
67
|
+
*/
|
|
68
|
+
const TOKEN_ERROR_HINTS: ReadonlyMap<string, string> = new Map([
|
|
69
|
+
['invalid_request', HINT_REQUEST],
|
|
70
|
+
['invalid_client', HINT_CLIENT],
|
|
71
|
+
['invalid_grant', HINT_GRANT],
|
|
72
|
+
['unauthorized_client', HINT_UNAUTHORIZED],
|
|
73
|
+
['unsupported_grant_type', HINT_UNAUTHORIZED],
|
|
74
|
+
['invalid_scope', HINT_SCOPE],
|
|
75
|
+
['server_error', HINT_PROVIDER],
|
|
76
|
+
['temporarily_unavailable', HINT_PROVIDER],
|
|
77
|
+
['BAD_CLIENT_ID', HINT_CLIENT],
|
|
78
|
+
['BAD_CLIENT_SECRET', HINT_CLIENT],
|
|
79
|
+
['BAD_AUTH_CODE', HINT_GRANT],
|
|
80
|
+
['BAD_REDIRECT_URI', HINT_GRANT],
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
/** The codes HubSpot's token endpoint answers in `status` instead of a standard `error`. */
|
|
84
|
+
const HUBSPOT_STATUS_CODES: ReadonlySet<string> = new Set(['BAD_CLIENT_ID', 'BAD_CLIENT_SECRET', 'BAD_AUTH_CODE', 'BAD_REDIRECT_URI']);
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The known error code a refusing token endpoint answered, or null: a standard
|
|
88
|
+
* `error` (RFC 6749 §5.2), else HubSpot's own `status`, when it is exactly one
|
|
89
|
+
* of the codes known. Without even the code, a bare "HTTP 400" cannot tell a
|
|
90
|
+
* client the provider does not accept from a refused grant.
|
|
91
|
+
*/
|
|
92
|
+
export function tokenEndpointErrorCode(bodyText: string): string | null {
|
|
93
|
+
const standard = standardTokenErrorCode(bodyText);
|
|
94
|
+
if (standard !== null) return standard;
|
|
95
|
+
let json: unknown;
|
|
96
|
+
try {
|
|
97
|
+
json = JSON.parse(bodyText);
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
if (!json || typeof json !== 'object' || Array.isArray(json)) return null;
|
|
102
|
+
const { status } = json as Record<string, unknown>;
|
|
103
|
+
return typeof status === 'string' && HUBSPOT_STATUS_CODES.has(status) ? status : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The refusal in one message: the status, the known code when there is one, and Hexis's sentence for it. */
|
|
107
|
+
export function describeTokenEndpointRefusal(status: number, code: string | null): string {
|
|
108
|
+
const base = `Token endpoint returned HTTP ${status}`;
|
|
109
|
+
const hint = code === null ? undefined : TOKEN_ERROR_HINTS.get(code);
|
|
110
|
+
return hint ? `${base} (${code}). ${hint}` : base;
|
|
111
|
+
}
|
|
35
112
|
|
|
36
113
|
/**
|
|
37
114
|
* How a row is stored, or `null` when there is no row — the column is a plain
|
|
@@ -487,8 +564,9 @@ export class DbSecretsVaultService implements ISecretsVaultService {
|
|
|
487
564
|
throw new InvalidSecretError('clientSecret is required');
|
|
488
565
|
}
|
|
489
566
|
// Validate the provider config the same way createOAuth does (SSRF-safe https
|
|
490
|
-
// URLs)
|
|
491
|
-
//
|
|
567
|
+
// URLs) and store it beside the secret as a record of what was saved — what
|
|
568
|
+
// `statusFor` and the setup surfaces read. A declared sign-in does not run
|
|
569
|
+
// from it: `beginToolOAuthByKey` is handed the declaration as it reads now.
|
|
492
570
|
const provider = this.requireProvider({ ...input.provider, clientSecret: input.clientSecret });
|
|
493
571
|
const blob: OAuthBlob = { clientSecret: provider.clientSecret };
|
|
494
572
|
const valueEncrypted = this.crypto().encrypt(JSON.stringify(blob));
|
|
@@ -510,11 +588,15 @@ export class DbSecretsVaultService implements ISecretsVaultService {
|
|
|
510
588
|
redirectUri: string;
|
|
511
589
|
state: string;
|
|
512
590
|
scopes?: string[];
|
|
591
|
+
provider?: DeclaredOAuthProvider;
|
|
513
592
|
}): Promise<{ id: string; url: string }> {
|
|
514
593
|
const userId = this.requireUserId(input.userId);
|
|
515
594
|
const key = this.requireKey(input.key);
|
|
516
|
-
// The shared row holds the owner-set client secret
|
|
517
|
-
//
|
|
595
|
+
// The shared row holds the owner-set client secret. For a client the
|
|
596
|
+
// platform registered itself it also holds the provider configuration, the
|
|
597
|
+
// only description of that client there is; for a declared sign-in the
|
|
598
|
+
// configuration it holds is a record of the save, and `input.provider` (the
|
|
599
|
+
// tool file as it reads now) is what the sign-in follows.
|
|
518
600
|
const [shared] = await this.db
|
|
519
601
|
.select()
|
|
520
602
|
.from(secrets)
|
|
@@ -524,11 +606,43 @@ export class DbSecretsVaultService implements ISecretsVaultService {
|
|
|
524
606
|
throw new SecretOAuthError("This tool's owner hasn't finished setting this up");
|
|
525
607
|
}
|
|
526
608
|
const sharedBlob = this.readBlob(shared.valueEncrypted);
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
609
|
+
let meta: OAuthProviderConfig;
|
|
610
|
+
let oauthMeta: typeof shared.oauthMeta;
|
|
611
|
+
if (input.provider) {
|
|
612
|
+
// A declared client is confidential: without the owner's secret there is
|
|
613
|
+
// nothing to sign in with, whatever the stored row says of itself.
|
|
614
|
+
if (!sharedBlob.clientSecret) {
|
|
615
|
+
throw new SecretOAuthError("This tool's owner hasn't finished setting this up");
|
|
616
|
+
}
|
|
617
|
+
// The save's own validation (https, no private or local address), BEFORE
|
|
618
|
+
// the caller's row is written or a URL built: a declaration that fails it
|
|
619
|
+
// sends nothing anywhere.
|
|
620
|
+
meta = this.requireProvider({ ...input.provider, publicClient: false });
|
|
621
|
+
oauthMeta = this.publicProviderMeta(meta);
|
|
622
|
+
// A MOVED PROPERTY, decided on the ticket (OAuth-Sign-In-Follows-The-Live-
|
|
623
|
+
// Declaration, 2026-10-27): the secret used to be bound to the token
|
|
624
|
+
// endpoint pinned at its save, so a later file edit could not send it
|
|
625
|
+
// anywhere else; now it goes where the file says at each start, so a
|
|
626
|
+
// writer of the tool file can move it (to an https, public host — the
|
|
627
|
+
// validation above still holds). Nothing refuses that; this line is the
|
|
628
|
+
// trail an operator reads when a secret went somewhere new.
|
|
629
|
+
const recorded = (shared.oauthMeta ?? {}) as { tokenUrl?: unknown };
|
|
630
|
+
if (typeof recorded.tokenUrl === 'string' && recorded.tokenUrl !== meta.tokenUrl) {
|
|
631
|
+
// Origins only: a path or query is the file's to say, not the log's.
|
|
632
|
+
const origin = (u: string) => (URL.canParse(u) ? new URL(u).origin : '(unparseable)');
|
|
633
|
+
log.warn(`sign-in on ${key}: the declared token endpoint differs from the one recorded when the client secret was saved`, {
|
|
634
|
+
savedOrigin: origin(recorded.tokenUrl),
|
|
635
|
+
declaredOrigin: origin(meta.tokenUrl),
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
} else {
|
|
639
|
+
meta = this.readMeta(shared.oauthMeta);
|
|
640
|
+
oauthMeta = shared.oauthMeta;
|
|
641
|
+
// A missing secret is "not set up yet" ONLY for confidential clients — a
|
|
642
|
+
// dynamically-registered PUBLIC client (PKCE-only) never has one.
|
|
643
|
+
if (!sharedBlob.clientSecret && !meta.publicClient) {
|
|
644
|
+
throw new SecretOAuthError("This tool's owner hasn't finished setting this up");
|
|
645
|
+
}
|
|
532
646
|
}
|
|
533
647
|
|
|
534
648
|
// Carry forward any tokens the caller already holds so re-entering the flow
|
|
@@ -543,12 +657,12 @@ export class DbSecretsVaultService implements ISecretsVaultService {
|
|
|
543
657
|
// encKey must degrade to "start fresh", not crash the recovery flow — and (b)
|
|
544
658
|
// they were minted under the SAME provider config we're about to (re)write, or
|
|
545
659
|
// a refresh_token for a stale client id/secret would silently fail once carried
|
|
546
|
-
// into this row after an owner rotates the shared secret
|
|
660
|
+
// into this row after an owner rotates the shared secret or edits the
|
|
661
|
+
// declaration. Compared as values, not as serialised text: the stored side
|
|
662
|
+
// has been through jsonb, which does not keep key order, and a declared
|
|
663
|
+
// sign-in's side is built here.
|
|
547
664
|
let existingTokens: OAuthTokenSet | undefined;
|
|
548
|
-
if (
|
|
549
|
-
existingUserRow?.kind === 'oauth' &&
|
|
550
|
-
JSON.stringify(existingUserRow.oauthMeta) === JSON.stringify(shared.oauthMeta)
|
|
551
|
-
) {
|
|
665
|
+
if (existingUserRow?.kind === 'oauth' && isDeepStrictEqual(existingUserRow.oauthMeta, oauthMeta)) {
|
|
552
666
|
try {
|
|
553
667
|
existingTokens = this.readBlob(existingUserRow.valueEncrypted).tokens;
|
|
554
668
|
} catch {
|
|
@@ -567,9 +681,13 @@ export class DbSecretsVaultService implements ISecretsVaultService {
|
|
|
567
681
|
const requestedScopes = input.scopes && input.scopes.length ? input.scopes : meta.scopes;
|
|
568
682
|
const pendingScopes = requestedScopes && requestedScopes.length ? requestedScopes.join(' ') : undefined;
|
|
569
683
|
|
|
570
|
-
// Provision (or reset) the caller's own oauth row for this key from the
|
|
571
|
-
//
|
|
572
|
-
//
|
|
684
|
+
// Provision (or reset) the caller's own oauth row for this key from the provider
|
|
685
|
+
// configuration this start uses + the shared secret, preserving any existing
|
|
686
|
+
// tokens. The row records that configuration because `completeOAuth` and every
|
|
687
|
+
// later refresh of this grant read it from HERE: the exchange goes to the token
|
|
688
|
+
// endpoint, under the client id, that the consent was given for. Keyed
|
|
689
|
+
// `<manual>_<VAR>` so `resolve` (scope 'user') returns the token once sign-in
|
|
690
|
+
// completes.
|
|
573
691
|
const blob: OAuthBlob = {
|
|
574
692
|
clientSecret: sharedBlob.clientSecret,
|
|
575
693
|
tokens: existingTokens,
|
|
@@ -579,16 +697,15 @@ export class DbSecretsVaultService implements ISecretsVaultService {
|
|
|
579
697
|
const valueEncrypted = this.crypto().encrypt(JSON.stringify(blob));
|
|
580
698
|
const [row] = await this.db
|
|
581
699
|
.insert(secrets)
|
|
582
|
-
.values({ userId, key, kind: 'oauth', label: shared.label, valueEncrypted, oauthMeta
|
|
700
|
+
.values({ userId, key, kind: 'oauth', label: shared.label, valueEncrypted, oauthMeta })
|
|
583
701
|
.onConflictDoUpdate({
|
|
584
702
|
target: [secrets.userId, secrets.key],
|
|
585
|
-
set: { kind: 'oauth', valueEncrypted, oauthMeta
|
|
703
|
+
set: { kind: 'oauth', valueEncrypted, oauthMeta, updatedAt: new Date() },
|
|
586
704
|
})
|
|
587
705
|
.returning();
|
|
588
706
|
|
|
589
|
-
// Build the consent URL exactly as beginOAuth does, from the
|
|
590
|
-
//
|
|
591
|
-
// stay owner-pinned.
|
|
707
|
+
// Build the consent URL exactly as beginOAuth does, from the configuration
|
|
708
|
+
// this start uses, with the requested scopes chosen above.
|
|
592
709
|
const url = new URL(meta.authorizationUrl);
|
|
593
710
|
url.searchParams.set('response_type', 'code');
|
|
594
711
|
url.searchParams.set('client_id', meta.clientId);
|
|
@@ -732,7 +849,18 @@ export class DbSecretsVaultService implements ISecretsVaultService {
|
|
|
732
849
|
err instanceof SecretOAuthError &&
|
|
733
850
|
(err.providerStatus === 400 || err.providerStatus === 401)
|
|
734
851
|
) {
|
|
735
|
-
|
|
852
|
+
// The only trace of a sign-in that silently turns into "not connected":
|
|
853
|
+
// say which one, the provider's reason, and what the guarded wipe did —
|
|
854
|
+
// a concurrent refresh may have rotated the tokens first, in which case
|
|
855
|
+
// nothing was wiped and the rotated grant is served.
|
|
856
|
+
const result = await this.wipeTokens(row, blob);
|
|
857
|
+
const did = result.wiped
|
|
858
|
+
? 'sign-in wiped'
|
|
859
|
+
: result.outcome === 'refreshed'
|
|
860
|
+
? 'the row had been rotated by a concurrent refresh meanwhile; the rotated sign-in is kept'
|
|
861
|
+
: 'the row had been removed or replaced meanwhile; nothing wiped';
|
|
862
|
+
log.warn(`refresh refused for "${row.key}", ${did}: ${err.message}`);
|
|
863
|
+
return result;
|
|
736
864
|
}
|
|
737
865
|
return { outcome: 'transient' };
|
|
738
866
|
}
|
|
@@ -794,7 +922,10 @@ export class DbSecretsVaultService implements ISecretsVaultService {
|
|
|
794
922
|
private async wipeTokens(
|
|
795
923
|
row: typeof secrets.$inferSelect,
|
|
796
924
|
blob: OAuthBlob,
|
|
797
|
-
): Promise<
|
|
925
|
+
): Promise<
|
|
926
|
+
| { outcome: 'refreshed'; accessToken: string; wiped: false }
|
|
927
|
+
| { outcome: 'rejected'; accessToken: null; wiped: boolean }
|
|
928
|
+
> {
|
|
798
929
|
const next: OAuthBlob = { clientSecret: blob.clientSecret };
|
|
799
930
|
// Guarded on the ciphertext we read, so a concurrent refresh that
|
|
800
931
|
// already persisted ROTATED tokens can't be wiped by our stale
|
|
@@ -807,13 +938,17 @@ export class DbSecretsVaultService implements ISecretsVaultService {
|
|
|
807
938
|
.where(and(eq(secrets.id, row.id), eq(secrets.valueEncrypted, row.valueEncrypted)))
|
|
808
939
|
.returning({ id: secrets.id });
|
|
809
940
|
if (Array.isArray(wiped) && wiped.length === 0) {
|
|
941
|
+
// `wiped: false` either way: this write matched nothing, and the caller
|
|
942
|
+
// reports what it did, not what it set out to do.
|
|
810
943
|
const fresh = await this.currentAccessToken(row.id);
|
|
811
|
-
return fresh
|
|
944
|
+
return fresh
|
|
945
|
+
? { outcome: 'refreshed', accessToken: fresh, wiped: false }
|
|
946
|
+
: { outcome: 'rejected', accessToken: null, wiped: false };
|
|
812
947
|
}
|
|
813
948
|
// The sign-in just became not-connected — let credential-validity caches
|
|
814
949
|
// (pooled downstream connections dialed with the dead token) drop it now.
|
|
815
950
|
this.notifyMutation(row.userId);
|
|
816
|
-
return { outcome: 'rejected', accessToken: null };
|
|
951
|
+
return { outcome: 'rejected', accessToken: null, wiped: true };
|
|
817
952
|
}
|
|
818
953
|
|
|
819
954
|
async putSharedOAuthProvider(input: {
|
|
@@ -887,7 +1022,15 @@ export class DbSecretsVaultService implements ISecretsVaultService {
|
|
|
887
1022
|
throw new SecretOAuthError(msg);
|
|
888
1023
|
}
|
|
889
1024
|
if (!res.ok) {
|
|
890
|
-
|
|
1025
|
+
// The provider's error CODE rides in the message: the callback route and
|
|
1026
|
+
// the refresh path log that message, and it is the one line that says
|
|
1027
|
+
// WHY a sign-in failed (a client the provider does not accept, a refused
|
|
1028
|
+
// authorization code). Best effort: the STATUS is the contract (the
|
|
1029
|
+
// refresh path reads 400/401 off it to retire a dead grant), and a body
|
|
1030
|
+
// that fails to read must not turn a definitive refusal into an
|
|
1031
|
+
// unclassified error.
|
|
1032
|
+
const code = tokenEndpointErrorCode(await readCapped(res, TOKEN_ERROR_BODY_MAX_BYTES).catch(() => ''));
|
|
1033
|
+
throw new SecretOAuthError(describeTokenEndpointRefusal(res.status, code), res.status);
|
|
891
1034
|
}
|
|
892
1035
|
const json = (await res.json()) as Record<string, unknown>;
|
|
893
1036
|
const accessToken = typeof json.access_token === 'string' ? json.access_token : '';
|
|
@@ -27,4 +27,4 @@ export {
|
|
|
27
27
|
DEFAULT_SECRETS_SCOPE,
|
|
28
28
|
} from './secrets-variable-loader.js';
|
|
29
29
|
// Reached by other modules; exported here so they stop importing the files.
|
|
30
|
-
export { scopesCovered } from './secrets-vault.contract.js';
|
|
30
|
+
export { scopesCovered, userSecretGap, type UserSecretGap } from './secrets-vault.contract.js';
|