@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 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. The only tool secret
76
- Intel stores is the resulting per-user access token for its own portal endpoint, sealed with a key
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.
@@ -141,6 +141,7 @@ export function createOpenId(deps) {
141
141
  code_challenge: input.codeChallenge,
142
142
  code_challenge_method: "S256",
143
143
  state: input.state,
144
+ ...(input.prompt ? { prompt: input.prompt } : {}),
144
145
  });
145
146
  },
146
147
  async exchange(input) {
@@ -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
- const remaining = (pending.user.expiresAt - deps.now().getTime()) / 1_000;
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 {
@@ -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);
@@ -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", "Connect the MCP portal to use tools");
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 instead of prompting a reconnect.
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", "Reconnect the MCP portal to continue");
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
- // An unconnected portal is a normal state, not an error: the UI shows a connect prompt.
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 connected, so
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 "connect the portal" apart from "the portal failed", and a
96
- // 401 here would otherwise look like an expired Intel session to the browser.
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. Dropping the old table would
6
- -- otherwise trip the foreign keys of `knowledge_versions`, `knowledge_links`, `tree_grants` and
7
- -- `flows`; deferring them to the end of the migration lets the rename put the same IDs back under
8
- -- the same name before anything is checked.
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
- CREATE TABLE knowledge_nodes_next (
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 knowledge_nodes_next(id),
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 knowledge_nodes_next (
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 knowledge_nodes;
56
+ FROM knowledge_nodes_carry;
33
57
 
34
- DROP TABLE knowledge_nodes;
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
- ALTER TABLE knowledge_nodes_next RENAME TO knowledge_nodes;
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {