@anchrd/intel-api 0.3.2 → 0.4.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/README.md +12 -2
- package/dist/adapters/openid/openid.js +1 -0
- package/dist/adapters/session-cookie/session-cookie.js +3 -0
- package/dist/auth/auth.js +45 -5
- package/dist/auth/auth.types.d.ts +2 -0
- package/dist/intel/intel.js +3 -1
- package/dist/tools/tools.js +12 -8
- package/migrations/0005_tables_in_the_knowledge_tree.sql +39 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -72,8 +72,18 @@ uses PKCE and dynamic public-client registration; browser access tokens stay ins
|
|
|
72
72
|
`node_modules/@anchrd/intel-api/examples/dev.vars.example` to `.dev.vars` for a local customer Worker.
|
|
73
73
|
|
|
74
74
|
The portal endpoint exposes RFC 9728 metadata. The Tools UI follows that metadata, dynamically
|
|
75
|
-
registers with Gate or Cloudflare Access, and completes a separate PKCE flow
|
|
76
|
-
|
|
75
|
+
registers with Gate or Cloudflare Access, and completes a separate PKCE flow — silently, with
|
|
76
|
+
`prompt=none`, as soon as a Gate session exists. Nobody is asked to connect anything.
|
|
77
|
+
|
|
78
|
+
⚠️ **Two settings outside this repository decide whether that silent sign-in can work.** In
|
|
79
|
+
Cloudflare Zero Trust → Access controls → AI controls → your portal → Edit → Advanced settings,
|
|
80
|
+
`Managed OAuth` must be enabled, and an Access policy must carry the people who use Intel. Without
|
|
81
|
+
both, every silent sign-in is refused and the Tools area shows "No access to the company portal" —
|
|
82
|
+
correct behaviour for somebody outside every policy, and a misleading one for a deployment that
|
|
83
|
+
simply never enabled the setting.
|
|
84
|
+
|
|
85
|
+
The only tool secret Intel stores is the resulting per-user access token for its own portal
|
|
86
|
+
endpoint — one per person, never one shared operator token — sealed with a key
|
|
77
87
|
derived from `INTEL_SESSION_SECRET` and kept in the `portal_tokens` table of your D1; provider
|
|
78
88
|
credentials stay with the portal and never reach Intel. The Intel audience token is never forwarded
|
|
79
89
|
to another OAuth resource.
|
|
@@ -27,6 +27,9 @@ const ConnectionPendingSession = z.strictObject({
|
|
|
27
27
|
resource: z.url(),
|
|
28
28
|
userId: z.string().min(1),
|
|
29
29
|
user: UserSession,
|
|
30
|
+
// Older cookies carry no `silent`, and one in flight across a deploy must not become an invalid
|
|
31
|
+
// session: absent means the visible flow, which is what those attempts were.
|
|
32
|
+
silent: z.boolean().default(false),
|
|
30
33
|
expiresAt: z.number().int().positive(),
|
|
31
34
|
});
|
|
32
35
|
const Session = z.discriminatedUnion("kind", [
|
package/dist/auth/auth.js
CHANGED
|
@@ -2,6 +2,22 @@ import { calculatePKCECodeChallenge, randomPKCECodeVerifier, randomState } from
|
|
|
2
2
|
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
3
3
|
import { SafeReturnPath } from "../shared/safe-return-path/safe-return-path.js";
|
|
4
4
|
const cookieName = "intel_session";
|
|
5
|
+
// What an authorization server answers when `prompt=none` would have worked, but only with somebody
|
|
6
|
+
// looking at a screen. It is the expected answer to a silent attempt, not a broken deployment.
|
|
7
|
+
const InteractionRequired = new Set([
|
|
8
|
+
"login_required",
|
|
9
|
+
"interaction_required",
|
|
10
|
+
"consent_required",
|
|
11
|
+
"account_selection_required",
|
|
12
|
+
]);
|
|
13
|
+
// One fixed marker, never the portal's own words. `error_description` is a sentence written by the
|
|
14
|
+
// authorization server about somebody else's policy: putting it in the URL would hand whatever
|
|
15
|
+
// renders the page next a string Intel does not control.
|
|
16
|
+
function withConnectError(returnTo) {
|
|
17
|
+
const url = new URL(returnTo, "https://intel.invalid");
|
|
18
|
+
url.searchParams.set("connectError", "portal_sign_in_refused");
|
|
19
|
+
return `${url.pathname}${url.search}`;
|
|
20
|
+
}
|
|
5
21
|
function cookieValue(headers) {
|
|
6
22
|
const values = headers.get("cookie")?.split(";") ?? [];
|
|
7
23
|
for (const value of values) {
|
|
@@ -51,6 +67,15 @@ export function createBrowserAuth(deps) {
|
|
|
51
67
|
createdAt: deps.now().toISOString(),
|
|
52
68
|
});
|
|
53
69
|
}
|
|
70
|
+
// The connection handoff parks the Intel session inside the pending cookie, so every way out of
|
|
71
|
+
// the callback has to put it back — the portal attempt must never cost somebody their Intel login.
|
|
72
|
+
async function restore(user, returnTo, location) {
|
|
73
|
+
const remaining = (user.expiresAt - deps.now().getTime()) / 1_000;
|
|
74
|
+
if (remaining <= 0) {
|
|
75
|
+
return redirect(`/auth/login?returnTo=${encodeURIComponent(returnTo)}`, sessionCookie("", secure, 0));
|
|
76
|
+
}
|
|
77
|
+
return redirect(location, sessionCookie(await deps.sessions.seal(user), secure, remaining));
|
|
78
|
+
}
|
|
54
79
|
async function userSession(client, tokens) {
|
|
55
80
|
return {
|
|
56
81
|
version: 1,
|
|
@@ -93,6 +118,10 @@ export function createBrowserAuth(deps) {
|
|
|
93
118
|
throw new IntelError(401, "authentication_required", "Authentication is required");
|
|
94
119
|
}
|
|
95
120
|
const returnTo = SafeReturnPath.catch("/tools").parse(requestUrl.searchParams.get("returnTo") ?? "/tools");
|
|
121
|
+
// Gate is the OIDC provider Cloudflare Access consumes, so whoever holds a valid Intel
|
|
122
|
+
// session is already the person the portal would ask about. `prompt=none` says exactly that:
|
|
123
|
+
// answer from the session that exists, and refuse rather than show anybody a login (#60).
|
|
124
|
+
const silent = requestUrl.searchParams.get("silent") === "1";
|
|
96
125
|
if (!deps.portalUrl) {
|
|
97
126
|
throw new IntelError(503, "portal_not_configured", "No MCP portal is configured for this deployment");
|
|
98
127
|
}
|
|
@@ -113,6 +142,7 @@ export function createBrowserAuth(deps) {
|
|
|
113
142
|
resource: discovered.resource,
|
|
114
143
|
userId,
|
|
115
144
|
user,
|
|
145
|
+
silent,
|
|
116
146
|
expiresAt: deps.now().getTime() + 10 * 60 * 1_000,
|
|
117
147
|
};
|
|
118
148
|
const authorizationUrl = await deps.oauth.authorizationUrl({
|
|
@@ -123,6 +153,7 @@ export function createBrowserAuth(deps) {
|
|
|
123
153
|
codeChallenge,
|
|
124
154
|
state,
|
|
125
155
|
...(discovered.scope ? { scope: discovered.scope } : {}),
|
|
156
|
+
...(silent ? { prompt: "none" } : {}),
|
|
126
157
|
});
|
|
127
158
|
return redirect(authorizationUrl.href, sessionCookie(await deps.sessions.seal(pending), secure, 10 * 60));
|
|
128
159
|
},
|
|
@@ -133,6 +164,19 @@ export function createBrowserAuth(deps) {
|
|
|
133
164
|
pending.expiresAt <= deps.now().getTime()) {
|
|
134
165
|
throw new IntelError(400, "oauth_session_invalid", "OAuth session is missing or expired");
|
|
135
166
|
}
|
|
167
|
+
// ⚠️ An authorization server reports a refusal on the redirect URI, not by failing the token
|
|
168
|
+
// exchange. Reading it here is what keeps a refused silent attempt from surfacing as a raw
|
|
169
|
+
// OAuth error, and it is the only place that knows whether a visible attempt is still owed.
|
|
170
|
+
const refusal = requestUrl.searchParams.get("error");
|
|
171
|
+
if (pending.kind === "connection-pending" && refusal) {
|
|
172
|
+
// The silent attempt only asked whether the sign-in works without a screen. "Not without
|
|
173
|
+
// one" is an answer, so the visible flow runs once — and because that one is not silent, a
|
|
174
|
+
// second refusal ends in the message instead of a third attempt.
|
|
175
|
+
const visibleAttemptLeft = pending.silent && InteractionRequired.has(refusal);
|
|
176
|
+
return await restore(pending.user, pending.returnTo, visibleAttemptLeft
|
|
177
|
+
? `/auth/connect?returnTo=${encodeURIComponent(pending.returnTo)}`
|
|
178
|
+
: withConnectError(pending.returnTo));
|
|
179
|
+
}
|
|
136
180
|
const tokens = await deps.oauth.exchange({
|
|
137
181
|
issuer: pending.kind === "connection-pending" ? pending.issuer : gateIssuer,
|
|
138
182
|
clientId: pending.clientId,
|
|
@@ -151,11 +195,7 @@ export function createBrowserAuth(deps) {
|
|
|
151
195
|
clientId: pending.clientId,
|
|
152
196
|
resource: pending.resource,
|
|
153
197
|
});
|
|
154
|
-
|
|
155
|
-
if (remaining <= 0) {
|
|
156
|
-
return redirect(`/auth/login?returnTo=${encodeURIComponent(pending.returnTo)}`, sessionCookie("", secure, 0));
|
|
157
|
-
}
|
|
158
|
-
return redirect(pending.returnTo, sessionCookie(await deps.sessions.seal(pending.user), secure, remaining));
|
|
198
|
+
return await restore(pending.user, pending.returnTo, pending.returnTo);
|
|
159
199
|
}
|
|
160
200
|
const session = await userSession(pending.clientId, tokens);
|
|
161
201
|
return redirect(pending.returnTo, sessionCookie(await deps.sessions.seal(session), secure, (session.expiresAt - deps.now().getTime()) / 1_000));
|
|
@@ -24,6 +24,7 @@ export interface OAuthPort {
|
|
|
24
24
|
codeChallenge: string;
|
|
25
25
|
state: string;
|
|
26
26
|
scope?: string;
|
|
27
|
+
prompt?: "none";
|
|
27
28
|
}): Promise<URL>;
|
|
28
29
|
exchange(input: {
|
|
29
30
|
issuer: string;
|
|
@@ -76,6 +77,7 @@ export type AuthSession = {
|
|
|
76
77
|
resource: string;
|
|
77
78
|
userId: string;
|
|
78
79
|
user: UserAuthSession;
|
|
80
|
+
silent: boolean;
|
|
79
81
|
expiresAt: number;
|
|
80
82
|
} | UserAuthSession;
|
|
81
83
|
export interface SessionCodec {
|
package/dist/intel/intel.js
CHANGED
|
@@ -32,7 +32,9 @@ export function createIntel(deps) {
|
|
|
32
32
|
const browserAuth = deps.auth;
|
|
33
33
|
if (browserAuth) {
|
|
34
34
|
app.get("/auth/login", async (context) => await browserAuth.login(new URL(context.req.url)));
|
|
35
|
-
// One connect route for the one portal: there are no per-source connections any more.
|
|
35
|
+
// One connect route for the one portal: there are no per-source connections any more. With
|
|
36
|
+
// `?silent=1` it runs `prompt=none`, which is how the Tools screen reaches it without anybody
|
|
37
|
+
// clicking (#60); the route itself is unchanged otherwise, including who may use it.
|
|
36
38
|
app.get("/auth/connect", async (context) => {
|
|
37
39
|
const connectReturnTo = encodeURIComponent("/auth/connect?returnTo=/tools");
|
|
38
40
|
const session = await browserAuth.resolve(context.req.raw.headers);
|
package/dist/tools/tools.js
CHANGED
|
@@ -12,20 +12,23 @@ export function createTools(deps) {
|
|
|
12
12
|
return deps.portalUrl;
|
|
13
13
|
}
|
|
14
14
|
// Authorization for tools lives entirely in the portal, so "may this user act" reduces to "does
|
|
15
|
-
// this user have a usable portal token".
|
|
15
|
+
// this user have a usable portal token". ⚠️ The token is read per actor and never shared: one
|
|
16
|
+
// operator token for everybody would make every catalog the same one and the portal's Access
|
|
17
|
+
// policies decorative (ADR-0003).
|
|
16
18
|
async function accessToken(actor) {
|
|
17
19
|
const stored = await deps.tokens.read(actor.id);
|
|
18
20
|
if (!stored) {
|
|
19
|
-
throw new IntelError(401, "portal_not_connected", "
|
|
21
|
+
throw new IntelError(401, "portal_not_connected", "The portal has not signed this user in yet");
|
|
20
22
|
}
|
|
21
23
|
if (stored.expiresAt > deps.now().getTime() + RefreshWindowMs)
|
|
22
24
|
return stored.accessToken;
|
|
23
25
|
const refreshed = stored.refreshToken ? await deps.refresh(stored) : null;
|
|
24
26
|
if (!refreshed) {
|
|
25
27
|
// A token that cannot be renewed is dropped: leaving it would keep failing every call with a
|
|
26
|
-
// stale credential
|
|
28
|
+
// stale credential. The browser answers this by signing in silently again (#60); an MCP
|
|
29
|
+
// client sees the code and repeats its own authorization.
|
|
27
30
|
await deps.tokens.clear(actor.id);
|
|
28
|
-
throw new IntelError(401, "portal_reconnect_required", "
|
|
31
|
+
throw new IntelError(401, "portal_reconnect_required", "The portal sign-in for this user has expired");
|
|
29
32
|
}
|
|
30
33
|
await deps.tokens.write(actor.id, refreshed);
|
|
31
34
|
return refreshed.accessToken;
|
|
@@ -83,17 +86,18 @@ export function createTools(deps) {
|
|
|
83
86
|
return {
|
|
84
87
|
async catalog(actor) {
|
|
85
88
|
const stored = await deps.tokens.read(actor.id);
|
|
86
|
-
//
|
|
89
|
+
// No portal sign-in yet is a normal state, not an error: the browser answers it by running
|
|
90
|
+
// the silent sign-in and asking again (#60).
|
|
87
91
|
if (!stored)
|
|
88
92
|
return { portalConnected: false, items: [] };
|
|
89
93
|
try {
|
|
90
94
|
return { portalConnected: true, items: await capabilities(actor) };
|
|
91
95
|
}
|
|
92
96
|
catch (error) {
|
|
93
|
-
// A token that is gone or beyond renewal is the same answer as never having
|
|
97
|
+
// A token that is gone or beyond renewal is the same answer as never having signed in, so
|
|
94
98
|
// reading the catalog reports it as a state. Only a portal that does not answer stays an
|
|
95
|
-
// error — the view has to tell "
|
|
96
|
-
//
|
|
99
|
+
// error — the view has to tell "sign in again" apart from "the portal failed", and a 401
|
|
100
|
+
// here would otherwise look like an expired Intel session to the browser.
|
|
97
101
|
if (error instanceof IntelError &&
|
|
98
102
|
(error.code === "portal_not_connected" || error.code === "portal_reconnect_required")) {
|
|
99
103
|
return { portalConnected: false, items: [] };
|
|
@@ -2,15 +2,39 @@
|
|
|
2
2
|
-- `parent_id`, same folder grants, same immutable `knowledge_versions` rows, same R2 body
|
|
3
3
|
-- (ADR-0004 §1). Nothing here creates a second content model; only the CHECK has to learn the word.
|
|
4
4
|
--
|
|
5
|
-
-- SQLite cannot alter a CHECK constraint, so the table is rebuilt.
|
|
6
|
-
--
|
|
7
|
-
--
|
|
8
|
-
--
|
|
5
|
+
-- SQLite cannot alter a CHECK constraint, so the table is rebuilt. The rebuild is written the long
|
|
6
|
+
-- way round, and both detours are here because the short way was tried and D1 rolled it back. The
|
|
7
|
+
-- table this migration leaves behind is the same one either way — same columns, same constraints,
|
|
8
|
+
-- same indexes — so an installation that already applied this file keeps exactly what it has.
|
|
9
|
+
--
|
|
10
|
+
-- ⚠️ First detour: the new table is created under the final name rather than built beside the old
|
|
11
|
+
-- one and renamed over it. `DROP TABLE` on a parent runs an implicit `DELETE FROM` first, so the
|
|
12
|
+
-- moment the old `knowledge_nodes` goes, every row of `knowledge_versions`, `tree_grants` and
|
|
13
|
+
-- `flows` pointing at a node is a foreign-key violation. `defer_foreign_keys` postpones the
|
|
14
|
+
-- complaint to COMMIT but does not withdraw it, and `ALTER TABLE ... RENAME` does not settle it
|
|
15
|
+
-- either: a rename puts the name back, not the rows. Only inserting the nodes again, under the name
|
|
16
|
+
-- the children have referenced all along, does. On an empty database none of this is visible —
|
|
17
|
+
-- nothing points at anything — which is precisely how the first version passed a green test suite
|
|
18
|
+
-- and then failed against the first database that had content in it.
|
|
19
|
+
--
|
|
20
|
+
-- ⚠️ Second detour: `knowledge_links` is the only child of `knowledge_nodes` declared ON DELETE
|
|
21
|
+
-- CASCADE, so that same implicit delete does not merely flag its rows, it removes them — the
|
|
22
|
+
-- migration would have committed with every relationship between two documents quietly gone. The
|
|
23
|
+
-- rows are carried out of the way first and put back afterwards. That is a rescue, not a decision
|
|
24
|
+
-- about the data: nothing is dropped, rewritten or reinterpreted here.
|
|
9
25
|
PRAGMA defer_foreign_keys = TRUE;
|
|
10
26
|
|
|
11
|
-
|
|
27
|
+
-- Plain holding tables on purpose: no keys, no CHECKs, no foreign keys, and the column set taken
|
|
28
|
+
-- from whatever the live table has. Anything enforced here would only be enforced a second time on
|
|
29
|
+
-- the way back in, and a holding table that can reject a row is a holding table that can lose one.
|
|
30
|
+
CREATE TABLE knowledge_nodes_carry AS SELECT * FROM knowledge_nodes;
|
|
31
|
+
CREATE TABLE knowledge_links_carry AS SELECT * FROM knowledge_links;
|
|
32
|
+
|
|
33
|
+
DROP TABLE knowledge_nodes;
|
|
34
|
+
|
|
35
|
+
CREATE TABLE knowledge_nodes (
|
|
12
36
|
id TEXT PRIMARY KEY NOT NULL,
|
|
13
|
-
parent_id TEXT REFERENCES
|
|
37
|
+
parent_id TEXT REFERENCES knowledge_nodes(id),
|
|
14
38
|
kind TEXT NOT NULL CHECK (kind IN ('folder', 'document', 'attachment', 'table')),
|
|
15
39
|
title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 240),
|
|
16
40
|
description TEXT CHECK (description IS NULL OR length(description) <= 2000),
|
|
@@ -22,18 +46,23 @@ CREATE TABLE knowledge_nodes_next (
|
|
|
22
46
|
archived_at TEXT
|
|
23
47
|
);
|
|
24
48
|
|
|
25
|
-
INSERT INTO
|
|
49
|
+
INSERT INTO knowledge_nodes (
|
|
26
50
|
id, parent_id, kind, title, description, context_policy, owner_id,
|
|
27
51
|
current_version_id, created_at, updated_at, archived_at
|
|
28
52
|
)
|
|
29
53
|
SELECT
|
|
30
54
|
id, parent_id, kind, title, description, context_policy, owner_id,
|
|
31
55
|
current_version_id, created_at, updated_at, archived_at
|
|
32
|
-
FROM
|
|
56
|
+
FROM knowledge_nodes_carry;
|
|
33
57
|
|
|
34
|
-
|
|
58
|
+
-- `OR IGNORE` because whether the cascade above actually fired is SQLite's business, not this
|
|
59
|
+
-- migration's: if it did, this puts the rows back; if it did not, each one is already present under
|
|
60
|
+
-- the same primary key and this is a no-op. Either way `knowledge_links` ends up holding exactly
|
|
61
|
+
-- what it held before, which is the only outcome this statement is permitted to have.
|
|
62
|
+
INSERT OR IGNORE INTO knowledge_links SELECT * FROM knowledge_links_carry;
|
|
35
63
|
|
|
36
|
-
|
|
64
|
+
DROP TABLE knowledge_nodes_carry;
|
|
65
|
+
DROP TABLE knowledge_links_carry;
|
|
37
66
|
|
|
38
67
|
CREATE INDEX knowledge_nodes_parent_idx ON knowledge_nodes(parent_id, archived_at, title);
|
|
39
68
|
CREATE INDEX knowledge_nodes_owner_idx ON knowledge_nodes(owner_id, archived_at);
|