diff --git a/README.md b/README.md index f8e0a9d..cd1bba8 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Mnemuron stores reusable memories with their sources and revisions, so authorize The central service stores data in SQLite. Adapters connect agent lifecycle events to the service and keep a local outbox when it is unavailable. There is no required cloud memory service or external vector database. -> **Status: experimental.** The project targets a single user's self-hosted workspace. APIs, schemas, and host integrations may change. `production_ready` remains `false`; adapter availability is not a claim that every host version or deployment is supported. +> **Status: experimental.** Single-owner self-hosting remains the default. An opt-in account-isolated console is available for local evaluation; deployment and recovery policies require separate review. APIs, schemas, and host integrations may change. `production_ready` remains `false`; adapter availability is not a claim that every host version or deployment is supported. ## Why Mnemuron? @@ -75,6 +75,7 @@ These are source integrations, not a universal installer. Host plugin loading, h - [Getting started](docs/getting-started.md) — a local, authenticated API walkthrough. - [Documentation index](docs/README.md) — concepts, protocol contracts, adapters, and operations. +- [Account console preview](docs/integrated-console-v0.3.md) — invitation-based registration, local TOTP, account-bound reads, migration and deliberately blocked operations. - [Core specification](docs/core-spec-v0.1.md) — the data model and continuity boundaries. - [Deployment guide](docs/pve-lxc-deployment-v0.1.md) — an optional Linux/LXC deployment example; Proxmox is not required by the core API. - [Core optimization notes](docs/core-optimization-v0.2/release-notes.md) and [retrieval/sync review](docs/core-review-v0.3/README.md) — implementation changes and compatibility notes. @@ -95,6 +96,7 @@ server/ HTTP API, SQLite storage, administration, and tests plugins/mnemuron/ ChatGPT / Codex plugin adapters/ OpenClaw, Hermes, and optional read-only HTTP MCP integrations services/oauth/ Optional password + TOTP authorization service +web/console/ Same-origin desktop console, themes and bilingual catalogue shared/ Shared OAuth/gateway boundary helpers scripts/ Benchmarks, regression runners, and publication checks docs/ Guides, specifications, and test plans @@ -102,7 +104,7 @@ docs/ Guides, specifications, and test plans ## Current boundaries -- Built for single-user self-hosting, not a managed multi-tenant service. +- Single-owner by default, with opt-in account isolation and a desktop console. This is not a managed multi-tenant service or a production certification. - Lexical/FTS retrieval works without models. Optional, operator-configured embedding and Qdrant modules provide hybrid/semantic retrieval; query egress approval and budgets remain required. Hybrid fallback is marked; unavailable semantic search is an error, not a fabricated success. - Derived summaries preserve source revisions and coverage. Read-only summary retrieval never schedules a model; see [Memory First](docs/memory-first-v0.1/README.md). - Automatic summaries can omit context. Source records and explicit task state remain distinct. diff --git a/README.zh-CN.md b/README.zh-CN.md index e51f1b6..9e58ff6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -8,7 +8,7 @@ Mnemuron 保留记忆的来源和版本,让授权的 Agent 跨会话、跨设 中心服务使用 SQLite 存储数据。适配器将宿主生命周期事件接入服务,并在服务暂时不可用时保留本地待发送队列。不依赖云端记忆服务或外部向量数据库。 -> **当前状态:实验阶段。** 项目面向单用户自托管工作空间,API、数据结构和宿主集成仍可能变化。`production_ready` 保持 `false`;提供适配器源码不等于承诺兼容所有宿主版本或部署环境。 +> **当前状态:实验阶段。** 默认仍为单 owner 自托管,另提供需显式启用的账户隔离控制台供本地评估;部署及恢复策略需单独审查。API、数据结构和宿主集成仍可能变化。`production_ready` 保持 `false`;提供适配器源码不等于承诺兼容所有宿主版本或部署环境。 ## 能解决什么问题? @@ -75,6 +75,7 @@ curl --fail http://127.0.0.1:47831/readyz - [快速开始](docs/getting-started.md):带身份认证的本地 API 示例。 - [文档索引](docs/README.md):概念、协议、适配器与运维文档。 +- [账户控制台预览](docs/integrated-console-v0.3.md):注册码注册、本地 TOTP、逐账户读取、迁移,以及仍关闭的高风险操作。 - [核心规格](docs/core-spec-v0.1.md):数据模型与任务连续性边界。 - [部署指南](docs/pve-lxc-deployment-v0.1.md):可选的 Linux/LXC 部署示例;核心 API 不要求使用 Proxmox。 - [核心优化说明](docs/core-optimization-v0.2/release-notes.md)与[检索及同步修订](docs/core-review-v0.3/README.md):实现变更和兼容性说明。 @@ -95,6 +96,7 @@ server/ HTTP API、SQLite 存储、管理工具与测试 plugins/mnemuron/ ChatGPT / Codex 插件 adapters/ OpenClaw、Hermes 与可选只读 HTTP MCP 集成 services/oauth/ 可选密码 + TOTP 授权服务 +web/console/ 同域桌面控制台、颜色主题与双语字典 shared/ OAuth 与网关共用的边界检查 scripts/ 性能基准、回归运行器与发布内容检查 docs/ 指南、规格与测试计划 @@ -102,7 +104,7 @@ docs/ 指南、规格与测试计划 ## 当前边界 -- 面向单用户自托管,不是托管式多租户服务。 +- 默认单 owner,逐账户隔离与桌面控制台需显式启用;不是托管式多租户服务,也不是生产认证。 - 词法/全文检索无需模型;可选的 Embedding 与 Qdrant 模块支持混合和语义检索,但必须配置查询外发许可与预算。混合降级会明确标记,语义搜索不可用时返回错误。 - 派生摘要保留来源版本和覆盖范围;只读摘要查询不会启动模型任务,详见 [Memory First](docs/memory-first-v0.1/README.md)。 - 自动摘要可能遗漏上下文,来源记录与明确的任务状态始终分开保留。 diff --git a/adapters/chatgpt-web/src/authorization.mjs b/adapters/chatgpt-web/src/authorization.mjs index 6e4a92d..73b46cc 100644 --- a/adapters/chatgpt-web/src/authorization.mjs +++ b/adapters/chatgpt-web/src/authorization.mjs @@ -1,12 +1,12 @@ import { BoundaryError, readSecret, seconds, secretHash } from "../../../shared/oauth-common.mjs"; -import { loadIdentityMap } from "./config.mjs"; +import { loadIdentityMappings } from "./config.mjs"; import { fetchAuthorizationJson } from "./auth-transport.mjs"; export class GatewayAuthorization { constructor(config) { this.config = config; this.secret = readSecret(config.introspection.client_secret_file); - loadIdentityMap(config); + loadIdentityMappings(config); } async metadata() { const c = this.config; @@ -46,10 +46,10 @@ export class GatewayAuthorization { || data.client_id !== c.introspection.expected_oauth_client_id || data.token_kind !== "access_token" || data.token_type !== "Bearer") throw new BoundaryError(401, "INVALID_TOKEN"); let mapping; - try { mapping = loadIdentityMap(c); } catch { throw new BoundaryError(503, "IDENTITY_CONFIGURATION_UNAVAILABLE"); } - if (!mapping.enabled || data.sub !== mapping.subject) throw new BoundaryError(403, "SUBJECT_DENIED"); - return { mapping, scopes: new Set(data.scope.split(" ").filter(Boolean)), - connection_id:secretHash(JSON.stringify([c.issuer,data.client_id,data.sub])) }; + try { mapping = loadIdentityMappings(c).find(item=>item.subject===data.sub && item.issuer===c.issuer); } catch { throw new BoundaryError(503, "IDENTITY_CONFIGURATION_UNAVAILABLE"); } + if (!mapping?.enabled || (c.identity_mode==='multi_account_v1' && (data.account_id!==mapping.account_id || data.security_version!==mapping.security_version))) throw new BoundaryError(403, "SUBJECT_DENIED"); + return Object.freeze({ mapping, scopes: new Set(data.scope.split(" ").filter(Boolean)), + connection_id:secretHash(JSON.stringify([c.issuer,data.client_id,data.sub])) }); } } diff --git a/adapters/chatgpt-web/src/config.mjs b/adapters/chatgpt-web/src/config.mjs index 5c5de85..e024f54 100644 --- a/adapters/chatgpt-web/src/config.mjs +++ b/adapters/chatgpt-web/src/config.mjs @@ -9,6 +9,8 @@ export function validateGatewayConfig(input, { isolated = false } = {}) { requireConfig(c.config_version === "mnemuron-web-gateway-config-v1", "config_version"); requireConfig(["oauth", "bootstrap_metadata_only"].includes(c.mode), "mode"); requireConfig(["auth_only", "readonly"].includes(c.tool_profile), "tool_profile"); + c.identity_mode ??= 'legacy_owner'; + requireConfig(['legacy_owner','multi_account_v1'].includes(c.identity_mode),'identity mode'); canonicalUrl(c.issuer, { isolated, pathname: "/" }); canonicalUrl(c.resource, { isolated, pathname: "/mcp" }); c.public_origin_mode = publicOriginMode(c); @@ -69,10 +71,17 @@ export function loadGatewayConfig(file, options) { } export function loadIdentityMap(config) { + const mappings=loadIdentityMappings(config); + requireConfig(mappings.length===1,'single mapping required by legacy operator command'); + return mappings[0]; +} + +export function loadIdentityMappings(config) { const data = readPrivate(config.identity_map_file, { json: true }); requireConfig(data.unknown_subject_policy === "deny" && Array.isArray(data.mappings) - && data.mappings.length === 1, "single-owner identity map"); - const mapping = data.mappings[0]; + && (config.identity_mode==='multi_account_v1' || data.mappings.length===1), "identity map"); + const subjects=new Set(),users=new Set(),credentials=new Set(); + for(const mapping of data.mappings) { requireConfig(mapping.issuer === config.issuer && typeof mapping.subject === "string" && /^[A-Za-z0-9_-]{16,128}$/.test(mapping.subject) && !mapping.subject.includes("__REQUIRED") && typeof mapping.enabled === "boolean", "immutable mapped subject"); @@ -82,5 +91,16 @@ export function loadIdentityMap(config) { && !mapping[key].includes("__REQUIRED"), `mapped ${key}`); } } - return mapping; + requireConfig(!subjects.has(mapping.subject),'duplicate subject');subjects.add(mapping.subject); + if(config.identity_mode==='multi_account_v1') { + requireConfig(typeof mapping.account_id==='string' && Number.isSafeInteger(mapping.security_version) && mapping.security_version>0,'account mapping version'); + requireConfig(!users.has(mapping.mnemuron_user_id),'duplicate owner binding');users.add(mapping.mnemuron_user_id); + if(config.tool_profile==='readonly') { + requireConfig(typeof mapping.credential_file==='string' && mapping.credential_file.startsWith('/') && typeof mapping.credential_id==='string','per-account credential'); + const credential=readPrivate(mapping.credential_file); + requireConfig(!credentials.has(credential),'shared credential forbidden');credentials.add(credential); + } + } + } + return data.mappings.map(mapping=>Object.freeze(mapping)); } diff --git a/adapters/chatgpt-web/src/core-client.mjs b/adapters/chatgpt-web/src/core-client.mjs index f57057a..63e9a47 100644 --- a/adapters/chatgpt-web/src/core-client.mjs +++ b/adapters/chatgpt-web/src/core-client.mjs @@ -39,6 +39,7 @@ export class ReadonlyCoreClient { const result = await this.request("/v1/identity"); const identity = result.identity; if (!identity || identity.user_id !== mapping.mnemuron_user_id + || (mapping.credential_id && identity.credential_id!==mapping.credential_id) || identity.agent_instance_id !== mapping.agent_instance_id || identity.identity_status !== "server_verified" || identity.agent_id!=='chatgpt-web' || identity.web_read_policy!=='web-memory-visibility-v1' || !Array.isArray(result.scopes) || result.scopes.length !== CORE_SCOPES.length @@ -52,7 +53,14 @@ export class ReadonlyCoreClient { async call(name, args, mapping) { await this.checkIdentity(mapping); switch (name) { - case "mnemuron_search_memories": return this.request("/v1/memories/query", args); + case "mnemuron_search_memories": { + // A new account does not inherit the legacy owner's paid model allocation. + if(this.config.identity_mode!=='multi_account_v1')return this.request('/v1/memories/query',args); + if(args.mode==='semantic')throw Object.assign(new BoundaryError(503,'SEMANTIC_UNAVAILABLE'),{degradation_code:'NOT_CONFIGURED'}); + const result=await this.request('/v1/memories/query',{...args,mode:'lexical'}); + if(args.mode==='hybrid')result.retrieval={...result.retrieval,mode:'hybrid',requested_mode:'hybrid',effective_mode:'lexical',degraded:true,fallback:'lexical',degradation_code:'NOT_CONFIGURED'}; + return result; + } case "mnemuron_get_summary": return this.request("/v1/memory-summaries/query", args); case "mnemuron_get_memory": { const { memory_id, ...options } = args; diff --git a/adapters/chatgpt-web/src/server.mjs b/adapters/chatgpt-web/src/server.mjs index feea3e4..45c131c 100644 --- a/adapters/chatgpt-web/src/server.mjs +++ b/adapters/chatgpt-web/src/server.mjs @@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { SUPPORTED_PROTOCOL_VERSIONS } from "@modelcontextprotocol/sdk/types.js"; import { BoundaryError, WindowLimit, RESOURCE_SCOPES, OAUTH_SCOPES, requestBoundary, sendJson, readBody, secretHash, requireConfig } from "../../../shared/oauth-common.mjs"; -import { validateGatewayConfig, loadGatewayConfig, loadIdentityMap } from "./config.mjs"; +import { validateGatewayConfig, loadGatewayConfig, loadIdentityMappings } from "./config.mjs"; import { GatewayAuthorization } from "./authorization.mjs"; import { ReadonlyCoreClient } from "./core-client.mjs"; import { createMcpServer, toolDefinitions, enabledTools } from "./tools.mjs"; @@ -14,7 +14,14 @@ import {readObservation} from './read-audit.mjs'; export function createGateway(input, { isolated = false, logger = () => {} } = {}) { const config = validateGatewayConfig(input, { isolated }); const authorization = config.mode === "oauth" ? new GatewayAuthorization(config) : null; - const core = config.mode === "oauth" && config.tool_profile === "readonly" ? new ReadonlyCoreClient(config) : null; + const multi=config.identity_mode==='multi_account_v1'; + const core = config.mode === "oauth" && config.tool_profile === "readonly" && !multi ? new ReadonlyCoreClient(config) : null; + const requestCore = mapping => { + if(config.tool_profile!=='readonly')return null; + const client=multi?new ReadonlyCoreClient({...config,core:{...config.core,credential_file:mapping.credential_file}}):core; + requireConfig(client.token!==authorization.secret,'separate introspection and core credentials'); + return client; + }; requireConfig(!core || core.token !== authorization.secret, "separate introspection and core credentials"); const origin = new URL(config.resource); const metadataUrl = `${origin.origin}${config.protected_resource_metadata_path}`; @@ -60,9 +67,9 @@ export function createGateway(input, { isolated = false, logger = () => {} } = { let ready = false; if (url.pathname === "/readyz" && authorization) { await authorization.metadata(); - const mapping = loadIdentityMap(config); - if (!mapping.enabled) throw new BoundaryError(503, "SUBJECT_DISABLED"); - if (core) await core.ready(mapping); + const mappings=loadIdentityMappings(config); + if(!multi && !mappings[0].enabled)throw new BoundaryError(503,'SUBJECT_DISABLED'); + for(const mapping of mappings.filter(m=>m.enabled))await requestCore(mapping)?.ready(mapping); ready = true; } return sendJson(response, url.pathname === "/livez" || ready ? 200 : 503, @@ -98,7 +105,7 @@ export function createGateway(input, { isolated = false, logger = () => {} } = { if (enabledTools(config).includes(body.params.name)) tool = body.params.name; if(tool)requireScope(auth,toolDefinitions[tool].scope); } - const mcp = createMcpServer({ config, auth, core, id:body.id, + const mcp = createMcpServer({ config, auth, core:requestCore(auth.mapping), id:body.id, onError:code=>{errorCode=code;readOutcome='tool_error';}, onResult:(name,result)=>{read=readObservation(name,result);readOutcome='success';} }); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); diff --git a/adapters/chatgpt-web/test/fixture.mjs b/adapters/chatgpt-web/test/fixture.mjs index b2510e0..0c5a6bd 100644 --- a/adapters/chatgpt-web/test/fixture.mjs +++ b/adapters/chatgpt-web/test/fixture.mjs @@ -60,7 +60,7 @@ export async function gatewayFixture(t, { profile = "auth_only", coreFixture, mu await listen(gateway.server, f.ports.gatewayPort); t.after(async () => { await close(gateway.server); }); if (sharedOrigin) { - f.ingress = testIngress(config.issuer, f.ports); + f.ingress = testIngress(config.issuer, f.ports,{consoleEnabled:f.config.identity_mode==='multi_account_v1'}); await listen(f.ingress, ingressPort); t.after(() => close(f.ingress)); } diff --git a/adapters/chatgpt-web/test/multi-account.test.mjs b/adapters/chatgpt-web/test/multi-account.test.mjs new file mode 100644 index 0000000..607f25d --- /dev/null +++ b/adapters/chatgpt-web/test/multi-account.test.mjs @@ -0,0 +1,109 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import {createHash} from 'node:crypto'; +import {generate} from '../../../services/oauth/node_modules/otplib/dist/index.js'; +import {gatewayFixture} from './fixture.mjs'; +import {Browser,validatedCallback} from '../../../services/oauth/test/fixture.mjs'; +import {pendingAccount} from '../../../services/oauth/test/helpers/identity-fixture.mjs'; +import {provisionIdentities} from '../../../services/oauth/src/provisioning.mjs'; +import {memoryFixture} from '../../../server/test/helpers/core-memory-fixture.mjs'; +import {randomSecret,writePrivate} from '../../../shared/oauth-common.mjs'; + +async function authorize(f,name,seed,{browser=new Browser(f.config.issuer),pauseOnConsent=false,forceLogin=false,stopStatus}={}) { + const verifier=randomSecret(); + const request={client_id:f.config.chatgpt_client.client_id,redirect_uri:f.config.chatgpt_client.redirect_uris[0],response_type:'code',scope:'openid offline_access memory:read project:read', + resource:f.config.resource,state:randomSecret(),code_challenge_method:'S256',code_challenge:createHash('sha256').update(verifier).digest('base64url'),...(forceLogin?{prompt:'login'}:{})}; + let r=await browser.request(`/authorize?${new URLSearchParams(request)}`); + for(let i=0;i<12;i++) { + if(stopStatus&&r.status===stopStatus)return r; + const location=r.headers.get('location'); + if(location){const next=new URL(location,f.config.issuer);if(next.origin!==f.config.issuer)return {callback:validatedCallback(next,f.config.issuer,request),verifier,request};r=await browser.request(next);} + else { + assert.equal(r.status,200);const csrf=r.text.match(/name="csrf" value="([^"]+)"/)?.[1],action=r.text.match(/action="([^"]+)"/)?.[1]; + assert.ok(csrf&&action,'authorization must return an actionable login or consent page'); + if(action.endsWith('/confirm'))assert.ok(r.text.includes(`${name}`),'consent identifies the actual immutable account'); + if(action.endsWith('/confirm')&&pauseOnConsent)return {browser,action,csrf,request,verifier}; + r=await browser.post(action,{csrf,...(action.endsWith('/login')?{username:name,password:'Synthetic password with spaces ',otp:await generate({secret:seed})}:{accountId:'forged-other-owner',user_id:'forged-other-owner'})}); + } + } + throw new Error('Synthetic authorization did not finish'); +} +test('OAUTH-01..03 ISO-05..07: real two-account OAuth and interleaved MCP reads use immutable request principals',async t=>{ + const core=await memoryFixture(t); + const f=await gatewayFixture(t,{sharedOrigin:true,profile:'readonly',coreFixture:core,authMutate:c=>{ + c.identity_mode='multi_account_v1';c.login.registration_enabled=true;c.identity={encryption_key_file:path.join(path.dirname(c.database_file),'identity-key'),invitation_batch_limit:10,console_session_ttl_seconds:3600}; + writePrivate(c.identity.encryption_key_file,randomSecret()); + },mutate:c=>{c.identity_mode='multi_account_v1';writePrivate(c.identity_map_file,{unknown_subject_policy:'deny',mappings:[]},{replace:true});}}); + const ids=f.app.accounts,owners=[]; + for(const asset of ['styles.css','appearance.mjs','app.mjs','catalog.mjs','session-state.mjs']) + assert.equal((await fetch(f.config.issuer+'/assets/'+asset)).status,200); + for(const route of ['/v1/identity','/v1/admin','/assets/private.json','/app/unknown','/console-api/export']) + assert.equal((await fetch(f.config.issuer+route)).status,404,route); + assert.equal((await fetch(f.config.issuer+'/login')).status,200); + assert.equal((await fetch(f.config.issuer+'/console-api/me')).status,401); + for(const name of ['Synthetic_A','Synthetic_B']) { + const a=await pendingAccount({identities:ids},name);ids.takeRecoveryCodes(a.session.token);ids.acknowledgeRecovery(a.session.token);owners.push({name,...a}); + } + provisionIdentities(ids,core.store,{credentialDirectory:path.join(f.directory,'principals'),identityMapFile:f.gatewayConfig.identity_map_file}); + for(const owner of owners) { + const account=ids.byId(owner.account.account_id),writer=core.issue(account.user_id,`writer-${account.account_id}`); + owner.memory=core.store.saveMemory(writer.auth,{scope:'user',content:'Synthetic identical private memory'}).memory; + const revision=core.store.revisions.latest(account.user_id,owner.memory.memory_id);core.store.webVisibility.set(writer.auth,owner.memory.memory_id,{allow:true,revision:revision.revision,state_hash:revision.state_hash}); + const tokens=await f.exchange(await authorize(f,owner.name,owner.setup.secret));assert.equal(tokens.status,200);owner.tokens=tokens.data; + } + const calls=[]; + // Interleave both accounts without exceeding the unchanged four-request subject guard. + for(let wave=0;wave<3;wave++) calls.push(...await Promise.all(Array.from({length:4},(_,i)=>f.mcp('tools/call',{name:'mnemuron_search_memories',arguments:{query:'Synthetic identical private memory'}},owners[i%2].tokens.access_token,{headers:{'x-user-id':ids.byId(owners[1-i%2].account.account_id).user_id}})))); + for(const [i,r] of calls.entries()){assert.equal(r.status,200);const content=JSON.stringify(r.data);assert.ok(content.includes(owners[i%2].memory.memory_id),content);assert.ok(!content.includes(owners[1-i%2].memory.memory_id));} + ids.db.prepare("UPDATE identity_accounts SET status='disabled',security_version=security_version+1 WHERE account_id=?").run(owners[0].account.account_id); + ids.store.revoke({subject:owners[0].setup.subject}); + assert.equal((await f.mcp('tools/list',undefined,owners[0].tokens.access_token)).status,401); + assert.equal((await f.mcp('tools/list',undefined,owners[1].tokens.access_token)).status,200); + const before=core.store.db.prepare('SELECT COUNT(*) AS n FROM memories').get().n; + const forbidden=await f.mcp('tools/call',{name:'mnemuron_save_memory',arguments:{}},owners[1].tokens.access_token); + assert.equal(forbidden.data.result.isError,true); + assert.equal(core.store.db.prepare('SELECT COUNT(*) AS n FROM memories').get().n,before); +}); +test('OAUTH-07: a different OAuth login invalidates the old browser session and requires a fresh authorization',async t=>{ + const core=await memoryFixture(t); + const f=await gatewayFixture(t,{profile:'readonly',coreFixture:core,authMutate:c=>{ + c.identity_mode='multi_account_v1';c.login.registration_enabled=true; + c.identity={encryption_key_file:path.join(path.dirname(c.database_file),'identity-key'),invitation_batch_limit:10,console_session_ttl_seconds:3600}; + writePrivate(c.identity.encryption_key_file,randomSecret()); + },mutate:c=>{c.identity_mode='multi_account_v1';writePrivate(c.identity_map_file,{unknown_subject_policy:'deny',mappings:[]},{replace:true});}}); + const ids=f.app.accounts,owners=[]; + for(const name of ['Synthetic_Switch_A','Synthetic_Switch_B']){ + const a=await pendingAccount({identities:ids},name);ids.takeRecoveryCodes(a.session.token);ids.acknowledgeRecovery(a.session.token);owners.push({name,...a}); + } + provisionIdentities(ids,core.store,{credentialDirectory:path.join(f.directory,'keys'),identityMapFile:f.gatewayConfig.identity_map_file}); + const [a,b]=owners,paused=await authorize(f,a.name,a.setup.secret,{pauseOnConsent:true}); + const switched=await authorize(f,b.name,b.setup.secret,{browser:paused.browser,forceLogin:true,stopStatus:409}); + assert.equal(JSON.parse(switched.text).error_code,'AUTHORIZATION_RESTART_REQUIRED'); + const old=await paused.browser.post(paused.action,{csrf:paused.csrf}); + assert.equal(old.status,403); + assert.equal(f.app.store.db.prepare("SELECT COUNT(*) AS n FROM oauth_records WHERE model='Grant'").get().n,0); +}); +test('OAUTH-07: switching the same browser to B cannot complete the earlier A consent',async t=>{ + const core=await memoryFixture(t); + const f=await gatewayFixture(t,{profile:'readonly',coreFixture:core,authMutate:c=>{ + c.identity_mode='multi_account_v1';c.login.registration_enabled=true; + c.identity={encryption_key_file:path.join(path.dirname(c.database_file),'identity-key'),invitation_batch_limit:10,console_session_ttl_seconds:3600}; + writePrivate(c.identity.encryption_key_file,randomSecret()); + },mutate:c=>{c.identity_mode='multi_account_v1';writePrivate(c.identity_map_file,{unknown_subject_policy:'deny',mappings:[]},{replace:true});}}); + const ids=f.app.accounts,owners=[]; + for(const name of ['Synthetic_Consent_A','Synthetic_Consent_B']){ + const a=await pendingAccount({identities:ids},name);ids.takeRecoveryCodes(a.session.token);ids.acknowledgeRecovery(a.session.token);owners.push({name,...a}); + } + provisionIdentities(ids,core.store,{credentialDirectory:path.join(f.directory,'keys'),identityMapFile:f.gatewayConfig.identity_map_file}); + const [a,b]=owners,paused=await authorize(f,a.name,a.setup.secret,{pauseOnConsent:true}); + const login=await paused.browser.request('/login'); + assert.equal((await paused.browser.post('/login',{csrf:login.text.match(/name="csrf" value="([^"]+)"/)[1],username:b.name, + password:'Synthetic password with spaces ',otp:await generate({secret:b.setup.secret})})).status,303); + const me=JSON.parse((await paused.browser.request('/console-api/me')).text); + assert.equal(me.account_id,b.account.account_id,'the same browser has actually switched to B'); + const old=await paused.browser.post(paused.action,{csrf:paused.csrf}); + assert.ok(old.status>=400&&old.status<500,'old consent fails closed after browser changes account'); + const grants=f.app.store.db.prepare("SELECT payload FROM oauth_records WHERE model='Grant'").all().map(r=>JSON.parse(r.payload)); + assert.equal(grants.length,0,'stale consent cannot create a grant for either account'); +}); diff --git a/adapters/chatgpt-web/test/multi-model-policy.test.mjs b/adapters/chatgpt-web/test/multi-model-policy.test.mjs new file mode 100644 index 0000000..bfe3957 --- /dev/null +++ b/adapters/chatgpt-web/test/multi-model-policy.test.mjs @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {ReadonlyCoreClient} from '../src/core-client.mjs'; +import {consoleRead} from '../../../server/lib/console-read.mjs'; + +test('SEC-00: new multi-account readers cannot inherit an unallocated shared model budget',async()=>{ + const calls=[],client=Object.create(ReadonlyCoreClient.prototype); + client.config={identity_mode:'multi_account_v1'};client.checkIdentity=async()=>{}; + client.request=async(route,body)=>{calls.push({route,body});return {results:[],retrieval:{mode:body.mode,requested_mode:body.mode,effective_mode:body.mode}};}; + await client.call('mnemuron_search_memories',{query:'synthetic'},{}); + assert.equal(calls[0].body.mode,'lexical'); + const hybrid=await client.call('mnemuron_search_memories',{query:'synthetic',mode:'hybrid'},{}); + assert.equal(calls[1].body.mode,'lexical');assert.equal(hybrid.retrieval.requested_mode,'hybrid'); + assert.equal(hybrid.retrieval.effective_mode,'lexical');assert.equal(hybrid.retrieval.degraded,true); + assert.equal(hybrid.retrieval.degradation_code,'NOT_CONFIGURED'); + await assert.rejects(client.call('mnemuron_search_memories',{query:'synthetic',mode:'semantic'},{}),e=>e.code==='SEMANTIC_UNAVAILABLE'&&e.degradation_code==='NOT_CONFIGURED'); + assert.equal(calls.length,2); + client.config.identity_mode='legacy_owner'; + await client.call('mnemuron_search_memories',{query:'synthetic',mode:'hybrid'},{}); + assert.equal(calls[2].body.mode,'hybrid','existing operator-approved legacy retrieval is preserved'); +}); +test('SEC-00: console search has an explicit no-egress mode while allocation policy is pending',()=>{ + let args;const auth={user_id:'synthetic-A',agent_id:'mnemuron-console'}; + const store={requireScope(){},searchMemories(principal,body){assert.equal(principal,auth);args=body;return {};}}; + consoleRead(store,auth,'memories',{query:'synthetic'});assert.equal(args.mode,'lexical'); +}); diff --git a/docs/adr/0010-integrated-console-identity.md b/docs/adr/0010-integrated-console-identity.md new file mode 100644 index 0000000..5b573ee --- /dev/null +++ b/docs/adr/0010-integrated-console-identity.md @@ -0,0 +1,33 @@ +# Integrated console and immutable accounts + +Status: local implementation; deployment and unresolved operating policies are not approved. + +The repository has an OAuth service using oidc-provider, a separate read-only MCP +gateway, and an owner-filtered Core SQLite store. There is no frontend framework +to reuse. Use a small, same-origin, server-rendered authentication surface and +native ES-module console, sharing local CSS tokens and a bilingual catalogue. +No second identity platform, CDN, browser-held bearer or simulated login. + +Identity tables live in the existing private OAuth database. The account UUID, +issuer/subject and Core user ID are immutable and independent of username. A +versioned, opt-in migration preserves legacy identifiers and password parameters; +legacy processes must be stopped before migrating and cannot open the upgraded +database. Never roll back by restoring an old database over later accounts. + +Core provisioning is an explicit local worker, not a public admin route. A +durable OAuth-side operation reserves encrypted credentials before a Core-side +idempotent transaction. Retries reconcile both databases and identity before +activation. There is no cross-database transaction. Registration waits for this +worker and recovery-code acknowledgement. The OAuth process needs no Core admin +bearer. Web and console get separate owner-bound, least-privilege credentials. + +Every MCP request resolves its own immutable principal and credential. The +gateway never mutates a shared token. Console authentication is a separate +purpose-bound cookie backed by this same account repository, not an alternate +identity provider. Registration/recovery cookies cannot authenticate the console. + +Production recovery proofs, session TTL, invitation batch limit, platform role +grants, model budget allocation, initial appearance defaults and sensitive web +actions require explicit operator policy. Missing policy blocks only the relevant +branch. Memory writes, task dispatch, export, role management and whole-database +restore are not authorized by showing their pages. production_ready stays false. diff --git a/docs/architecture/account-ownership.json b/docs/architecture/account-ownership.json new file mode 100644 index 0000000..3a44a1f --- /dev/null +++ b/docs/architecture/account-ownership.json @@ -0,0 +1,16 @@ +{ + "schema_version": "account-ownership-inventory-v1", + "core": { + "user_id": ["audit_events","checkpoints","credentials","events","handoff_drain_resumes","memories","memory_annotations","memory_category_overrides","memory_create_operations","memory_fingerprints","memory_index_outbox","memory_job_items","memory_jobs","memory_owner_model_usage","memory_owner_vector_usage","memory_privacy","memory_processing_outbox","memory_revisions","memory_source_links","memory_source_pins","memory_sources","memory_summaries","memory_summary_dependencies","memory_vector_documents","memory_vector_points","memory_web_grants","projects","resolver_selections","resume_delivery_receipts","resume_injection_events","resumes","task_bootstrap_previews","task_canonical_revisions","task_reconciliation_proposals","tasks"], + "summary_parent": ["memory_derived_outbox","memory_summary_claims"], + "memory_parent_fts_internal": ["memory_search_docs","memory_search_fts","memory_search_fts_config","memory_search_fts_data","memory_search_fts_docsize","memory_search_fts_idx"], + "operator_only_infrastructure": ["handoff_module_state","memory_model_budget","memory_profile_state","memory_search_state","memory_vector_active","memory_vector_calls","memory_vector_generations","settings"] + }, + "oauth": { + "immutable_account": ["identity_accounts","identity_bindings","identity_operations","identity_recovery_claims"], + "account_or_restricted_bootstrap": ["identity_sessions","identity_audit"], + "operator_invitation_batch_then_registration": ["identity_invitations"], + "subject_or_protocol_transaction": ["oauth_records","oauth_revoked_grants","oauth_mfa_steps","oauth_csrf"], + "hashed_subject_client_or_peer_rate_key": ["oauth_rate_limits"] + } +} diff --git a/docs/architecture/account-ownership.md b/docs/architecture/account-ownership.md new file mode 100644 index 0000000..c57fbfb --- /dev/null +++ b/docs/architecture/account-ownership.md @@ -0,0 +1,103 @@ +# Account ownership and boundaries + +This is an implementation inventory, not permission to deploy. The executable +inventory beside this document covers every Core and OAuth table; regression +tests fail when a table is added without a classification. `sqlite_%` tables +are SQLite internals, not additional application access paths. + +## Authoritative and derived storage + +- Direct `user_id` tables are filtered using the authenticated credential or + verified account mapping. User-supplied IDs do not select the principal. +- Summary claims and derived outboxes inherit their summary's `user_id`. + Summary cursors bind owner, scope, source manifest and revision. A foreign + summary is indistinguishable from a missing summary. +- FTS tables are shared physical indices, not a shared space. Candidate IDs + join authoritative memories with the owner filter before results, counts + and conflicts are returned. No console route exposes FTS rows or raw SQL. +- Vector payloads contain owner/document surrogates, not plaintext memory. + Query filters and authoritative owner/revision hydration both apply. The + index generation, health and service-wide budget ceiling are operator state. +- Jobs carry owner, scope, model profile, input manifest and a fenced lease. + Chunk results must match the persisted owner/source set. Organizer and + embedding usage have separate owner-attribution ledgers; this does **not** + approve a model-cost allocation policy. New multi-account Web and console + query paths use lexical retrieval until that policy exists. Existing + operator-controlled model workers are not exposed as console actions. +- Settings, retention, handoff module enablement, profile health, global + budgets and vector generations are operator infrastructure. Ordinary + accounts cannot read or mutate them through BFF/MCP. Retention and model + administration remain existing privileged local operations, not platform UI. + +## Identity, sessions and credentials + +`account_id`, `(issuer, subject)` and Core `user_id` are stable and unique. +Username is a login label, never a storage key. Accounts, bindings, provisioning +and recovery operations are account-owned. Pending registration sessions have +only a reservation until an account is created. Invitations are operator batch +records whose claim binds a registration session; plaintext is not stored. + +OAuth records contain subject, grant/client/transaction references; they are +validated by the existing provider, then account eligibility and security +version. MFA replay counters are per subject. CSRF belongs to the interaction +or restricted cookie purpose. Rate buckets distinguish subject/account, +client and peer; the hashed key is not an alternate identity. Operator-only +invitation and migration audit entries have a null account and are not returned +in any normal account's audit feed. + +Each account has **two distinct Core credentials**, one Web read-only and one +console read-only. The request constructs a client for the matched principal; +there is no global mutable owner token. OAuth introspection is performed on +each request. Discovery/JWKS metadata caching never substitutes for it. + +## Route inventory + +| Surface | Principal and boundary | +| --- | --- | +| OAuth `/authorize`, `/interaction/:uid/*`, `/token`, `/revoke`, `/introspect`, `/userinfo`, `/jwks`, discovery | Existing protocol validation, exact redirect, PKCE, purpose-specific clients, CSRF, verified subject/security version; no frontend fallback | +| `/register/*` | Invitation-bound registration cookie; strict field set; cannot access private BFF/MCP | +| `/login`, `/console-api/logout`, `/recover` | Password + TOTP; separate console cookie; POST origin and CSRF; recovery is `blocked_policy` | +| `/app` and its twelve fixed pages, `/assets/*` | Fixed route/asset allowlist; private pages require eligible account; no SPA catch-all | +| `/console-api/me`, security, connections, audit | Verified console session; only same-account metadata; no secrets/raw queries or private content of others | +| `/console-api/overview`, memories, memory, summaries, summary, jobs, storage | Owner-bound console Core key, authoritative credential identity check, strict query allowlist, session rechecked after awaited read | +| Models, invitation/account administration, export/restore and all other BFF mutations | Denied or `blocked_policy`; visible navigation grants no authority | +| `/mcp` tools | OAuth only, strict read-only tool allowlist, owner mapping and revision-pinned Web visibility; console cookies cannot authenticate | +| Core `/v1/identity`, capabilities/status, memory/source/summary/project/task reads | Existing scopes plus owner checks; console and Web agent route allowlists restrict the subset available to each | +| Core capture/checkpoint, bootstrap/reconciliation, Resume/confirm/receipts, memory writes | Existing agent scopes and owner checks; never forwarded by BFF or Web MCP; existing handoff gates remain | +| Core agent registration/rotation/revocation, retention and task administration | Existing operator/agent permissions; registration cannot override credential `user_id`; no public BFF passthrough | +| Liveness/readiness | Bounded service metadata only; not private content, keys or an identity selector | + +## Files, caches, queues and workers + +- Core SQLite/WAL contain all owners; OAuth SQLite/WAL contain identity state. + They are private server files, not per-account downloads. The threat model + does not claim isolation from an OS administrator who can read both databases + and encryption keys. +- Credential files are server-generated account UUID/purpose/version names in + a validated, private directory outside **all** Git worktrees. The mapping is + a derived atomic publication, serialized with identity transactions. No + browser-supplied path, username or user ID determines a key filename. +- Password hashes, TOTP ciphertext, recovery-code hashes and encrypted pending + provisioning payloads remain server-side. The AES key, signing/cookie keys, + identity map, invitation output and all backup material stay outside Git. +- Browser memory content exists only in the current DOM/request state. + Only theme/mode/locale persist under an account-keyed preference. Exit aborts + requests, clears detail/cursor/draft state and rejects late responses; + back-forward restoration forces reauthentication. All private HTTP responses + are `no-store`. +- Durable memory source/processing/index outboxes retain owner and revision. + Source files are reached through authorized manifest pins, not direct paths. + Summary outboxes resolve their summary parent. Job/view access uses the owner, + not a global status endpoint. No account export/download endpoint is enabled. +- Logs contain request metadata and safe error codes; no body, password, OTP, + query string or bearer. Audit UI returns only the account's allowlisted + metadata. Core audit `target_id` is returned only within that same owner. +- Provisioning is a local worker with durable encrypted intent, Core-side + idempotency and authoritative identity recheck. It does not run implicitly on + a public registration request. Migration shares the authorization process + lease; legacy runtime access to upgraded tables fails closed. + +Recovery proof combinations, roles, invitation batch cap, console session TTL, +initial appearance defaults, model resources/costs and high-risk web actions +must be explicitly configured/approved before deployment. No platform role is +implemented that implicitly grants access to another account's private body. diff --git a/docs/architecture/private-oauth-ingress.md b/docs/architecture/private-oauth-ingress.md new file mode 100644 index 0000000..1901b71 --- /dev/null +++ b/docs/architecture/private-oauth-ingress.md @@ -0,0 +1,74 @@ +# Optional private OAuth ingress + +The public OAuth/MCP origin remains the canonical origin. A private reverse proxy +may reach the same loopback OAuth service through `services/oauth/src/private-ingress.mjs` +when sending LAN traffic out to a public tunnel introduces unnecessary latency. +This is an optional transport, not another identity provider or authentication mode. + +## Boundaries + +- The listener binds one RFC1918 IPv4 address. It accepts one exact peer IPv4 only. +- TLS 1.3 requires a client certificate from a dedicated CA **and** an exact SHA-256 + leaf fingerprint. The proxy validates the server certificate, CA and DNS name. +- The only upstream is `127.0.0.1` at the configured OAuth port. The transport + preserves request bytes; it cannot select an owner, token, database or upstream URL. +- OAuth still checks Host, Origin, CSRF, MFA, PKCE, precise callbacks and consent. + Console and OAuth sessions remain separate. MCP stays read-only. +- Handshakes, upstream connects, idle connections, connection counts and shutdown + are bounded. No request content, cookies or peer certificate data is logged. +- The private route must be inside the proxy's existing source-address gate. Do + not change public DNS, tunnel rules, Core routing, or unrelated virtual hosts. + +Use a dedicated OS service identity, a read-only code directory and a private +configuration/certificate directory (0700, files 0600, owned by that identity). +Generate CA/server/client private keys on the hosts that use them; transfer only +public CSRs and certificates. Do not place real keys or deployment configuration +in this repository. Generate a dedicated CA, not one shared with general clients. + +Example configuration shape (replace all placeholders in a private file): + +```json +{ + "listen_host": "PRIVATE_OAUTH_IPV4", + "listen_port": 47835, + "allowed_peer": "PRIVATE_PROXY_IPV4", + "server_name": "memory.example.test", + "upstream_port": 47833, + "ca_file": "/private/ingress/ca.pem", + "cert_file": "/private/ingress/server.pem", + "key_file": "/private/ingress/server.key", + "client_fingerprint_sha256": "REPLACE_WITH_64_LOWERCASE_HEX_CHARACTERS" +} +``` + +Start with `node services/oauth/src/private-ingress.mjs /private/ingress/config.json`. +The CLI never accepts the tests' loopback/ephemeral listener exception. + +For Caddy, use the existing approved OAuth/console path matcher and preserve the +public Host header. Configure `tls_client_auth`, a private CA `tls_trust_pool file`, +`tls_server_name`, and HTTP/1.1 on the private upstream. Never enable +`tls_insecure_skip_verify`. Keep MCP and other upstreams on their existing routes. +See [Caddy reverse-proxy transport documentation](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy). + +## Verification and rollback + +Run `services/oauth/test/private-ingress.test.mjs` plus the full OAuth/MCP suites. +Verify missing/wrong certificates, wrong source/SNI, Host/Origin boundaries, +anonymous API rejection, static assets, public OAuth/MCP and actual browser timing. +Run synthetic UI verification; a public login page is not a real ChatGPT consent +and memory-read acceptance. + +Before reload, compare adapted disk and running proxy configuration, back up the +exact configuration and prove only the scoped matcher/upstream changed. Keep the +previous release and route for rollback. Do not migrate identity data for this +transport/UI change. Record certificate expiry privately and renew before expiry: +generate a replacement pair, stage trust and fingerprint changes, verify, then +reload; never disable validation to work around an expired certificate. A failed +private path fails closed. Roll back the proxy to its prior public route if needed. + +## Login purpose + +`/login` enters the management console. A dynamic `/interaction/:uid` belongs to +one OAuth request and requires its original cookies; it is not a bookmarkable +integration-management page. Expired/mismatched links instruct the user to restart +from ChatGPT rather than sending them into an unrelated console login. diff --git a/docs/console-ingress.example.yml b/docs/console-ingress.example.yml new file mode 100644 index 0000000..f638eb0 --- /dev/null +++ b/docs/console-ingress.example.yml @@ -0,0 +1,33 @@ +# UNAPPLIED example for the opt-in integrated console. Not a production change. +# Review policies, private configuration and existing routes before deployment. +tunnel: __REQUIRED_APPROVED_TUNNEL_ID__ +credentials-file: /etc/cloudflared/__REQUIRED_TUNNEL_ID__.json + +ingress: + - hostname: memory.example.com + path: '^/(mcp|\.well-known/oauth-protected-resource(/mcp)?)$' + service: http://127.0.0.1:47832 + originRequest: + httpHostHeader: memory.example.com + - hostname: memory.example.com + path: '^/\.well-known/(oauth-authorization-server|openid-configuration)$' + service: http://127.0.0.1:47833 + originRequest: + httpHostHeader: memory.example.com + - hostname: memory.example.com + path: '^/(authorize(/[A-Za-z0-9_-]{1,128})?|token|jwks|revoke|introspect)$' + service: http://127.0.0.1:47833 + originRequest: + httpHostHeader: memory.example.com + - hostname: memory.example.com + path: '^/interaction/[A-Za-z0-9_-]{1,128}(/(login|confirm|abort))?$' + service: http://127.0.0.1:47833 + originRequest: + httpHostHeader: memory.example.com + - hostname: memory.example.com + path: '^/(login|recover|register(/(reserve|account|totp|recovery-codes|ack|status))?|app(/(overview|memories|summaries|jobs|connections|models|security|audit|storage|appearance|invitations|accounts))?/?|console-api/(me|logout|security|connections|audit|overview|memories|memory|summaries|summary|jobs|storage|models|invitations|accounts)|assets/(styles\.css|appearance\.mjs|catalog\.mjs|app\.mjs|session-state\.mjs))$' + service: http://127.0.0.1:47833 + originRequest: + httpHostHeader: memory.example.com + # No wildcard assets, health, Core, admin, database or export routes. + - service: http_status:404 diff --git a/docs/integrated-console-v0.3.md b/docs/integrated-console-v0.3.md new file mode 100644 index 0000000..d5882ff --- /dev/null +++ b/docs/integrated-console-v0.3.md @@ -0,0 +1,175 @@ +# Integrated account console (experimental) + +This is an **opt-in local implementation**, not a deployment instruction to run +against an existing installation without a maintenance review. No production +accounts, invitations, recovery proofs, domain routes or model permissions are +created by installing the code. `production_ready` remains `false`. + +See the [architecture decision](adr/0010-integrated-console-identity.md) and +[ownership inventory](architecture/account-ownership.md). Existing memory storage, +OAuth authorization, read-only MCP, vector adapters and workers are reused. +Handoff remains separate and retains its confirmation and receipt gates. + +## Architecture and interface + +The existing OAuth service serves local CSS/native ES modules and authenticates +the console with a purpose-bound, HttpOnly, Secure, SameSite=Lax cookie. It holds +no shared owner bearer. Each request resolves the immutable account and obtains +its independently verified Core console credential. Browser-supplied `user_id` +cannot choose an owner. The MCP gateway continues to require an OAuth access +token, with a separate per-account Core Web credential and uncached activity +checks. Console cookies do not authorize MCP. + +Changing the console account or signing out expires that browser's OAuth session +and pending consent, without revoking other devices' existing grants. A different +principal entered during OAuth login must start a fresh authorization; stale +interaction pages cannot transfer grants to another subject. + +| Route | Contract | +| --- | --- | +| `GET /register`, `/register/account`, `/register/totp`, `/register/recovery-codes`, `/register/status` | Server-controlled, invitation-bound steps; incomplete identities cannot read memory. | +| `POST /register/reserve`, `/register/account`, `/register/totp`, `/register/ack` | Exact form fields, same-origin CSRF, atomic invite/TOTP checks; not a general account API. | +| `GET/POST /login` | Username, password and TOTP; success requires MFA, Core binding and recovery-code acknowledgement. | +| `GET /recover` | Truthful `blocked_policy` page; no weaker recovery fallback. | +| `GET /app` and `/app/{memories,summaries,jobs,connections,models,security,audit,storage,appearance,invitations,accounts}` | Fixed-A desktop console; page visibility grants no operation rights. | +| `GET /console-api/me`, `/security`, `/connections`, `/audit`, `/overview`, `/memories`, `/memory`, `/summaries`, `/summary`, `/jobs`, `/storage` | Authenticated, account-bound reads only. `/memory` requires `memory_id`; use the returned revision and continuation fields for body/source pages. `/summary` uses the returned pinned detail request. | +| `POST /console-api/logout` | CSRF-checked server revocation and browser data cleanup. | +| Models, invitations, accounts and other writes under `/console-api/` | Denied server-side pending policy. No export/download or role-management API is exposed. | + +`/authorize`, dynamic `/interaction/:uid`, `/token`, `/introspect`, `/revoke`, +`/jwks` and discovery retain their exact existing protocol contracts. There is no +SPA catch-all. The same-origin reverse proxy must route **only** the named OAuth, +console and assets paths to the OAuth service, `/mcp` and protected-resource +metadata to the gateway. Never forward `/v1` or arbitrary paths to Core. Review +the existing same-origin route allowlist before deploying; this change does not +automatically alter any proxy or DNS configuration. +An [unapplied console ingress example](console-ingress.example.yml) is exercised +by the isolated same-origin tests. The legacy ingress example remains unchanged. + +## Configuration and secrets + +Start from the existing private, validated OAuth/gateway configuration, retaining +all PKCE, exact callback, scopes, token lifetime and proxy settings. Add: + +- Both services: `identity_mode: "multi_account_v1"`. Omission keeps + `legacy_owner`; legacy processes refuse an upgraded identity database. +- OAuth: `identity.encryption_key_file`, an absolute, owner-only file outside + every Git worktree, containing a separately generated 32-byte base64url key. + It encrypts pending factors/credential intents; back it up separately with + appropriate protection. Losing it prevents resuming pending operations. +- OAuth: `identity.invitation_batch_limit`, an explicitly chosen integer in + `1..1000`. No default is selected for production. Invitation expiry itself is + an integer **1..1440 minutes**, selected per batch. +- OAuth: `identity.console_session_ttl_seconds`, an explicitly chosen integer in + `60..28800`. No production default is selected. +- OAuth: `identity.core.base_url`, the approved Core loopback/private-TLS origin; + registration activation additionally requires the local provisioning command. +- OAuth: `login.registration_enabled: true` only after registration policy and + the local supply workflow are approved; otherwise keep it false. +- Gateway: `identity_map_file` points to the private atomic map derived by + provisioning. Do not hand-edit it into a shared-owner mapping. Every mapped + account has unique subject, Core user, credential ID/file and security version. + +Keep configuration/key directories `0700` and private files `0600`; do not pass +secrets through shell arguments. The service checks that identity storage is +outside worktrees before opening it. The CLI and provisioning also guard output +paths, including symlinks. No account data is bundled into static assets. + +The only new runtime dependency is exact `qrcode@1.5.4` in the existing OAuth +module, locally generating the TOTP QR. Install its reviewed lockfile with +`npm ci --ignore-scripts --prefix services/oauth`; no CDN or external QR service. +Native console modules need no build step or frontend package installation. + +## Operator commands and resumable migration + +Run `node services/oauth/bin/identity.mjs --help`. All paths below denote private +operator-selected files, not repository files. Commands are **not** a grant to +change production. They intentionally require an exact target and `--confirm` +for state transitions. + +1. Inventory the old OAuth issuer/subject, Core `user_id`, exact Web mapping, + filesystem ownership and existing pending handoffs. Stop incompatible OAuth + writers in an approved maintenance window. `migrate-owner` obtains the same + exclusive process lease as the service and rejects a live process. +2. Take consistent OAuth/Core backups and protect keys, mapping and permissions. + Verify copies in an isolated directory first. Do not treat a raw copy of a + live WAL file alone as a database backup. +3. Use `migrate-owner --config /private/auth.json + --legacy-file /private/owner.json --mapping-file /private/legacy-map.json + --confirm`. The original file is not edited. Existing subject, Core user, + password parameters, factor and recovery hashes are preserved. Retrying + returns the same account rather than duplicating memories. +4. Use `provision --config /private/auth.json + --core-database /private/core.sqlite3 --credential-directory /private/keys + --identity-map /private/identity-map.json --confirm`. It persists an encrypted + operation before the separate Core transaction, reuses exact credentials + after interruption, validates Core identity and publishes the map atomically. + A pending/failed operation cannot borrow the legacy owner's credential. +5. The same **local** provisioning command reconciles subsequent completed + registrations. Arrange approved operator runs or a reviewed service schedule; + the web server cannot administer Core. Run again after recovery-code + acknowledgement if provision happened first, so the derived map reflects + activation. `status` and pending counts distinguish incomplete work. This + release does not silently install a scheduler/service. +6. Do not restart old binaries on an upgraded database. To inspect a backup, + restore into a new isolated path. If later registrations or memories exist, + never overwrite them with a pre-migration copy: freeze writes, inventory the + delta and design an explicit reconciliation/forward-fix first. + +Invitation command shape: + +```text +node services/oauth/bin/identity.mjs invite-issue --config /private/auth.json \ + --count APPROVED_INTEGER --ttl-minutes APPROVED_INTEGER \ + --issuer OPERATOR_LABEL --output /private/new-invitations.json +node services/oauth/bin/identity.mjs invite-list --config /private/auth.json +node services/oauth/bin/identity.mjs invite-revoke --config /private/auth.json \ + --batch-id EXACT_BATCH_ID --confirm +``` + +Each code is independently random, single-use and digest-only in the database. +The first command writes plaintext once to an exclusive private output file; +stdout/list/audit contain no usable codes. Revocation affects unused codes only. +Reservations expire; reclaim creates a new binding, never inherits the previous +claimant's password or factor. An ambiguous completion retries the same identity. +Usernames use ASCII letters/digits and `_.@-`, case-insensitive unique labels; +immutable UUIDs are the actual identity. Passwords retain spaces, require 14–1024 +characters and use scrypt. TOTP seeds appear once during binding, then recovery +codes appear once and must be acknowledged before normal access. + +## Deliberately blocked policies + +- Web password/TOTP recovery proof combinations, operator reset proofs and old + owner recovery approval. `recovery-inspect` exposes only state; `recovery-reset` + records the request and refuses it. The internal recovery engine is tested + using explicitly synthetic policies, not an enabled production backdoor. +- Memory writes, job scheduling, per-account model configuration/billing, + invitation issuance from Web, platform roles, export and whole-database restore. +- Multi-account query model egress: lexical works; semantic reports unavailable + and hybrid explicitly degrades to lexical until the policy is approved. + Owner-attributed usage accounting is not authorization to spend or disclose. +- Console grant revocation/reset controls await the corresponding operation + policy. Existing OAuth revocation protocol remains intact. + +Themes (Neural Indigo, Signal Teal, Paper Amber) only change colors; light/dark +and Chinese/English share geometry and permissions. Browser persistence stores +allowlisted appearance settings only, keyed by account; content, cursors, +passwords, seeds and bearer credentials are not persisted. Layout is desktop-first. + +## Before any deployment + +- Review migration backups and cross-database retry/activation evidence; verify + no incompatible old process can write; preserve later data in rollback plans. +- Approve batch cap, session TTL, recovery proof policy and role/model boundaries. + Unapproved branches stay closed, not defaulted on. +- Approve the first-visit appearance defaults before rollout. A/light/Chinese is + the local preview default, not a confirmed production preference. +- Review exact same-domain route allowlist, cookies, TLS, CSP/CSRF, callback and + firewall; check that Core `/v1` is not public. Do not reuse test `isolated` mode. +- Check account credential/file separation, encryption-key backup and logs. +- Run `npm test`, publication and the pinned local secret scanner. Keep runtime + data and actual evidence outside Git. Inspect six themes and both languages. +- Separately authorize two-user ChatGPT tests using synthetic sentinels, complete + reads and revocation isolation. Local protocol tests do not certify that host. +- Obtain explicit deployment/restart approval. Do not equate this preview or its + UI with production readiness, mobile support or approved recovery operations. diff --git a/server/lib/app.mjs b/server/lib/app.mjs index 4c5cb60..766a80b 100644 --- a/server/lib/app.mjs +++ b/server/lib/app.mjs @@ -1,6 +1,7 @@ import http from "node:http"; import { URL } from "node:url"; import {isWebReader} from './memory/web-visibility.mjs'; +import {isConsoleReader,consoleRead} from './console-read.mjs'; import { AuthenticationError, MnemuronStore, @@ -98,6 +99,13 @@ export function createMnemuronApp({ } const auth = store.authenticate(bearerToken(request)); + if(isConsoleReader(auth) && !( + request.method==='GET' && (pathname==='/v1/identity' || /^\/v1\/console\/(overview|memories|summaries|summary|jobs|storage|connections|audit)$/.test(pathname) || /^\/v1\/memories\/[A-Za-z0-9_.:-]+$/.test(pathname)) + || request.method==='POST' && ['/v1/memories/query','/v1/memory-summaries/query','/v1/memory-source-manifests/query'].includes(pathname))) + throw new NotFoundError('Endpoint not available to this destination.'); + if(request.method==='GET'&&pathname.startsWith('/v1/console/')) { + responseStatus=200;return sendJson(response,200,await consoleRead(store,auth,pathname.slice('/v1/console/'.length),Object.fromEntries(url.searchParams))); + } if(isWebReader(auth) && !( request.method==='GET' && (['/v1/identity','/readyz/search'].includes(pathname) || /^\/v1\/memories\/[A-Za-z0-9_.:-]+$/.test(pathname)) || request.method==='POST' && ['/v1/memories/query','/v1/memory-summaries/query','/v1/project-context/preview'].includes(pathname))) diff --git a/server/lib/console-read.mjs b/server/lib/console-read.mjs new file mode 100644 index 0000000..be28325 --- /dev/null +++ b/server/lib/console-read.mjs @@ -0,0 +1,42 @@ +import {ValidationError,NotFoundError,ConflictError} from './errors.mjs'; +export const isConsoleReader=auth=>auth.agent_id==='mnemuron-console'; +export function consoleRead(store,auth,view,params={}) { + store.requireScope(auth,'console:read'); + if(!isConsoleReader(auth))throw new NotFoundError('Console route not available.'); + const allowed=view==='memories'?['offset','limit','query']:view==='summary'?['summary_id','revision','cursor']:[]; + if(Object.keys(params).some(key=>!allowed.includes(key)))throw new ValidationError('Unknown console query parameter.'); + const db=store.db,user=auth.user_id; + const count=table=>db.prepare(`SELECT COUNT(*) n FROM ${table} WHERE user_id=?`).get(user).n; + switch(view) { + case 'overview':return {read_only:true,production_ready:false,counts:{memories:count('memories'),sources:count('memory_sources'),summaries:count('memory_summaries'),jobs:count('memory_jobs')}, + recent:db.prepare('SELECT memory_id,content,memory_type,status,created_at FROM memories WHERE user_id=? ORDER BY created_at DESC,memory_id LIMIT 5').all(user).map(m=>({...m,content:[...m.content].slice(0,160).join('')}))}; + case 'memories': { + if(params.query) { + if(params.query.length>2000)throw new ValidationError('Query too long.'); + return store.searchMemories(auth,{query:params.query,limit:20,mode:'lexical'}); + } + const offset=Number(params.offset??0),limit=Number(params.limit??25); + if(!Number.isInteger(offset)||offset<0||offset>1000000||!Number.isInteger(limit)||limit<1||limit>50)throw new ValidationError('Invalid pagination.'); + const rows=db.prepare('SELECT memory_id,content,memory_type,status,created_at FROM memories WHERE user_id=? ORDER BY created_at DESC,memory_id LIMIT ? OFFSET ?').all(user,limit+1,offset); + return {read_only:true,results:rows.slice(0,limit).map(m=>({...m,content:[...m.content].slice(0,160).join('')})),next_offset:rows.length>limit?offset+limit:null}; + } + case 'summaries':return {read_only:true, + categories:db.prepare('SELECT category,COUNT(DISTINCT memory_id) count FROM memory_annotations WHERE user_id=? GROUP BY category').all(user), + summaries:db.prepare('SELECT summary_id,category,status,revision,coverage,omitted,created_at FROM memory_summaries WHERE user_id=? ORDER BY created_at DESC LIMIT 100').all(user)}; + case 'summary': { + const row=db.prepare('SELECT rowid AS row,* FROM memory_summaries WHERE user_id=? AND summary_id=?').get(user,params.summary_id||''); + if(!row)throw new NotFoundError('Summary not found.'); + if(row.status!=='current'||params.revision!==undefined&&Number(params.revision)!==row.revision)throw new ConflictError('Summary changed; restart reading.','SUMMARY_VERSION_CHANGED'); + const offset=params.cursor?0:db.prepare("SELECT COUNT(*) n FROM memory_summaries WHERE user_id=? AND scope_key=? AND category=? AND status='current' AND rowid>?").get(user,row.scope_key,row.category,row.row).n; + const result=store.derivedMemory.summaries(user,row.scope_key,{auth,category:row.category,limit:1,offset,cursor:params.cursor,budget:48*1024}); + if(result.results[0]?.summary_id!==row.summary_id)throw new ConflictError('Summary changed; restart reading.','SUMMARY_VERSION_CHANGED'); + const next=result.results[0].content_complete?null:{summary_id:row.summary_id,revision:row.revision,cursor:result.next_cursor}; + return {...result,next_request:next,next_cursor:next?.cursor||null,next_offset:null,complete:!next}; + } + case 'jobs':return {read_only:true,jobs:db.prepare('SELECT job_id,job_type,state,total,processed,attempt_count,last_error_code,created_at,updated_at FROM memory_jobs WHERE user_id=? ORDER BY created_at DESC LIMIT 100').all(user),operations:'blocked_policy'}; + case 'connections':return {read_only:true,connections:db.prepare('SELECT credential_id,label,device_id,agent_id,agent_instance_id,created_at,last_used_at,revoked_at FROM credentials WHERE user_id=? ORDER BY created_at DESC LIMIT 100').all(user),operations:'blocked_policy'}; + case 'audit':return {read_only:true,entries:db.prepare('SELECT audit_id,action,target_type,target_id,outcome,created_at FROM audit_events WHERE user_id=? ORDER BY created_at DESC LIMIT 100').all(user)}; + case 'storage':return {read_only:true,counts:{memories:count('memories'),sources:count('memory_sources'),events:count('events'),revisions:count('memory_revisions')},export:'blocked_policy',restore:'blocked_policy'}; + default:throw new NotFoundError('Console view not found.'); + } +} diff --git a/server/lib/memory-jobs/store.mjs b/server/lib/memory-jobs/store.mjs index 827d1d0..2578c09 100644 --- a/server/lib/memory-jobs/store.mjs +++ b/server/lib/memory-jobs/store.mjs @@ -12,7 +12,8 @@ export class MemoryJobs { CREATE TABLE IF NOT EXISTS memory_job_items (job_id TEXT NOT NULL,ordinal INTEGER NOT NULL,user_id TEXT NOT NULL,memory_id TEXT NOT NULL,revision INTEGER NOT NULL, state_hash TEXT NOT NULL,scope_key TEXT NOT NULL,state TEXT NOT NULL,result_json TEXT,PRIMARY KEY(job_id,ordinal)); CREATE TABLE IF NOT EXISTS memory_profile_state (profile TEXT PRIMARY KEY,state TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS memory_model_budget (profile TEXT NOT NULL,day TEXT NOT NULL,reserved_calls INTEGER NOT NULL,PRIMARY KEY(profile,day));`); + CREATE TABLE IF NOT EXISTS memory_model_budget (profile TEXT NOT NULL,day TEXT NOT NULL,reserved_calls INTEGER NOT NULL,PRIMARY KEY(profile,day)); + CREATE TABLE IF NOT EXISTS memory_owner_model_usage (user_id TEXT NOT NULL,profile TEXT NOT NULL,day TEXT NOT NULL,reserved_calls INTEGER NOT NULL,PRIMARY KEY(user_id,profile,day));`); } enqueue({type,userId,scope,profile,metadata,items,highwater=0}) { if(!['classification','summary'].includes(type) || !items.length || items.some(i=>i.user_id!==userId || i.scope_key!==scope))fail('INVALID_JOB'); @@ -42,7 +43,8 @@ export class MemoryJobs { }); } get(id){const job=this.db.prepare('SELECT * FROM memory_jobs WHERE job_id=?').get(id);return job?{...job,metadata:JSON.parse(job.metadata_json)}:null;} - owns(job){const row=this.get(job.job_id);return row?.state==='leased' && row.fence===job.fence && row.lease_owner===job.lease_owner && row.lease_expires>this.clock();} + owns(job){const row=this.get(job.job_id);return row?.state==='leased' && row.fence===job.fence && row.lease_owner===job.lease_owner && row.lease_expires>this.clock() + && ['user_id','scope_key','profile','input_hash','group_key','job_type'].every(key=>row[key]===job[key]) && JSON.stringify(row.metadata)===JSON.stringify(job.metadata);} renew(job){if(!this.owns(job))fail('LEASE_LOST');this.db.prepare('UPDATE memory_jobs SET lease_expires=? WHERE job_id=? AND fence=?').run(this.clock()+this.leaseMs,job.job_id,job.fence);} items(job){return this.db.prepare('SELECT * FROM memory_job_items WHERE job_id=? ORDER BY ordinal').all(job.job_id);} reserve(job,limit){return this.store.memoryTransaction(()=>{ @@ -51,10 +53,19 @@ export class MemoryJobs { const current=this.db.prepare('SELECT reserved_calls FROM memory_model_budget WHERE profile=? AND day=?').get(job.profile,day); if(current.reserved_calls>=limit)fail('BUDGET_EXHAUSTED'); this.db.prepare('UPDATE memory_model_budget SET reserved_calls=reserved_calls+1 WHERE profile=? AND day=?').run(job.profile,day); + // Account attribution is not a new cost allocation policy; the existing global ceiling still applies. + this.db.prepare(`INSERT INTO memory_owner_model_usage VALUES(?,?,?,1) ON CONFLICT(user_id,profile,day) + DO UPDATE SET reserved_calls=reserved_calls+1`).run(job.user_id,job.profile,day); });} saveChunk(job,items,results){return this.store.memoryTransaction(()=>{ if(!this.owns(job))fail('LEASE_LOST'); - for(const item of items)if(!this.store.derivedMemory.validateItem(item))fail('STALE_INPUT'); + for(const item of items) { + const saved=this.db.prepare('SELECT * FROM memory_job_items WHERE job_id=? AND ordinal=?').get(job.job_id,item.ordinal); + if(!saved||item.job_id!==job.job_id||item.user_id!==job.user_id||item.scope_key!==job.scope_key + ||['user_id','memory_id','revision','state_hash','scope_key'].some(key=>saved[key]!==item[key]))fail('INVALID_JOB_ITEM'); + if(!this.store.derivedMemory.validateItem(item))fail('STALE_INPUT'); + } + if(results.some(result=>!items.some(item=>item.memory_id===result.memory_id)))fail('INVALID_SOURCE_SET'); for(const item of items)this.db.prepare("UPDATE memory_job_items SET state='done',result_json=? WHERE job_id=? AND ordinal=?") .run(JSON.stringify(results.filter(r=>r.memory_id===item.memory_id)),job.job_id,item.ordinal); this.db.prepare("UPDATE memory_jobs SET processed=(SELECT COUNT(*) FROM memory_job_items WHERE job_id=? AND state='done'),updated_at=? WHERE job_id=?") @@ -62,7 +73,7 @@ export class MemoryJobs { });} publish(job,callback){return this.store.memoryTransaction(()=>{ if(!this.owns(job))fail('LEASE_LOST');const items=this.items(job); - if(items.some(i=>i.state!=='done' || !this.store.derivedMemory.validateItem(i)))fail('STALE_INPUT'); + if(items.some(i=>i.user_id!==job.user_id || i.scope_key!==job.scope_key || i.state!=='done' || !this.store.derivedMemory.validateItem(i)))fail('STALE_INPUT'); const ref=callback(items,items.flatMap(i=>JSON.parse(i.result_json))); this.db.prepare("UPDATE memory_jobs SET state='succeeded',result_ref=?,lease_owner=NULL,lease_expires=NULL,last_error_code=NULL,updated_at=? WHERE job_id=?") .run(ref || null,this.clock(),job.job_id);return ref; diff --git a/server/lib/store.mjs b/server/lib/store.mjs index f8b91a6..1ed1699 100644 --- a/server/lib/store.mjs +++ b/server/lib/store.mjs @@ -1240,6 +1240,7 @@ export class MnemuronStore { registerAgent(auth, payload) { this.requireScope(auth, "admin:devices"); + if(payload.user_id && payload.user_id!==auth.user_id) throw new AuthorizationError('same-owner device registration'); const result = this.issueCredential({ label: payload.label, userId: payload.user_id || auth.user_id, @@ -5811,6 +5812,7 @@ export class MnemuronStore { publicIdentity(auth) { return { + ...((isWebReader(auth)||auth.agent_id==='mnemuron-console')?{credential_id:auth.credential_id}:{}), user_id: auth.user_id, device_id: auth.device_id, agent_id: auth.agent_id, diff --git a/server/lib/vector-stores/index.mjs b/server/lib/vector-stores/index.mjs index 9d72ba7..5043be4 100644 --- a/server/lib/vector-stores/index.mjs +++ b/server/lib/vector-stores/index.mjs @@ -24,15 +24,19 @@ export class VectorIndex { scope_key TEXT NOT NULL,content_hash TEXT NOT NULL,state TEXT NOT NULL,PRIMARY KEY(generation,user_id,memory_id)); CREATE TABLE IF NOT EXISTS memory_vector_points (point_id TEXT PRIMARY KEY,generation TEXT NOT NULL,user_id TEXT NOT NULL,memory_id TEXT NOT NULL,revision INTEGER NOT NULL,chunk INTEGER NOT NULL); CREATE INDEX IF NOT EXISTS memory_vector_point_doc ON memory_vector_points(generation,user_id,memory_id); - CREATE TABLE IF NOT EXISTS memory_vector_calls (profile TEXT NOT NULL,day TEXT NOT NULL,count INTEGER NOT NULL,PRIMARY KEY(profile,day));`); + CREATE TABLE IF NOT EXISTS memory_vector_calls (profile TEXT NOT NULL,day TEXT NOT NULL,count INTEGER NOT NULL,PRIMARY KEY(profile,day)); + CREATE TABLE IF NOT EXISTS memory_owner_vector_usage (user_id TEXT NOT NULL,profile TEXT NOT NULL,day TEXT NOT NULL,count INTEGER NOT NULL,PRIMARY KEY(user_id,profile,day));`); } - reserve(embedder){this.store.memoryTransaction(()=>{const p=embedder.profile,day=new Date(this.clock()).toISOString().slice(0,10); + reserve(embedder,userId){this.store.memoryTransaction(()=>{const p=embedder.profile,day=new Date(this.clock()).toISOString().slice(0,10); + if(typeof userId!=='string'||!userId)fail('INVALID_OWNER'); if(this.db.prepare('SELECT state FROM memory_profile_state WHERE profile=?').get(p.fingerprint)?.state==='blocked_auth')fail('AUTH_FAILED'); this.db.prepare('INSERT OR IGNORE INTO memory_vector_calls VALUES (?,?,0)').run(p.fingerprint,day); if(this.db.prepare('SELECT count FROM memory_vector_calls WHERE profile=? AND day=?').get(p.fingerprint,day).count>=p.limits.daily_requests)fail('BUDGET_EXHAUSTED'); this.db.prepare('UPDATE memory_vector_calls SET count=count+1 WHERE profile=? AND day=?').run(p.fingerprint,day); + this.db.prepare(`INSERT INTO memory_owner_vector_usage VALUES(?,?,?,1) ON CONFLICT(user_id,profile,day) + DO UPDATE SET count=count+1`).run(userId,p.fingerprint,day); });} - async embed(embedder,texts,inputType,options){try{return await embedder.embed(texts,inputType,{...options,reserve:()=>this.reserve(embedder)});} + async embed(embedder,texts,inputType,{userId,...options}){try{return await embedder.embed(texts,inputType,{...options,reserve:()=>this.reserve(embedder,userId)});} catch(error){if(error.code==='AUTH_FAILED')this.db.prepare('INSERT OR REPLACE INTO memory_profile_state VALUES (?,?)').run(embedder.profile.fingerprint,'blocked_auth');throw error;}} begin(profile){ const embedder=this.embedders.get(profile);if(!embedder?.profile.enabled)fail('NOT_CONFIGURED'); @@ -72,7 +76,7 @@ export class VectorIndex { const chunks=splitDocument(source.content,Math.min(8192,Math.floor(e.profile.limits.input_tokens/4))),points=[]; for(let start=0;start128)fail('VECTOR_SCOPE_TOO_BROAD'); const filter={must:[condition('owner',surrogate(auth.user_id)),condition('profile',snapshot.profile),condition('lifecycle','active'),{key:'scope',match:{any:scopes.map(row=>surrogate(scopeKey(row)))}}]}; - const {vectors}=await this.embed(e,[payload.query],'query',{sensitivity:'sensitive'}); + const {vectors}=await this.embed(e,[payload.query],'query',{sensitivity:'sensitive',userId:auth.user_id}); const hits=scopes.length?await this.backend.search(snapshot.collection_name,vectors[0],filter,100):[],semantic=[]; assertWebIndexFresh(); // Network waits may outlive a privacy change; rebuild lexical results and conflicts now. diff --git a/server/test/account-ownership-inventory.test.mjs b/server/test/account-ownership-inventory.test.mjs new file mode 100644 index 0000000..87dbc20 --- /dev/null +++ b/server/test/account-ownership-inventory.test.mjs @@ -0,0 +1,13 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import {memoryFixture} from './helpers/core-memory-fixture.mjs'; +import {VectorIndex} from '../lib/vector-stores/index.mjs'; +test('BASE-02: every Core table is classified, including derived indices and operator-only state',async t=>{ + const f=await memoryFixture(t);new VectorIndex(f.store,null,new Map()); + const inventory=JSON.parse(fs.readFileSync(new URL('../../docs/architecture/account-ownership.json',import.meta.url),'utf8')); + const tables=f.store.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all().map(r=>r.name); + const classified=Object.values(inventory.core).flat();assert.equal(new Set(classified).size,classified.length); + assert.deepEqual(classified.sort(),tables); + for(const table of inventory.core.user_id)assert.ok(f.store.db.prepare(`PRAGMA table_info(${table})`).all().some(c=>c.name==='user_id'),table); +}); diff --git a/server/test/console-isolation.test.mjs b/server/test/console-isolation.test.mjs new file mode 100644 index 0000000..4764dfc --- /dev/null +++ b/server/test/console-isolation.test.mjs @@ -0,0 +1,18 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {memoryFixture} from './helpers/core-memory-fixture.mjs'; +test('ISO-01..04 ISO-11..15 INT-10: account-bound console reads, no admin bypass or exports',async t=>{ + const f=await memoryFixture(t);const a=f.store.issueCredential({userId:f.a.auth.user_id,deviceId:'console',agentId:'mnemuron-console',agentInstanceId:'console-A',scopes:['memory:read','resume:read','console:read']}); + const own=f.store.saveMemory(f.a.auth,{scope:'user',content:'Synthetic isolated console A'}).memory; + const foreign=f.store.saveMemory(f.other.auth,{scope:'user',content:'Synthetic isolated console B'}).memory; + for(const view of ['overview','memories','summaries','jobs','storage','connections','audit']) { + const r=await f.request('GET',`/v1/console/${view}`,undefined,a);assert.equal(r.status,200,view); + assert.ok(!JSON.stringify(r.body).includes(foreign.memory_id));assert.ok(!JSON.stringify(r.body).includes(f.other.auth.user_id)); + } + assert.equal((await f.request('GET','/v1/console/memories?user_id='+f.other.auth.user_id,undefined,a)).status,400); + assert.equal((await f.request('GET','/v1/console/export/../../credentials',undefined,a)).status,404); + assert.equal((await f.request('GET','/v1/status',undefined,a)).status,404); + assert.equal((await f.request('POST','/v1/memories',{scope:'user',content:'forbidden'},a)).status,404); + assert.equal((await f.request('GET',`/v1/memories/${foreign.memory_id}`,undefined,a)).status,404); + assert.equal((await f.request('GET',`/v1/memories/${own.memory_id}`,undefined,a)).status,200); +}); diff --git a/server/test/memory-first-vector.test.mjs b/server/test/memory-first-vector.test.mjs index 86adb7a..f3592c5 100644 --- a/server/test/memory-first-vector.test.mjs +++ b/server/test/memory-first-vector.test.mjs @@ -33,6 +33,15 @@ test('V-01 V-02 V-03: authoritative outbox survives failure, points contain no s assert.equal(f.s.db.prepare('SELECT state FROM memory_index_outbox WHERE memory_id=?').get(c.memory_id).state,'disabled'); f.backend.down=false;await f.index.sync(f.generation);assert.equal(points.size,3); }); +test('ISO-08: embedding usage is attributed per owner without exposing another owner ledger',async t=>{ + const f=await indexed(t),other=f.s.issueCredential({label:'Synthetic other usage',userId:'other-usage-owner',deviceId:'test',agentId:'synthetic',agentInstanceId:'usage-other',scopes:['memory:read','memory:write']}); + const auth=f.s.authenticate(other.api_key);f.s.saveMemory(auth,{scope:'user',content:'Synthetic other network memory'}); + await f.index.sync(f.generation);await f.index.search(auth,{query:'network',mode:'semantic'}); + const rows=f.s.db.prepare('SELECT user_id,SUM(count) n FROM memory_owner_vector_usage GROUP BY user_id').all(); + assert.equal(rows.length,2);assert.ok(rows.every(r=>r.n>0)); + assert.equal(rows.reduce((n,r)=>n+r.n,0),f.s.db.prepare('SELECT SUM(count) n FROM memory_vector_calls').get().n); + assert.throws(()=>f.index.reserve(f.e,''),e=>e.code==='INVALID_OWNER'); +}); test('V-04 V-08 V-10 R-08: stale/foreign/future/orphan points never hydrate after lifecycle changes',async t=>{ const f=await indexed(t),snap=f.index.snapshot(),points=f.backend.collections.get(snap.collection_name).points; const before=[...points.values()].find(p=>p.payload.document===surrogate([f.auth.user_id,f.a.memory_id])); diff --git a/server/test/worker-owner-boundaries.test.mjs b/server/test/worker-owner-boundaries.test.mjs new file mode 100644 index 0000000..fae9076 --- /dev/null +++ b/server/test/worker-owner-boundaries.test.mjs @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {fixture,organizer,taxonomy} from './helpers/memory-models.mjs'; +import {MemoryJobs} from '../lib/memory-jobs/store.mjs'; +import {scheduleLibrary,MemoryWorker} from '../lib/memory-jobs/worker.mjs'; + +test('ISO-08: lease, chunk, output, accounting and fingerprint retain their authoritative owner',async t=>{ + const f=fixture(t),model=organizer(),jobs=new MemoryJobs(f.s,{concurrency:2}); + const credential=f.s.issueCredential({userId:'synthetic-other',deviceId:'other',agentId:'synthetic',agentInstanceId:'other',scopes:['memory:read','memory:write']}); + const other=f.s.authenticate(credential.api_key); + f.save('Same synthetic source');f.s.saveMemory(other,{scope:'user',content:'Same synthetic source'}); + const a=scheduleLibrary(f.s,jobs,{userId:f.auth.user_id,organizer:model,taxonomy}); + const b=scheduleLibrary(f.s,jobs,{userId:other.user_id,organizer:model,taxonomy}); + assert.notEqual(a.jobs[0],b.jobs[0]); + const ja=jobs.claim('worker-a',{userId:f.auth.user_id}),jb=jobs.claim('worker-b',{userId:other.user_id}); + assert.equal(jobs.owns({...ja,user_id:other.user_id}),false); + assert.equal(jobs.owns({...ja,profile:'another-profile'}),false); + assert.equal(jobs.owns({...ja,metadata:{...ja.metadata,category:'forged'}}),false); + const ia=jobs.items(ja)[0],ib=jobs.items(jb)[0]; + assert.throws(()=>jobs.saveChunk(ja,[{...ib,job_id:ja.job_id}],[]),e=>e.code==='INVALID_JOB_ITEM'); + assert.throws(()=>jobs.saveChunk(ja,[ia],[{memory_id:ib.memory_id,category:'engineering',tags:[]}]),e=>e.code==='INVALID_SOURCE_SET'); + assert.equal(jobs.items(ja)[0].state,'pending'); + jobs.reserve(ja,3);jobs.reserve(ja,3);jobs.reserve(jb,3); + const ledger=f.s.db.prepare('SELECT user_id,reserved_calls FROM memory_owner_model_usage ORDER BY user_id').all(); + assert.equal(ledger.find(r=>r.user_id===f.auth.user_id).reserved_calls,2); + assert.equal(ledger.find(r=>r.user_id===other.user_id).reserved_calls,1); + assert.throws(()=>jobs.reserve(jb,3),e=>e.code==='BUDGET_EXHAUSTED'); + assert.equal(f.s.db.prepare('SELECT SUM(reserved_calls) n FROM memory_owner_model_usage').get().n,3); +}); + +test('ISO-08: two owners run the existing synthetic organizer without cross-owner publication',async t=>{ + const f=fixture(t),model=organizer(),jobs=new MemoryJobs(f.s); + const cred=f.s.issueCredential({userId:'synthetic-b',deviceId:'b',agentId:'synthetic',agentInstanceId:'b',scopes:['memory:read','memory:write']}); + const b=f.s.authenticate(cred.api_key),ma=f.save('Synthetic identical job content'),mb=f.s.saveMemory(b,{scope:'user',content:ma.content}).memory; + for(const userId of [f.auth.user_id,b.user_id])scheduleLibrary(f.s,jobs,{userId,organizer:model,taxonomy}); + const results=await new MemoryWorker(f.s,jobs,model).drain();assert.equal(results.length,2);assert.ok(results.every(r=>r.state==='succeeded')); + for(const [user,id] of [[f.auth.user_id,ma.memory_id],[b.user_id,mb.memory_id]]) { + const rows=f.s.db.prepare('SELECT memory_id,user_id FROM memory_annotations WHERE user_id=?').all(user); + assert.equal(rows.length,1);assert.equal(rows[0].memory_id,id); + } +}); diff --git a/services/oauth/bin/identity.mjs b/services/oauth/bin/identity.mjs new file mode 100644 index 0000000..45cd44e --- /dev/null +++ b/services/oauth/bin/identity.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import {pathToFileURL} from 'node:url'; +import {AuthStore} from '../src/sqlite-adapter.mjs'; +import {IdentityRepository} from '../src/identity-repository.mjs'; +import {provisionIdentities} from '../src/provisioning.mjs'; +import {loadAuthConfig} from '../src/config.mjs'; +import {acquireAuthorizationLease} from '../src/process-lease.mjs'; +import {MnemuronStore} from '../../../server/lib/store.mjs'; +import {storageDoctor,realDestination} from '../../../server/lib/storage-policy.mjs'; +import {readPrivate,writePrivate,requireConfig,BoundaryError} from '../../../shared/oauth-common.mjs'; + +export async function main(argv) { + if(!argv.length||argv.includes('--help')) { + console.log(`Local account operator; no public admin route, no secrets in arguments. +node services/oauth/bin/identity.mjs COMMAND --config /private/auth.json [options] +invite-issue --count INTEGER --ttl-minutes INTEGER --issuer LABEL --output /private/new-file +invite-list +invite-revoke --batch-id ID --confirm +migrate-owner --legacy-file /private/owner.json --mapping-file /private/map.json --confirm +provision --core-database /private/core.sqlite3 --credential-directory /private/keys --identity-map /private/map.json --confirm +recovery-inspect --account-id ID +recovery-reset --account-id ID --confirm (blocked until a recovery proof policy is approved) +status +Requires identity_mode=multi_account_v1. No production defaults for batch/session policy. +Stop legacy processes before migrate-owner. provision is a retryable local worker. +Recovery proof policy is pending; no weaker operator reset is enabled.`);return; + } + const [command,...rest]=argv,args=new Map(); + requireConfig(['invite-issue','invite-list','invite-revoke','migrate-owner','provision','status','recovery-inspect','recovery-reset'].includes(command),'known identity command'); + for(let i=0;i['--config','--isolated-fixture',...options].includes(key) + &&(value===true||typeof value==='string'&&!value.startsWith('--'))),'known complete command options'); + const config=loadAuthConfig(args.get('--config'),{isolated:args.has('--isolated-fixture')}); + requireConfig(config.identity_mode==='multi_account_v1','explicit multi-account mode'); + storageDoctor({database:config.database_file,key:config.identity.encryption_key_file}); + const release=command==='migrate-owner'?acquireAuthorizationLease(config.database_file):()=>{}; + let store; + try { + store=new AuthStore(config.database_file,{identity:true}); + const ids=new IdentityRepository(store,{issuer:config.issuer,keyFile:config.identity.encryption_key_file,batchLimit:config.identity.invitation_batch_limit,sessionTtl:config.identity.console_session_ttl_seconds}); + let result; + if(command==='invite-issue') { + const supplied=args.get('--output');requireConfig(typeof supplied==='string','private output required'); + const output=realDestination(supplied);storageDoctor({invitation_output:output});requireConfig(!fs.existsSync(output),'exclusive new output'); + const integer=name=>{const value=args.get(name);requireConfig(typeof value==='string'&&/^[1-9][0-9]*$/.test(value),'integer CLI parameter');return Number(value);}; + result=ids.issueInvitations({count:integer('--count'),ttlMinutes:integer('--ttl-minutes'),issuer:args.get('--issuer')}); + try{writePrivate(output,result);}catch(error){ids.revokeBatch(result.batch_id);throw error;} + result={batch_id:result.batch_id,count:result.codes.length,expires:result.expires,plaintext:'written_once_to_private_file'}; + } else if(command==='invite-list')result=ids.listInvitations(); + else if(command==='invite-revoke'){requireConfig(args.has('--confirm'),'explicit confirmation');result={revoked:ids.revokeBatch(args.get('--batch-id'))};} + else if(command==='migrate-owner') { + requireConfig(args.has('--confirm'),'explicit confirmation'); + const map=readPrivate(args.get('--mapping-file'),{json:true});requireConfig(map.mappings?.length===1,'exact legacy binding'); + result=ids.importLegacy(readPrivate(args.get('--legacy-file'),{json:true}),map.mappings[0]); + } else if(command==='provision') { + requireConfig(args.has('--confirm'),'explicit confirmation'); + const file=args.get('--core-database');requireConfig(fs.existsSync(file),'existing Core database'); + const core=new MnemuronStore(file); + try{result=provisionIdentities(ids,core,{credentialDirectory:args.get('--credential-directory'),identityMapFile:args.get('--identity-map')});}finally{core.close();} + } else if(command==='recovery-inspect'||command==='recovery-reset') { + const target=ids.byId(args.get('--account-id'));requireConfig(!!target,'exact recovery account required'); + if(command==='recovery-reset') { + requireConfig(args.has('--confirm'),'explicit recovery intent'); + ids.audit(target.account_id,'recovery.operator_request','blocked_policy');throw new BoundaryError(403,'BLOCKED_POLICY'); + } + result={account_id:target.account_id,status:target.status,security_version:target.security_version,mfa_verified:!!target.mfa_verified,policy:'blocked_policy', + operations:store.db.prepare("SELECT operation_id,state,last_error FROM identity_operations WHERE account_id=? AND kind LIKE 'recovery:%'").all(target.account_id)}; + } else if(command==='status') result={accounts:store.db.prepare('SELECT status,COUNT(*) count FROM identity_accounts GROUP BY status').all(),production_ready:false}; + else throw new Error('Unknown local identity command'); + console.log(JSON.stringify(result)); + } finally {store?.close();release();} +} +if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href) main(process.argv.slice(2)).catch(()=>{console.error('Identity operation refused or incomplete. Reconcile the durable operation; no secrets are printed.');process.exitCode=1;}); diff --git a/services/oauth/package-lock.json b/services/oauth/package-lock.json index 044b483..30e1267 100644 --- a/services/oauth/package-lock.json +++ b/services/oauth/package-lock.json @@ -10,7 +10,8 @@ "license": "Apache-2.0", "dependencies": { "oidc-provider": "9.12.2", - "otplib": "13.5.0" + "otplib": "13.5.0", + "qrcode": "1.5.4" }, "engines": { "node": ">=24" @@ -127,6 +128,68 @@ "node": ">= 0.6" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -179,6 +242,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-equal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", @@ -210,12 +282,24 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -231,6 +315,19 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -240,6 +337,15 @@ "node": ">= 0.6" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/http-assert": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", @@ -313,6 +419,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/jose": { "version": "6.2.12", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", @@ -369,6 +484,18 @@ "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==", "license": "MIT" }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/media-typer": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", @@ -462,6 +589,42 @@ "@otplib/uri": "13.5.0" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -471,6 +634,62 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -486,6 +705,32 @@ "node": ">= 0.8" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -543,6 +788,67 @@ "engines": { "node": ">= 0.8" } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } } } } diff --git a/services/oauth/package.json b/services/oauth/package.json index 791f7c5..db531ba 100644 --- a/services/oauth/package.json +++ b/services/oauth/package.json @@ -4,7 +4,9 @@ "private": true, "license": "Apache-2.0", "type": "module", - "engines": { "node": ">=24" }, + "engines": { + "node": ">=24" + }, "scripts": { "start": "node src/server.mjs", "admin": "node bin/admin.mjs", @@ -12,6 +14,7 @@ }, "dependencies": { "oidc-provider": "9.12.2", - "otplib": "13.5.0" + "otplib": "13.5.0", + "qrcode": "1.5.4" } } diff --git a/services/oauth/src/browser-session.mjs b/services/oauth/src/browser-session.mjs new file mode 100644 index 0000000..481dd2b --- /dev/null +++ b/services/oauth/src/browser-session.mjs @@ -0,0 +1,7 @@ +// Expire this browser's authorization session, not its grants or other devices. +export async function invalidateBrowserAuthorization(request,response,provider,{nextSubject}={}) { + const session=await provider.Session.get(provider.createContext(request,response)); + if(!session.accountId || (nextSubject && session.accountId===nextSubject))return false; + await session.destroy(); + return true; +} diff --git a/services/oauth/src/config.mjs b/services/oauth/src/config.mjs index 394a3e7..9eaf09d 100644 --- a/services/oauth/src/config.mjs +++ b/services/oauth/src/config.mjs @@ -21,7 +21,15 @@ export function validateAuthConfig(input, { isolated = false } = {}) { exactList(c.resource_scopes, RESOURCE_SCOPES, "resource_scopes"); exactList(c.oidc_scopes, ["openid", "offline_access"], "oidc_scopes"); requireConfig(c.client_registration?.dynamic === false && c.client_registration?.cimd === false, "static clients only"); - requireConfig(c.login?.mfa_required === true && c.login.registration_enabled === false + c.identity_mode ??= 'legacy_owner'; + requireConfig(['legacy_owner','multi_account_v1'].includes(c.identity_mode),'identity mode'); + if(c.identity_mode==='multi_account_v1') { + requireConfig(typeof c.identity?.encryption_key_file==='string' && c.identity.encryption_key_file.startsWith('/'),'identity encryption key file'); + for(const [name,min,max] of [['invitation_batch_limit',1,1000],['console_session_ttl_seconds',60,28800]]) + if(c.identity[name]!==undefined) boundedInteger(c.identity[name],null,min,max,name); + if(c.identity.core) canonicalUrl(c.identity.core.base_url,{isolated,loopbackHttp:true,pathname:'/'}); + } + requireConfig(c.login?.mfa_required === true && (c.login.registration_enabled === false || c.identity_mode==='multi_account_v1' && c.login.registration_enabled===true) && c.login.development_interactions === false && c.login.cookie_secure === true && c.login.cookie_http_only === true && c.login.cookie_same_site === "lax", "login security policy"); requireConfig(c.log?.include_tokens === false && c.log.include_request_body === false @@ -68,7 +76,7 @@ export function validateAuthConfig(input, { isolated = false } = {}) { requireConfig(isolated || (callback.protocol === "https:" && callback.hostname === "chatgpt.com" && (callback.pathname === "/connector_platform_oauth_redirect" || /^\/connector\/oauth\/[A-Za-z0-9_-]+$/.test(callback.pathname))), "exact ChatGPT callback"); - for (const key of ["database_file", "private_jwks_file", "cookie_keys_file", "accounts_file"]) { + for (const key of ["database_file", "private_jwks_file", "cookie_keys_file", ...(c.identity_mode==='legacy_owner' ? ['accounts_file'] : [])]) { requireConfig(typeof c[key] === "string" && c[key].startsWith("/"), key); } } diff --git a/services/oauth/src/console-core.mjs b/services/oauth/src/console-core.mjs new file mode 100644 index 0000000..0007112 --- /dev/null +++ b/services/oauth/src/console-core.mjs @@ -0,0 +1,29 @@ +import {BoundaryError,readPrivate,fetchJson} from '../../../shared/oauth-common.mjs'; +export class ConsoleCore { + constructor(config,principal,binding) { + if(!binding||binding.purpose!=='console'||!config?.base_url)throw new BoundaryError(503,'CONSOLE_CORE_UNAVAILABLE'); + this.config=config;this.principal=principal;this.binding=binding;this.token=readPrivate(binding.credential_file); + } + async request(route) { + let r; + try {r=await fetchJson(`${this.config.base_url}${route}`,{headers:{authorization:`Bearer ${this.token}`}}, {timeoutMs:5000,maxBytes:262144});} + catch{throw new BoundaryError(503,'CONSOLE_CORE_UNAVAILABLE');} + if(r.status!==200) { + const code=['MEMORY_VERSION_CHANGED','SOURCE_MANIFEST_CHANGED','SUMMARY_VERSION_CHANGED','INVALID_CURSOR','CURSOR_EXPIRED','MEMORY_NOT_FOUND'].includes(r.data.error_code)?r.data.error_code:'CONSOLE_READ_FAILED'; + throw new BoundaryError([400,404,409].includes(r.status)?r.status:503,code); + } + return r.data; + } + async view(view,params) { + const identity=await this.request('/v1/identity'),i=identity.identity; + if(i?.user_id!==this.principal.user_id||i?.credential_id!==this.binding.credential_id||i?.agent_id!=='mnemuron-console' + || i?.agent_instance_id!==this.binding.agent_instance_id||identity.scopes?.length!==3||!['console:read','memory:read','resume:read'].every(s=>identity.scopes.includes(s)))throw new BoundaryError(503,'CORE_IDENTITY_MISMATCH'); + if(view==='memory') { + const {memory_id,...options}=params; + if(!/^[A-Za-z0-9_.:-]{1,160}$/.test(memory_id||'')||Object.keys(options).some(k=>!['content_offset','content_limit','source_offset','revision','source_version','include_history'].includes(k)))throw new BoundaryError(400,'INVALID_DETAIL_REQUEST'); + return this.request(`/v1/memories/${encodeURIComponent(memory_id)}?${new URLSearchParams(options)}`); + } + if(!['overview','memories','summaries','summary','jobs','storage','connections','audit'].includes(view))throw new BoundaryError(404,'NOT_FOUND'); + return this.request(`/v1/console/${view}?${new URLSearchParams(params)}`); + } +} diff --git a/services/oauth/src/console.mjs b/services/oauth/src/console.mjs new file mode 100644 index 0000000..2627c5a --- /dev/null +++ b/services/oauth/src/console.mjs @@ -0,0 +1,132 @@ +import QRCode from 'qrcode'; +import {BoundaryError,parseForm,readBody,sendJson} from '../../../shared/oauth-common.mjs'; +import {routeTitle,sendPage,label,escapeHtml,serveAsset} from '../../../web/console/render.mjs'; +import {text} from '../../../web/console/catalog.mjs'; + +const field=(name,key,{type='text',autocomplete='off',pattern,maxlength=1024,value=''}={})=>`${type==='password'?``:''}`; +const form=(action,csrf,fields,submit='continue')=>`
${fields}
`; +const redirect=(response,to)=>{response.writeHead(303,{location:to,'cache-control':'no-store'});response.end();}; +const names=(config,purpose)=>`${config.isolated?'mnm_fixture_':'__Host-mnm_'}${purpose}`; +function cookie(request,config,purpose) { + const name=names(config,purpose),values=(request.headers.cookie||'').split(';').map(v=>v.trim()).filter(v=>v.startsWith(`${name}=`)); + if(values.length!==1)return '';return values[0].slice(name.length+1); +} +function setCookie(response,config,purpose,token,maxAge=600) { + response.setHeader('set-cookie',`${names(config,purpose)}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${config.isolated?'':'; Secure'}`); +} +function paramsOnly(body,names) {if([...body.keys()].some(k=>!names.includes(k)))throw new BoundaryError(400,'UNEXPECTED_FIELD');} + +export async function consoleRequest(request,response,{config,accounts,store,url,coreFor,invalidateAuthorization}) { + if(serveAsset(request,response,url.pathname))return true; + if(!['/register','/login','/recover','/app'].some(p=>url.pathname===p||url.pathname.startsWith(p+'/'))&&!url.pathname.startsWith('/console-api/'))return false; + if(config.identity_mode!=='multi_account_v1')throw new BoundaryError(503,'IDENTITY_UPGRADE_REQUIRED'); + const ids=accounts,pathname=url.pathname; + let body; + if(request.method==='POST') { + if(request.headers.origin!==config.issuer)throw new BoundaryError(403,'ORIGIN_DENIED'); + if(!/^application\/x-www-form-urlencoded(?:;|$)/i.test(request.headers['content-type']||''))throw new BoundaryError(415,'FORM_REQUIRED'); + body=parseForm(await readBody(request,Math.min(8192,config.limits.request_body_bytes))); + } + const show=(title,content)=>{sendPage(response,{title,body:content,auth:true});return true;}; + const submit=(purpose,allowed)=>{ + paramsOnly(body,['csrf',...allowed]);const token=cookie(request,config,purpose); + ids.session(token,purpose,{csrf:body.get('csrf')||''});return token; + }; + if(pathname==='/recover') { + if(request.method!=='GET')throw new BoundaryError(403,'BLOCKED_POLICY'); + return show('recover',`
${label('blocked','h2')}${label('securityNote','p')}
${text('signIn')}`); + } + if(pathname.startsWith('/register')) { + if(!config.login.registration_enabled)throw new BoundaryError(403,'REGISTRATION_DISABLED'); + if(pathname==='/register'&&request.method==='GET') { + const s=ids.newSession('registration_start',{ttl:600});setCookie(response,config,'registration_start',s.token); + return show('register',label('inviteNote','p')+form('/register/reserve',s.csrf,field('code','invitation',{maxlength:43}))); + } + if(pathname==='/register/reserve'&&request.method==='POST') { + const old=submit('registration_start',['code']);store.limit(`registration:peer:${request.socket.remoteAddress}`,30,900); + const s=ids.reserveInvitation(body.get('code'));ids.revokeSession(old);setCookie(response,config,'registration',s.token); + redirect(response,'/register/account');return true; + } + const token=cookie(request,config,'registration');const state=ids.registrationState(token); + if(pathname==='/register/account'&&request.method==='GET'&&state.status==='reserved') + return show('register',form('/register/account',ids.formCsrf(token,'registration'),field('username','username',{autocomplete:'username',maxlength:100})+field('password','password',{type:'password',autocomplete:'new-password'})+field('password_confirm','passwordConfirm',{type:'password',autocomplete:'new-password'}))); + if(pathname==='/register/account'&&request.method==='POST') { + submit('registration',['username','password','password_confirm']); + store.limit(`registration:account:${String(body.get('username')||'').toLowerCase()}`,10,900); + if(body.get('password')!==body.get('password_confirm'))throw new BoundaryError(400,'PASSWORD_CONFIRMATION_FAILED'); + await ids.prepareRegistration(token,body.get('username'),body.get('password'));redirect(response,'/register/totp');return true; + } + if(pathname==='/register/totp'&&request.method==='GET') { + const setup=ids.enrollment(token),qr=setup.uri?await QRCode.toString(setup.uri,{type:'svg',errorCorrectionLevel:'M',margin:4}):null; + return show('totp',(qr?label('totpNote','p')+`${escapeHtml(setup.secret)}`:label('alreadyShown','p'))+ + form('/register/totp',ids.formCsrf(token,'registration'),field('otp','otp',{autocomplete:'one-time-code',pattern:'[0-9]{6}',maxlength:6}),'verify')); + } + if(pathname==='/register/totp'&&request.method==='POST') { + submit('registration',['otp']);store.limit(`enroll:${state.account_id}`,10,900); + await ids.verifyEnrollment(token,body.get('otp'));redirect(response,'/register/recovery-codes');return true; + } + if(pathname==='/register/recovery-codes'&&request.method==='GET') { + const codes=state.recovery_available?ids.takeRecoveryCodes(token):null; + if(!['provisioning','active'].includes(state.status))throw new BoundaryError(400,'REGISTRATION_UNAVAILABLE'); + return show('recoveryCodes',(codes?label('recoveryNote','p')+``:label('alreadyShown','p'))+form('/register/ack',ids.formCsrf(token,'registration'),'','acknowledge')); + } + if(pathname==='/register/ack'&&request.method==='POST') { + submit('registration',[]);ids.acknowledgeRecovery(token);redirect(response,'/register/status');return true; + } + if(pathname==='/register/status'&&request.method==='GET') return show(state.status==='active'?'registered':'pending', + label(state.status==='active'?'registered':'pendingNote','p')+`

${escapeHtml(state.status)}

${text(state.status==='active'?'signIn':'retry')}`); + throw new BoundaryError(404,'NOT_FOUND'); + } + if(pathname==='/login') { + if(request.method==='GET') { + const s=ids.newSession('login',{ttl:600});setCookie(response,config,'login',s.token); + return show('consoleLogin',label('consoleLoginNote','p')+label('authNote','p')+form('/login',s.csrf,field('username','username',{autocomplete:'username',maxlength:100})+field('password','password',{type:'password',autocomplete:'current-password'})+field('otp','otp',{autocomplete:'one-time-code',pattern:'[0-9]{6}',maxlength:6}),'signIn')+``); + } + if(request.method==='POST') { + const old=submit('login',['username','password','otp']); + store.limit(`login:account:${String(body.get('username')||'').toLowerCase()}`,config.limits.login_attempts_per_account_per_15min,900); + store.limit(`login:peer:${request.socket.remoteAddress}`,100,900); + const subject=await ids.authenticate(body.get('username'),body.get('password'),body.get('otp')); + if(!subject)throw new BoundaryError(401,'LOGIN_FAILED'); + await invalidateAuthorization(subject); + const s=ids.newSession('console',{accountId:ids.account(subject).account_id});ids.revokeSession(old); + const previous=cookie(request,config,'console');if(previous)ids.revokeSession(previous); + setCookie(response,config,'console',s.token,config.identity.console_session_ttl_seconds);redirect(response,'/app');return true; + } + } + const token=cookie(request,config,'console');let session; + try{session=ids.session(token,'console');}catch(error){if(routeTitle(pathname)&&request.method==='GET'){redirect(response,'/login');return true;}throw error;} + const account=ids.byId(session.account_id),principal=ids.principal(account.subject); + if(routeTitle(pathname)&&request.method==='GET') { + sendPage(response,{title:routeTitle(pathname),page:routeTitle(pathname),account,csrf:ids.formCsrf(token,'console')});return true; + } + if(pathname==='/console-api/me'&&request.method==='GET') { + sendJson(response,200,{account_id:principal.account_id,username:account.username,mfa_verified:!!account.mfa_verified,security_version:principal.security_version,csrf:ids.formCsrf(token,'console'),production_ready:false});return true; + } + if(pathname==='/console-api/logout'&&request.method==='POST') { + submit('console',[]);ids.revokeSession(token);await invalidateAuthorization();setCookie(response,config,'console','',0);response.setHeader('clear-site-data','"cache"');redirect(response,'/login');return true; + } + if(pathname==='/console-api/security'&&request.method==='GET') { + const sessions=ids.db.prepare("SELECT purpose,created,expires FROM identity_sessions WHERE account_id=? AND purpose='console' AND expires>strftime('%s','now')").all(account.account_id); + sendJson(response,200,{username:account.username,mfa_verified:!!account.mfa_verified,security_version:account.security_version,sessions,recovery_policy:'blocked_policy',operations:'blocked_policy'});return true; + } + if(pathname==='/console-api/connections'&&request.method==='GET') { + const grants=store.db.prepare("SELECT payload,expires FROM oauth_records WHERE model='Grant' AND json_extract(payload,'$.accountId')=? AND expires>strftime('%s','now')").all(account.subject) + .map(row=>({client_id:JSON.parse(row.payload).clientId,expires:row.expires})); + const core=await coreFor(account.subject).view('connections',{});ids.session(token,'console'); + sendJson(response,200,{connections:grants,core_connections:core.connections,physical_device_verified:false,operations:'blocked_policy'});return true; + } + if(pathname==='/console-api/audit'&&request.method==='GET') { + const core=await coreFor(account.subject).view('audit',{});ids.session(token,'console'); + sendJson(response,200,{entries:ids.db.prepare('SELECT audit_id,action,outcome,created FROM identity_audit WHERE account_id=? ORDER BY created DESC,rowid DESC LIMIT 100').all(account.account_id),core_entries:core.entries,read_only:true});return true; + } + if(['/console-api/models','/console-api/invitations','/console-api/accounts'].includes(pathname))throw new BoundaryError(403,'BLOCKED_POLICY'); + if(coreFor && request.method==='GET' && /^\/console-api\/(overview|memories|summaries|summary|jobs|storage|memory)$/.test(pathname)) { + const core=coreFor(account.subject),view=pathname.slice('/console-api/'.length); + const result=await core.view(view,Object.fromEntries(url.searchParams)); + ids.session(token,'console'); // Do not send a response after revocation raced an awaited Core read. + ids.audit(account.account_id,`console.read.${view}`);sendJson(response,200,result);return true; + } + if(pathname.startsWith('/console-api/')&&request.method!=='GET')throw new BoundaryError(403,'BLOCKED_POLICY'); + throw new BoundaryError(404,'NOT_FOUND'); +} diff --git a/services/oauth/src/identity-repository.mjs b/services/oauth/src/identity-repository.mjs new file mode 100644 index 0000000..a101f24 --- /dev/null +++ b/services/oauth/src/identity-repository.mjs @@ -0,0 +1,248 @@ +import {randomBytes,randomUUID,createCipheriv,createDecipheriv,scrypt} from 'node:crypto'; +import {promisify} from 'node:util'; +import {generateSecret,generateURI,verify} from 'otplib'; +import {BoundaryError,readSecret,randomSecret,secretHash,equalSecret,seconds,requireConfig} from '../../../shared/oauth-common.mjs'; + +const derive=promisify(scrypt); +const SCRYPT={N:65536,r:8,p:1,maxmem:128*1024*1024}; +const denied=()=>new BoundaryError(400,'REGISTRATION_UNAVAILABLE'); +export function usernameKey(value) { + if(typeof value!=='string' || !/^[A-Za-z0-9_.@-]{1,100}$/.test(value)) throw new BoundaryError(400,'INVALID_USERNAME'); + // Preserve the legacy ASCII spelling; uniqueness is ASCII case insensitive. + return value.toLowerCase(); +} +export async function passwordRecord(value) { + if(typeof value!=='string' || value.length<14 || value.length>1024) throw new BoundaryError(400,'INVALID_PASSWORD'); + const salt=randomSecret();const hash=(await derive(value,salt,64,SCRYPT)).toString('base64url'); + return {algorithm:'scrypt',salt,hash,N:SCRYPT.N,r:SCRYPT.r,p:SCRYPT.p}; +} + +export class IdentityRepository { + constructor(store,{keyFile,issuer,batchLimit,sessionTtl}={}) { + this.store=store;this.db=store.db;this.issuer=issuer;this.batchLimit=batchLimit;this.sessionTtl=sessionTtl; + this.key=Buffer.from(readSecret(keyFile),'base64url');requireConfig(this.key.length===32,'identity encryption key'); + requireConfig(typeof issuer==='string' && new URL(issuer).origin===issuer,'identity issuer'); + this.db.exec(` + CREATE TABLE IF NOT EXISTS identity_accounts ( + account_id TEXT PRIMARY KEY,issuer TEXT NOT NULL,subject TEXT NOT NULL,user_id TEXT NOT NULL UNIQUE, + username TEXT NOT NULL,username_key TEXT NOT NULL UNIQUE,password_json TEXT NOT NULL,mfa_cipher TEXT NOT NULL, + mfa_verified INTEGER NOT NULL DEFAULT 0,status TEXT NOT NULL,security_version INTEGER NOT NULL DEFAULT 1, + recovery_hashes TEXT NOT NULL DEFAULT '[]',recovery_cipher TEXT,recovery_ack INTEGER NOT NULL DEFAULT 0, + binding_ready INTEGER NOT NULL DEFAULT 0,created INTEGER NOT NULL,UNIQUE(issuer,subject)); + CREATE TABLE IF NOT EXISTS identity_invitations ( + invitation_id TEXT PRIMARY KEY,batch_id TEXT NOT NULL,digest TEXT NOT NULL UNIQUE,issuer TEXT NOT NULL, + created INTEGER NOT NULL,expires INTEGER NOT NULL,state TEXT NOT NULL,session_digest TEXT, + reserved_until INTEGER,account_id TEXT); + CREATE TABLE IF NOT EXISTS identity_sessions ( + digest TEXT PRIMARY KEY,purpose TEXT NOT NULL,account_id TEXT,security_version INTEGER, + csrf_digest TEXT NOT NULL,expires INTEGER NOT NULL,created INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS identity_bindings ( + account_id TEXT NOT NULL,purpose TEXT NOT NULL,credential_id TEXT NOT NULL UNIQUE, + agent_instance_id TEXT NOT NULL,credential_file TEXT NOT NULL,checked INTEGER NOT NULL, + PRIMARY KEY(account_id,purpose)); + CREATE TABLE IF NOT EXISTS identity_operations ( + operation_id TEXT PRIMARY KEY,account_id TEXT NOT NULL,kind TEXT NOT NULL,state TEXT NOT NULL, + payload_cipher TEXT,last_error TEXT,created INTEGER NOT NULL,UNIQUE(account_id,kind)); + CREATE TABLE IF NOT EXISTS identity_audit ( + audit_id TEXT PRIMARY KEY,account_id TEXT,action TEXT NOT NULL,outcome TEXT NOT NULL,created INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS identity_recovery_claims ( + account_id TEXT NOT NULL,digest TEXT NOT NULL,session_digest TEXT NOT NULL,PRIMARY KEY(account_id,digest)); + `); + } + seal(value,account,purpose) { + const iv=randomBytes(12);const cipher=createCipheriv('aes-256-gcm',this.key,iv); + cipher.setAAD(Buffer.from(`identity-v1|${account}|${purpose}`)); + const body=Buffer.concat([cipher.update(JSON.stringify(value)),cipher.final()]); + return JSON.stringify({v:1,iv:iv.toString('base64url'),tag:cipher.getAuthTag().toString('base64url'),body:body.toString('base64url')}); + } + unseal(value,account,purpose) { + const data=JSON.parse(value);if(data.v!==1) throw new Error('Unsupported identity encryption version'); + const cipher=createDecipheriv('aes-256-gcm',this.key,Buffer.from(data.iv,'base64url')); + cipher.setAAD(Buffer.from(`identity-v1|${account}|${purpose}`));cipher.setAuthTag(Buffer.from(data.tag,'base64url')); + return JSON.parse(Buffer.concat([cipher.update(Buffer.from(data.body,'base64url')),cipher.final()]).toString()); + } + audit(account,action,outcome='success') { + this.db.prepare('INSERT INTO identity_audit VALUES(?,?,?,?,?)').run(randomUUID(),account,action,outcome,seconds()); + } + account(subject) {return this.db.prepare('SELECT * FROM identity_accounts WHERE issuer=? AND subject=?').get(this.issuer,subject);} + byId(id) {return this.db.prepare('SELECT * FROM identity_accounts WHERE account_id=?').get(id);} + eligible(subject) {const a=this.account(subject);return !!a && a.status==='active' && a.mfa_verified===1 && a.binding_ready===1 && a.recovery_ack===1;} + principal(subject) { + const a=this.account(subject);if(!this.eligible(subject)) throw new BoundaryError(403,'SUBJECT_DENIED'); + return Object.freeze({account_id:a.account_id,issuer:a.issuer,subject:a.subject,user_id:a.user_id,security_version:a.security_version}); + } + newSession(purpose,{accountId=null,ttl}={}) { + const lifetime=ttl??this.sessionTtl; + if(!Number.isInteger(lifetime)||lifetime<60||lifetime>28800) throw new BoundaryError(503,'SESSION_POLICY_REQUIRED'); + const token=randomSecret(),csrf=randomSecret();const a=accountId?this.byId(accountId):null; + this.db.prepare('INSERT INTO identity_sessions VALUES(?,?,?,?,?,?,?)').run(secretHash(token),purpose,accountId,a?.security_version??null,secretHash(csrf),seconds()+lifetime,seconds()); + return {token,csrf}; + } + session(token,purpose,{csrf}={}) { + if(typeof token!=='string'||token.length>256) throw new BoundaryError(401,'SESSION_REQUIRED'); + const row=this.db.prepare('SELECT * FROM identity_sessions WHERE digest=? AND purpose=? AND expires>?').get(secretHash(token),purpose,seconds()); + if(!row || (csrf!==undefined && !equalSecret(row.csrf_digest,secretHash(csrf)))) throw new BoundaryError(401,'SESSION_REQUIRED'); + if(row.account_id) { + const a=this.byId(row.account_id); + if(!a||a.security_version!==row.security_version || (purpose==='console'&&!this.eligible(a.subject))) throw new BoundaryError(401,'SESSION_REQUIRED'); + } + return row; + } + revokeSession(token) {this.db.prepare('DELETE FROM identity_sessions WHERE digest=?').run(secretHash(token));} + formCsrf(token,purpose) { + const row=this.session(token,purpose),csrf=randomSecret(); + this.db.prepare('UPDATE identity_sessions SET csrf_digest=? WHERE digest=?').run(secretHash(csrf),row.digest);return csrf; + } + issueInvitations({count,ttlMinutes,issuer}) { + if(!Number.isInteger(this.batchLimit)||this.batchLimit<1||this.batchLimit>1000) throw new BoundaryError(503,'BATCH_POLICY_REQUIRED'); + if(!Number.isInteger(count)||count<1||count>this.batchLimit||!Number.isInteger(ttlMinutes)||ttlMinutes<1||ttlMinutes>1440 + ||typeof issuer!=='string'||!issuer.trim()||issuer.length>128) throw new BoundaryError(400,'INVALID_INVITATION_PARAMETERS'); + const batch_id=randomUUID(),now=seconds();const codes=Array.from({length:count},randomSecret); + this.store.transaction(()=>{ + for(const code of codes) this.db.prepare('INSERT INTO identity_invitations(invitation_id,batch_id,digest,issuer,created,expires,state) VALUES(?,?,?,?,?,?,?)') + .run(randomUUID(),batch_id,secretHash(code),issuer,now,now+ttlMinutes*60,'issued'); + this.audit(null,'invitation.issue'); + }); + return {batch_id,codes,expires:now+ttlMinutes*60}; + } + listInvitations() {return this.db.prepare('SELECT invitation_id,batch_id,issuer,created,expires,state FROM identity_invitations ORDER BY created, rowid').all();} + revokeBatch(batchId) {return this.store.transaction(()=>{ + const changed=this.db.prepare("UPDATE identity_invitations SET state='revoked' WHERE batch_id=? AND state IN ('issued','reserved')").run(batchId).changes; + this.audit(null,'invitation.batch_revoke');return changed; + });} + reserveInvitation(code) { + if(typeof code!=='string'||!/^[A-Za-z0-9_-]{43}$/.test(code)) throw denied(); + return this.store.transaction(()=>{ + const i=this.db.prepare('SELECT * FROM identity_invitations WHERE digest=?').get(secretHash(code)),now=seconds(); + if(!i||i.expires<=now||!(i.state==='issued'||i.state==='reserved'&&i.reserved_until<=now)) throw denied(); + if(i.account_id) { + const abandoned=this.byId(i.account_id); + if(!abandoned||abandoned.status!=='pending_mfa'||abandoned.mfa_verified||abandoned.binding_ready)throw denied(); + // Keep the failed identity and audit, but never transfer its password or factor to a new claimant. + this.db.prepare("UPDATE identity_accounts SET status='registration_expired',security_version=security_version+1,username_key=? WHERE account_id=?") + .run(`expired:${abandoned.account_id}`,abandoned.account_id); + this.db.prepare('DELETE FROM identity_sessions WHERE account_id=?').run(abandoned.account_id); + this.audit(abandoned.account_id,'registration.expired'); + } + const session=this.newSession('registration',{ttl:Math.max(60,Math.min(600,i.expires-now))}); + this.db.prepare("UPDATE identity_invitations SET state='reserved',session_digest=?,reserved_until=?,account_id=NULL WHERE invitation_id=?") + .run(secretHash(session.token),Math.min(now+600,i.expires),i.invitation_id); + return session; + }); + } + reservation(token) { + const s=this.session(token,'registration');const i=this.db.prepare('SELECT * FROM identity_invitations WHERE session_digest=?').get(s.digest); + if(!i||!['reserved','consumed'].includes(i.state)||i.state==='reserved'&&(i.expires<=seconds()||i.reserved_until<=seconds())) throw denied(); + return {s,i}; + } + async prepareRegistration(token,username,password) { + const key=usernameKey(username);this.reservation(token);const record=await passwordRecord(password); + return this.store.transaction(()=>{ + const {s,i}=this.reservation(token);if(i.account_id) throw denied(); + if(this.db.prepare('SELECT 1 FROM identity_accounts WHERE username_key=?').get(key)) throw denied(); + const id=randomUUID(),subject=randomUUID(),userId=`user-${id}`; + this.db.prepare(`INSERT INTO identity_accounts(account_id,issuer,subject,user_id,username,username_key,password_json,mfa_cipher,status,created) + VALUES(?,?,?,?,?,?,?,?,?,?)`).run(id,this.issuer,subject,userId,username,key,JSON.stringify(record),this.seal(generateSecret(),id,'totp'),'pending_mfa',seconds()); + this.db.prepare('UPDATE identity_sessions SET account_id=?,security_version=1 WHERE digest=?').run(id,s.digest); + this.db.prepare('UPDATE identity_invitations SET account_id=? WHERE invitation_id=?').run(id,i.invitation_id); + this.audit(id,'registration.prepared');return {account_id:id,status:'pending_mfa'}; + }); + } + enrollment(token) { + return this.store.transaction(()=>{ + const {s}=this.reservation(token),a=this.byId(s.account_id); + if(!a||a.status!=='pending_mfa'||a.mfa_verified) throw denied(); + if(this.db.prepare("SELECT 1 FROM identity_audit WHERE account_id=? AND action='registration.totp_displayed'").get(a.account_id)) + return {subject:a.subject,already_shown:true}; + const secret=this.unseal(a.mfa_cipher,a.account_id,'totp');this.audit(a.account_id,'registration.totp_displayed'); + return {subject:a.subject,secret,uri:generateURI({issuer:'Mnemuron',label:a.username,secret})}; + }); + } + async verifyEnrollment(token,otp) { + const {s,i}=this.reservation(token),a=this.byId(s.account_id); + if(a?.mfa_verified&&i.state==='consumed'&&['provisioning','active'].includes(a.status))return {account_id:a.account_id,status:a.status}; + if(!a||a.status!=='pending_mfa'||typeof otp!=='string'||!/^\d{6}$/.test(otp)) throw denied(); + const result=await verify({secret:this.unseal(a.mfa_cipher,a.account_id,'totp'),token:otp,epochTolerance:30}); + if(!result.valid) throw denied(); + const codes=Array.from({length:8},randomSecret); + return this.store.transaction(()=>{ + const {i}=this.reservation(token),current=this.byId(a.account_id); + if(current.mfa_verified&&i.state==='consumed'&&['provisioning','active'].includes(current.status))return {account_id:a.account_id,status:current.status}; + if(current.status!=='pending_mfa'||i.state!=='reserved'||!this.store.consumeStep(a.subject,result.epoch)) throw denied(); + this.db.prepare("UPDATE identity_accounts SET mfa_verified=1,status='provisioning',recovery_hashes=?,recovery_cipher=? WHERE account_id=?") + .run(JSON.stringify(codes.map(secretHash)),this.seal(codes,a.account_id,'recovery-display'),a.account_id); + this.db.prepare("UPDATE identity_invitations SET state='consumed' WHERE invitation_id=?").run(i.invitation_id); + this.queueProvision(a.account_id);this.audit(a.account_id,'registration.mfa_verified'); + return {account_id:a.account_id,status:'provisioning'}; + }); + } + takeRecoveryCodes(token) { + return this.store.transaction(()=>{ + const {s}=this.reservation(token),a=this.byId(s.account_id); + if(!a?.recovery_cipher) throw new BoundaryError(409,'RECOVERY_CODES_ALREADY_SHOWN'); + const codes=this.unseal(a.recovery_cipher,a.account_id,'recovery-display'); + this.db.prepare('UPDATE identity_accounts SET recovery_cipher=NULL WHERE account_id=?').run(a.account_id); + this.audit(a.account_id,'registration.recovery_shown');return codes; + }); + } + acknowledgeRecovery(token) { + return this.store.transaction(()=>{ + const {s}=this.reservation(token),a=this.byId(s.account_id); + if(!a?.mfa_verified||a.recovery_cipher) throw denied(); + this.db.prepare('UPDATE identity_accounts SET recovery_ack=1 WHERE account_id=?').run(a.account_id); + this.activate(a.account_id);this.audit(a.account_id,'registration.recovery_ack'); + }); + } + registrationState(token) { + const {s}=this.reservation(token),a=this.byId(s.account_id); + return a?{account_id:a.account_id,status:a.status,recovery_ack:!!a.recovery_ack,recovery_available:!!a.recovery_cipher}:{status:'reserved'}; + } + activate(id) {this.db.prepare("UPDATE identity_accounts SET status='active' WHERE account_id=? AND status='provisioning' AND mfa_verified=1 AND recovery_ack=1 AND binding_ready=1").run(id);} + queueProvision(id) { + this.db.prepare("INSERT OR IGNORE INTO identity_operations(operation_id,account_id,kind,state,created) VALUES(?,?,?,'pending',?)").run(randomUUID(),id,`provision:${this.byId(id).security_version}`,seconds()); + } + importLegacy(owner,mapping) { + requireConfig(owner.subject===mapping.subject&&mapping.issuer===this.issuer&&typeof mapping.mnemuron_user_id==='string'&&mapping.mnemuron_user_id.length>0,'legacy identity mismatch'); + requireConfig(owner.password?.algorithm==='scrypt'&&owner.password.N===SCRYPT.N&&owner.password.r===8&&owner.password.p===1&&typeof owner.mfa?.secret==='string','legacy credentials'); + const key=usernameKey(owner.username); + return this.store.transaction(()=>{ + const old=this.account(owner.subject); + if(old){requireConfig(old.user_id===mapping.mnemuron_user_id && old.username===owner.username,'legacy identity mismatch');return {account_id:old.account_id,status:old.status};} + const id=randomUUID(); + this.db.prepare(`INSERT INTO identity_accounts(account_id,issuer,subject,user_id,username,username_key,password_json,mfa_cipher,mfa_verified,status,recovery_hashes,recovery_ack,created) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(id,this.issuer,owner.subject,mapping.mnemuron_user_id,owner.username,key,JSON.stringify(owner.password),this.seal(owner.mfa.secret,id,'totp'),owner.mfa.verified?1:0,owner.enabled&&mapping.enabled?'provisioning':'disabled',JSON.stringify(owner.recovery_hashes||[]),1,seconds()); + this.queueProvision(id);this.audit(id,'migration.legacy_imported');return {account_id:id,status:this.byId(id).status}; + }); + } + async authenticate(username,password,otp) { + let key;try{key=usernameKey(username);}catch{return null;} + const a=this.db.prepare('SELECT * FROM identity_accounts WHERE username_key=?').get(key); + const record=a?JSON.parse(a.password_json):{salt:'synthetic-constant-cost-unknown-user',hash:''}; + const value=typeof password==='string'&&password.length<=1024?password:''; + const hash=(await derive(value,record.salt,64,SCRYPT)).toString('base64url'); + if(!a||!equalSecret(hash,record.hash)||!this.eligible(a.subject)||!/^\d{6}$/.test(otp||'')) return null; + const result=await verify({secret:this.unseal(a.mfa_cipher,a.account_id,'totp'),token:otp,epochTolerance:30}); + if(!result.valid) return null; + return this.store.transaction(()=>{ + const current=this.byId(a.account_id); + if(!this.eligible(a.subject)||current.security_version!==a.security_version||current.password_json!==a.password_json||current.mfa_cipher!==a.mfa_cipher||!this.store.consumeStep(a.subject,result.epoch)) return null; + this.audit(a.account_id,'account.login');return a.subject; + }); + } + bindings(subject) { + const a=this.account(subject);if(!this.eligible(subject)) throw new BoundaryError(403,'SUBJECT_DENIED'); + return this.db.prepare('SELECT purpose,credential_id,agent_instance_id,credential_file FROM identity_bindings WHERE account_id=? AND checked=1').all(a.account_id); + } + finishProvision(id,bindings,operationId) { + return this.store.transaction(()=>{ + const a=this.byId(id),operation=this.db.prepare('SELECT * FROM identity_operations WHERE operation_id=? AND account_id=?').get(operationId,id); + requireConfig(a&&operation?.kind===`provision:${a.security_version}`&&['provisioning','active'].includes(a.status),'current provisioning epoch'); + requireConfig(bindings.length===2&&new Set(bindings.map(b=>b.purpose)).size===2&&bindings.every(b=>['web','console'].includes(b.purpose)),'two distinct core bindings'); + for(const b of bindings) this.db.prepare(`INSERT INTO identity_bindings VALUES(?,?,?,?,?,1) ON CONFLICT(account_id,purpose) DO UPDATE SET credential_id=excluded.credential_id,agent_instance_id=excluded.agent_instance_id,credential_file=excluded.credential_file,checked=1`) + .run(id,b.purpose,b.credential_id,b.agent_instance_id,b.credential_file); + this.db.prepare('UPDATE identity_accounts SET binding_ready=1 WHERE account_id=?').run(id); + this.db.prepare("UPDATE identity_operations SET state='completed',last_error=NULL WHERE account_id=? AND operation_id=?").run(id,operationId); + this.activate(id);this.audit(id,'identity.provisioned'); + }); + } +} diff --git a/services/oauth/src/interactions.mjs b/services/oauth/src/interactions.mjs index b0b209d..f4bee5c 100644 --- a/services/oauth/src/interactions.mjs +++ b/services/oauth/src/interactions.mjs @@ -1,45 +1,72 @@ import { BoundaryError, parseForm, readBody, seconds } from "../../../shared/oauth-common.mjs"; +import {sendPage,label} from '../../../web/console/render.mjs'; +import {text} from '../../../web/console/catalog.mjs'; +import {errors} from 'oidc-provider'; +import {invalidateBrowserAuthorization} from './browser-session.mjs'; const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char])); -function page(response, body, redirectUri) { +function page(response, body, redirectUri,enhanced=false,account=null,title='oauthConsent') { // no-referrer also suppresses Origin on native form POSTs, breaking the same-origin boundary. // Form navigation policies also cover the final redirect to the registered OAuth callback. - response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", - "content-security-policy": `default-src 'none'; form-action 'self' ${redirectUri}; frame-ancestors 'none'; base-uri 'none'`, - "x-frame-options": "DENY", "referrer-policy": "same-origin", "x-content-type-options": "nosniff" }); + if(enhanced)return sendPage(response,{title,body,auth:true,authPurpose:'oauth',account},{redirectUri}); + response.writeHead(200,{'content-type':'text/html; charset=utf-8','cache-control':'no-store', + 'content-security-policy':`default-src 'none'; form-action 'self' ${redirectUri}; frame-ancestors 'none'; base-uri 'none'`, + 'x-frame-options':'DENY','referrer-policy':'same-origin','x-content-type-options':'nosniff'}); response.end(`Mnemuron authorization

Mnemuron

${body}
`); } export async function interactionRequest(request, response, { provider, store, accounts, config, url }) { const match = url.pathname.match(/^\/interaction\/([A-Za-z0-9_-]{1,128})(?:\/(login|confirm|abort))?$/); if (!match) throw new BoundaryError(404, "NOT_FOUND"); - const details = await provider.interactionDetails(request, response); + let details; + try {details=await provider.interactionDetails(request,response);} + catch(error) {if(error instanceof errors.SessionNotFound)throw new BoundaryError(403,'INTERACTION_EXPIRED');throw error;} if (details.uid !== match[1] || details.params.client_id !== config.chatgpt_client.client_id || !config.chatgpt_client.redirect_uris.includes(details.params.redirect_uri)) { throw new BoundaryError(403, "INTERACTION_MISMATCH"); } const { uid, prompt, params } = details; + const enhanced=config.identity_mode==='multi_account_v1'; + if(enhanced && details.session?.accountId) { + const browser=await provider.Session.get(provider.createContext(request,response)); + if(browser.uid!==details.session.uid || browser.accountId!==details.session.accountId) + throw new BoundaryError(403,'INTERACTION_MISMATCH'); + } if (request.method === "GET" && !match[2]) { const csrf = store.csrf(uid, Math.min(details.exp - seconds(), config.token_policy.interaction_ttl_seconds)); const field = ``; const abort = `
${field}
`; if (prompt.name === "login") { + if(enhanced)return page(response,`${label('oauthLoginNote','p')}${label('authNote','p')}
${field} + + + + +
${abort.replace('>Cancel<',` data-i18n="cancel">${text('cancel')}<`)}`,params.redirect_uri,true,null,'oauthLogin'); return page(response, `

Sign in to your Mnemuron account

Do not enter your ChatGPT password here.

${field}

-
${abort}`, params.redirect_uri); + ${abort}`, params.redirect_uri,config.identity_mode==='multi_account_v1'); } if (prompt.name === "consent") { const scopes = String(params.scope || "").split(" "); + if(enhanced) { + const account=accounts.account(details.session?.accountId); + if(!account||!accounts.eligible(account.subject))throw new BoundaryError(403,'ACCOUNT_DISABLED'); + const keys={openid:'scopeIdentity',offline_access:'scopeOffline','memory:read':'scopeMemory','project:read':'scopeProject'}; + return page(response,` + ${label('oauthClient','p')}${escapeHtml(params.client_id)}${label('consentNote','p')} +
${field}
${abort.replace('>Cancel<',` data-i18n="cancel">${text('cancel')}<`)}`,params.redirect_uri,true,account); + } const labels = { openid: "Identify your Mnemuron account", offline_access: "Keep this connection with revocable, rotating refresh tokens", "memory:read": "Read memories you are authorized to access", "project:read": "Read context from your projects" }; const list = scopes.filter((scope) => labels[scope]).map((scope) => `
  • ${escapeHtml(labels[scope])}
  • `).join(""); return page(response, `

    Allow ${escapeHtml(config.chatgpt_client.client_id)}?

    This is read-only access to this owner's authorized data, not access restricted to one project. No memory writes, task switching or Resume confirmation.

    -
    ${field}
    ${abort}`, params.redirect_uri); +
    ${field}
    ${abort}`, params.redirect_uri,config.identity_mode==='multi_account_v1'); } throw new BoundaryError(400, "UNSUPPORTED_INTERACTION"); } @@ -53,10 +80,14 @@ export async function interactionRequest(request, response, { provider, store, a return provider.interactionFinished(request, response, { error: "access_denied", error_description: "The user denied authorization" }, { mergeWithLastSubmission: false }); } if (match[2] === "login" && prompt.name === "login") { - store.limit("login:owner", config.limits.login_attempts_per_account_per_15min, 900); - store.limit(`login:peer:${request.socket.remoteAddress}`, config.limits.login_attempts_per_account_per_15min, 900); + store.limit(`login:account:${String(body.get('username')||'').toLowerCase()}`, config.limits.login_attempts_per_account_per_15min, 900); + store.limit(`login:peer:${request.socket.remoteAddress}`, config.limits.login_attempts_per_account_per_15min*10, 900); const subject = await accounts.authenticate(body.get("username"), body.get("password"), body.get("otp")); if (!subject) throw new BoundaryError(401, "LOGIN_FAILED"); + // A different authenticated principal must start a fresh interaction. Do not + // let the provider's automatic account-switch form reuse the old consent. + if(enhanced && await invalidateBrowserAuthorization(request,response,provider,{nextSubject:subject})) + throw new BoundaryError(409,'AUTHORIZATION_RESTART_REQUIRED'); return provider.interactionFinished(request, response, { login: { accountId: subject, acr: "urn:mnemuron:password-totp", amr: ["pwd", "otp"], ts: seconds() } }, { mergeWithLastSubmission: false }); @@ -65,6 +96,7 @@ export async function interactionRequest(request, response, { provider, store, a const subject = details.session?.accountId; if (!accounts.eligible(subject)) throw new BoundaryError(403, "ACCOUNT_DISABLED"); let grant = details.grantId ? await provider.Grant.find(details.grantId) : undefined; + if(grant && (grant.accountId!==subject || grant.clientId!==params.client_id)) throw new BoundaryError(403,'INTERACTION_MISMATCH'); grant ||= new provider.Grant({ accountId: subject, clientId: params.client_id }); if (prompt.details.missingOIDCScope) grant.addOIDCScope(prompt.details.missingOIDCScope.join(" ")); for (const [resource, scopes] of Object.entries(prompt.details.missingResourceScopes || {})) { diff --git a/services/oauth/src/private-ingress.mjs b/services/oauth/src/private-ingress.mjs new file mode 100644 index 0000000..d409d20 --- /dev/null +++ b/services/oauth/src/private-ingress.mjs @@ -0,0 +1,67 @@ +import tls from 'node:tls'; +import net from 'node:net'; +import {pathToFileURL} from 'node:url'; +import {readPrivate} from '../../../shared/oauth-common.mjs'; + +const privateIPv4=value=>net.isIPv4(value)&&(/^(10\.|192\.168\.)/.test(value)||/^172\.(1[6-9]|2\d|3[01])\./.test(value)); +const integer=(value,min,max)=>Number.isInteger(value)&&value>=min&&value<=max; +export function validateIngressConfig(input,{isolated=false}={}){ + const c={connection_limit:128,handshake_timeout_ms:5000,connect_timeout_ms:3000, + idle_timeout_ms:60000,shutdown_timeout_ms:5000,...input}; + const address=value=>privateIPv4(value)||(isolated&&/^127\./.test(value)&&net.isIPv4(value)); + if(!address(c.listen_host)||!address(c.allowed_peer)||!integer(c.listen_port,isolated?0:1024,65535) + ||!integer(c.upstream_port,1024,65535)||c.upstream_host!==undefined + ||!/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,62}$/.test(c.server_name||'') + ||!/^[a-f0-9]{64}$/.test(c.client_fingerprint_sha256||'') + ||!integer(c.connection_limit,1,1024) + ||!integer(c.handshake_timeout_ms,100,10000)||!integer(c.connect_timeout_ms,100,10000) + ||!integer(c.idle_timeout_ms,1000,120000)||!integer(c.shutdown_timeout_ms,100,10000)) + throw new Error('PRIVATE_INGRESS_CONFIG_INVALID'); + return c; +} + +// Transport only: the loopback authorization server still owns Host/Origin, +// cookies, MFA, CSRF and OAuth policy. No identity headers are manufactured here. +export function createPrivateIngress(input,{isolated=false}={}){ + const config=validateIngressConfig(input,{isolated}),connections=new Set(); + const server=tls.createServer({key:readPrivate(config.key_file),cert:readPrivate(config.cert_file), + ca:readPrivate(config.ca_file),minVersion:'TLSv1.3',maxVersion:'TLSv1.3', + requestCert:true,rejectUnauthorized:true,handshakeTimeout:config.handshake_timeout_ms, + ALPNProtocols:['http/1.1']},socket=>{ + const fingerprint=socket.getPeerCertificate().fingerprint256?.replaceAll(':','').toLowerCase(); + if(!socket.authorized||socket.remoteAddress!==config.allowed_peer||socket.servername!==config.server_name + ||fingerprint!==config.client_fingerprint_sha256){socket.destroy();return;} + socket.pause(); + const upstream=net.createConnection({host:'127.0.0.1',port:config.upstream_port}); + const timer=setTimeout(()=>{socket.destroy();upstream.destroy();},config.connect_timeout_ms); + timer.unref(); + socket.setTimeout(config.idle_timeout_ms,()=>socket.destroy()); + upstream.setTimeout(config.idle_timeout_ms,()=>upstream.destroy()); + socket.on('error',()=>upstream.destroy());upstream.on('error',()=>socket.destroy()); + socket.on('close',()=>{clearTimeout(timer);upstream.destroy();}); + upstream.on('close',()=>{clearTimeout(timer);socket.destroy();}); + upstream.once('connect',()=>{clearTimeout(timer);socket.pipe(upstream);upstream.pipe(socket);socket.resume();}); + }); + server.on('connection',socket=>{ + if(socket.remoteAddress!==config.allowed_peer||connections.size>=config.connection_limit){socket.destroy();return;} + connections.add(socket);socket.once('close',()=>connections.delete(socket)); + }); + // Do not log peer certificates, request bytes, cookies or TLS error objects. + server.on('tlsClientError',()=>{}); + let closing; + const close=()=>closing??=new Promise(resolve=>{ + if(!server.listening){for(const socket of connections)socket.destroy();resolve();return;} + const timeout=setTimeout(()=>{for(const socket of connections)socket.destroy();},config.shutdown_timeout_ms); + timeout.unref();server.close(()=>{clearTimeout(timeout);resolve();}); + }); + return {server,config,close}; +} + +if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href){ + try{ + const ingress=createPrivateIngress(readPrivate(process.argv[2],{json:true})); + ingress.server.on('error',()=>{process.stderr.write('PRIVATE_INGRESS_UNAVAILABLE\n');process.exitCode=1;ingress.close();}); + ingress.server.listen(ingress.config.listen_port,ingress.config.listen_host); + for(const signal of ['SIGINT','SIGTERM'])process.once(signal,()=>{ingress.close();}); + }catch{process.stderr.write('PRIVATE_INGRESS_START_FAILED\n');process.exitCode=1;} +} diff --git a/services/oauth/src/process-lease.mjs b/services/oauth/src/process-lease.mjs new file mode 100644 index 0000000..666f12c --- /dev/null +++ b/services/oauth/src/process-lease.mjs @@ -0,0 +1,26 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import {privateDirectory} from '../../../shared/oauth-common.mjs'; + +// The migration command and server must acquire the same exclusive lease. +export function acquireAuthorizationLease(file) { + const lease=`${file}.process-lock`; + privateDirectory(path.dirname(file),{create:true}); + for(let attempt=0;attempt<2;attempt++) { + try { + const fd=fs.openSync(lease,'wx',0o600); + fs.writeFileSync(fd,String(process.pid));const inode=fs.fstatSync(fd).ino;fs.closeSync(fd); + return ()=>{if(fs.existsSync(lease)&&fs.lstatSync(lease).ino===inode)fs.unlinkSync(lease);}; + } catch(error) { + if(error.code!=='EEXIST')throw error; + const stat=fs.lstatSync(lease); + if(!stat.isFile()||stat.isSymbolicLink()||(stat.mode&0o077)!==0||process.getuid&&stat.uid!==process.getuid())throw new Error('Invalid authorization process lock'); + const pid=Number(fs.readFileSync(lease,'utf8')); + if(!Number.isSafeInteger(pid)||pid<=0)throw new Error('Invalid authorization process lock'); + try{process.kill(pid,0);throw new Error('Only one authorization process may own this database; stop the server before migration');} + catch(probe){if(probe.code!=='ESRCH')throw probe;} + if(fs.lstatSync(lease).ino===stat.ino)fs.unlinkSync(lease); + } + } + throw new Error('Authorization process lock unavailable'); +} diff --git a/services/oauth/src/provider.mjs b/services/oauth/src/provider.mjs index 950492e..f4bc46a 100644 --- a/services/oauth/src/provider.mjs +++ b/services/oauth/src/provider.mjs @@ -53,7 +53,10 @@ export function makeProvider(config, secrets, store, accounts) { }, }, rotateRefreshToken: true, revokeGrantPolicy: () => true, - extraTokenClaims: (_ctx, token) => token.kind === "AccessToken" ? { token_kind: "access_token" } : undefined, + extraTokenClaims: (_ctx, token) => token.kind === "AccessToken" ? { token_kind: "access_token", + ...(accounts.principal?{account_id:accounts.principal(token.accountId).account_id, + security_version:accounts.principal(token.accountId).security_version}:{}), + } : undefined, findAccount: async (_ctx, subject) => accounts.eligible(subject) ? { accountId: subject, claims: async () => ({ sub: subject }) } : undefined, interactions: { url: (_ctx, interaction) => `/interaction/${interaction.uid}` }, diff --git a/services/oauth/src/provisioning.mjs b/services/oauth/src/provisioning.mjs new file mode 100644 index 0000000..ebd0bb2 --- /dev/null +++ b/services/oauth/src/provisioning.mjs @@ -0,0 +1,62 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import {randomUUID} from 'node:crypto'; +import {randomSecret,secretHash,readPrivate,writePrivate,requireConfig,CORE_SCOPES} from '../../../shared/oauth-common.mjs'; +import {storageDoctor} from '../../../server/lib/storage-policy.mjs'; + +export function provisionIdentities(identities,core,{credentialDirectory,identityMapFile,afterCore=()=>{}}) { + storageDoctor({credential_directory:credentialDirectory,identity_map:identityMapFile}); + const db=identities.db; + const operations=db.prepare("SELECT * FROM identity_operations WHERE kind LIKE 'provision:%' AND state NOT IN ('completed','superseded') ORDER BY created,operation_id").all(); + const completed=[]; + for(const operation of operations) { + const a=identities.byId(operation.account_id); + if(a&&operation.kind!==`provision:${a.security_version}`){db.prepare("UPDATE identity_operations SET state='superseded' WHERE operation_id=?").run(operation.operation_id);continue;} + if(!a || !a.mfa_verified || !['provisioning','active'].includes(a.status))continue; + const prepared=identities.store.transaction(()=>{ + const row=db.prepare('SELECT * FROM identity_operations WHERE operation_id=?').get(operation.operation_id); + if(row.payload_cipher)return identities.unseal(row.payload_cipher,a.account_id,'provision'); + const bindings=['web','console'].map(purpose=>({purpose,credential_id:randomUUID(),api_key:`mnm_${randomSecret()}`, + user_id:a.user_id,agent_instance_id:`${purpose}-${a.account_id}`,agent_id:purpose==='web'?'chatgpt-web':'mnemuron-console', + scopes:purpose==='web'?[...CORE_SCOPES]:['memory:read','resume:read','console:read'], + credential_file:path.join(credentialDirectory,`${a.account_id}-${purpose}-v${a.security_version}.key`)})); + db.prepare("UPDATE identity_operations SET state='prepared',payload_cipher=? WHERE operation_id=?") + .run(identities.seal(bindings,a.account_id,'provision'),operation.operation_id); + return bindings; + }); + try { + // The durable operation is committed BEFORE Core. An uncertain completion reuses the same key/id. + core.memoryTransaction(()=>{ + for(const b of prepared) { + const existing=core.db.prepare('SELECT * FROM credentials WHERE credential_id=? OR key_hash=?').get(b.credential_id,secretHash(b.api_key)); + if(existing) { + requireConfig(existing.credential_id===b.credential_id&&existing.user_id===a.user_id&&existing.agent_instance_id===b.agent_instance_id + &&existing.agent_id===b.agent_id&&existing.key_hash===secretHash(b.api_key)&&!existing.revoked_at&&existing.scopes_json===JSON.stringify(b.scopes),'core provisioning conflict'); + } else core.db.prepare(`INSERT INTO credentials(credential_id,label,user_id,device_id,agent_id,agent_instance_id,key_hash,scopes_json,created_at) + VALUES(?,?,?,?,?,?,?,?,?)`).run(b.credential_id,'Account-bound read-only connection',a.user_id,'identity-service',b.agent_id,b.agent_instance_id,secretHash(b.api_key),JSON.stringify(b.scopes),new Date().toISOString()); + } + }); + afterCore(operation); + for(const b of prepared) { + const auth=core.authenticate(b.api_key); + requireConfig(auth.user_id===a.user_id&&auth.credential_id===b.credential_id&&auth.agent_instance_id===b.agent_instance_id,'authoritative core identity'); + if(fs.existsSync(b.credential_file))requireConfig(readPrivate(b.credential_file)===b.api_key,'credential file collision'); + else writePrivate(b.credential_file,b.api_key); + } + identities.finishProvision(a.account_id,prepared,operation.operation_id); + completed.push(a.account_id); + } catch(error) { + db.prepare("UPDATE identity_operations SET last_error='PROVISIONING_INCOMPLETE' WHERE operation_id=?").run(operation.operation_id); + throw error; + } + } + // Derived publication only: OAuth introspection remains authoritative for current activity/version. + identities.store.transaction(()=>{ + const mappings=db.prepare(`SELECT a.*,b.credential_id,b.agent_instance_id,b.credential_file FROM identity_accounts a + JOIN identity_bindings b ON a.account_id=b.account_id AND b.purpose='web' AND b.checked=1 WHERE a.binding_ready=1`).all().map(a=>({ + issuer:a.issuer,subject:a.subject,account_id:a.account_id,mnemuron_user_id:a.user_id,security_version:a.security_version, + enabled:a.status==='active',agent_instance_id:a.agent_instance_id,credential_id:a.credential_id,credential_file:a.credential_file})); + writePrivate(identityMapFile,{schema_version:'multi-account-identity-v1',unknown_subject_policy:'deny',mappings},{replace:fs.existsSync(identityMapFile)}); + }); + return {completed:completed.length,pending:db.prepare("SELECT COUNT(*) n FROM identity_operations WHERE kind LIKE 'provision:%' AND state NOT IN ('completed','superseded')").get().n}; +} diff --git a/services/oauth/src/recovery.mjs b/services/oauth/src/recovery.mjs new file mode 100644 index 0000000..9dce10b --- /dev/null +++ b/services/oauth/src/recovery.mjs @@ -0,0 +1,102 @@ +import {scrypt,randomUUID} from 'node:crypto'; +import {promisify} from 'node:util'; +import {generateSecret,generateURI,verify} from 'otplib'; +import {BoundaryError,secretHash,equalSecret,seconds} from '../../../shared/oauth-common.mjs'; +import {usernameKey,passwordRecord} from './identity-repository.mjs'; +const derive=promisify(scrypt); +const fail=()=>{throw new BoundaryError(400,'RECOVERY_UNAVAILABLE');}; + +// No constructor default approves a proof combination. HTTP/CLI remain blocked +// until an operator provides an independently approved policy integration. +export class RecoveryService { + constructor(identities,{policy=null}={}) {this.ids=identities;this.db=identities.db;this.policy=policy;} + proofs(action) { + const required=this.policy?.[action]; + if(!['password','totp'].includes(action)||!Array.isArray(required)||required.length!==2||!required.includes('recovery_code') + ||!required.includes(action==='password'?'totp':'password'))throw new BoundaryError(403,'BLOCKED_POLICY'); + return required; + } + async begin({username,action,password,otp,recoveryCode}) { + const required=this.proofs(action),key=usernameKey(username); + this.ids.store.limit(`recovery:${key}`,5,900); + const a=this.db.prepare('SELECT * FROM identity_accounts WHERE username_key=?').get(key); + if(!a||!this.ids.eligible(a.subject)||typeof recoveryCode!=='string'||recoveryCode.length>256)fail(); + const digest=secretHash(recoveryCode),hashes=JSON.parse(a.recovery_hashes); + if(!hashes.some(h=>equalSecret(h,digest)))fail(); + let epoch; + if(required.includes('password')) { + const p=JSON.parse(a.password_json),supplied=typeof password==='string'&&password.length<=1024?password:''; + const actual=(await derive(supplied,p.salt,64,{N:p.N,r:p.r,p:p.p,maxmem:128*1024*1024})).toString('base64url'); + if(!equalSecret(actual,p.hash))fail(); + } + if(required.includes('totp')) { + if(!/^\d{6}$/.test(otp||''))fail(); + const result=await verify({secret:this.ids.unseal(a.mfa_cipher,a.account_id,'totp'),token:otp,epochTolerance:30}); + if(!result.valid)fail();epoch=result.epoch; + } + return this.ids.store.transaction(()=>{ + const current=this.ids.byId(a.account_id); + if(!this.ids.eligible(a.subject)||current.security_version!==a.security_version||!JSON.parse(current.recovery_hashes).includes(digest))fail(); + if(epoch!==undefined&&!this.ids.store.consumeStep(a.subject,epoch))fail(); + this.db.prepare("UPDATE identity_accounts SET status='recovery_pending',security_version=security_version+1,recovery_hashes=? WHERE account_id=?") + .run(JSON.stringify(hashes.filter(h=>h!==digest)),a.account_id); + const s=this.ids.newSession('recovery',{accountId:a.account_id,ttl:600}); + this.db.prepare('INSERT INTO identity_recovery_claims VALUES(?,?,?)').run(a.account_id,digest,secretHash(s.token)); + const id=randomUUID(),payload={session_digest:secretHash(s.token),action,...(action==='totp'?{totp_secret:generateSecret()}:{}),prior_security_version:a.security_version}; + this.db.prepare('INSERT INTO identity_operations(operation_id,account_id,kind,state,payload_cipher,created) VALUES(?,?,?,?,?,?)') + .run(id,a.account_id,`recovery:${id}`,'proof_verified',this.ids.seal(payload,a.account_id,'recovery'),seconds()); + this.ids.audit(a.account_id,'recovery.proof_verified');return {token:s.token,csrf:s.csrf,restricted:true,action}; + }); + } + operation(token) { + const s=this.ids.session(token,'recovery'); + const rows=this.db.prepare("SELECT * FROM identity_operations WHERE account_id=? AND kind LIKE 'recovery:%'").all(s.account_id); + const op=rows.map(row=>({...row,payload:this.ids.unseal(row.payload_cipher,s.account_id,'recovery')})).find(row=>row.payload.session_digest===s.digest); + if(!op)fail();return op; + } + enrollment(token) { + const op=this.operation(token);if(op.payload.action!=='totp'||op.state!=='proof_verified')fail(); + const a=this.ids.byId(op.account_id);return {uri:generateURI({issuer:'Mnemuron',label:a.username,secret:op.payload.totp_secret}),secret:op.payload.totp_secret}; + } + async complete(token,{password,otp},{revokeCore}={}) { + let op=this.operation(token),a=this.ids.byId(op.account_id);this.proofs(op.payload.action); + if(op.state==='completed')return {status:a.status,login_required:true}; + if(typeof revokeCore!=='function')throw new BoundaryError(503,'REVOCATION_DEPENDENCY_REQUIRED'); + if(op.state==='proof_verified') { + let record,epoch; + if(op.payload.action==='password')record=await passwordRecord(password); + else { + if(!/^\d{6}$/.test(otp||''))fail();const v=await verify({secret:op.payload.totp_secret,token:otp,epochTolerance:30}); + if(!v.valid)fail();epoch=v.epoch; + } + this.ids.store.transaction(()=>{ + const current=this.operation(token);if(current.state!=='proof_verified')fail(); + if(record)this.db.prepare('UPDATE identity_accounts SET password_json=? WHERE account_id=?').run(JSON.stringify(record),a.account_id); + else { + if(!this.ids.store.consumeStep(a.subject,epoch))fail(); + this.db.prepare('UPDATE identity_accounts SET mfa_cipher=?,mfa_verified=1 WHERE account_id=?').run(this.ids.seal(op.payload.totp_secret,a.account_id,'totp'),a.account_id); + } + this.db.prepare("UPDATE identity_operations SET state='revocation_pending' WHERE operation_id=?").run(op.operation_id); + this.ids.audit(a.account_id,'recovery.credentials_prepared'); + }); + } + // A durable paused account invalidates introspection and console sessions even + // when the independent Core revocation is interrupted. No success is claimed. + try { + this.ids.store.revoke({subject:a.subject}); + const bindings=this.db.prepare('SELECT credential_id FROM identity_bindings WHERE account_id=?').all(a.account_id); + if(await revokeCore({user_id:a.user_id,credential_ids:bindings.map(b=>b.credential_id)})!==true)throw new Error('Core revocation unverified'); + }catch { + this.db.prepare("UPDATE identity_operations SET last_error='REVOCATION_INCOMPLETE' WHERE operation_id=?").run(op.operation_id); + throw new BoundaryError(503,'REVOCATION_INCOMPLETE'); + } + this.ids.store.transaction(()=>{ + this.operation(token); + this.db.prepare("UPDATE identity_accounts SET status='provisioning',binding_ready=0 WHERE account_id=? AND status='recovery_pending'").run(a.account_id); + this.ids.queueProvision(a.account_id); + this.db.prepare("UPDATE identity_operations SET state='completed',last_error=NULL WHERE operation_id=?").run(op.operation_id); + this.ids.audit(a.account_id,'recovery.completed'); + }); + return {status:'provisioning',login_required:true}; + } +} diff --git a/services/oauth/src/server.mjs b/services/oauth/src/server.mjs index fd30444..a10e0ce 100644 --- a/services/oauth/src/server.mjs +++ b/services/oauth/src/server.mjs @@ -1,39 +1,20 @@ import http from "node:http"; -import fs from "node:fs"; import { pathToFileURL } from "node:url"; import { randomUUID } from "node:crypto"; import { loadAuthConfig, validateAuthConfig, loadAuthSecrets } from "./config.mjs"; import { AuthStore } from "./sqlite-adapter.mjs"; import { Accounts } from "./accounts.mjs"; +import { IdentityRepository } from './identity-repository.mjs'; +import {consoleRequest} from './console.mjs'; +import {ConsoleCore} from './console-core.mjs'; +import {sendPage,label} from '../../../web/console/render.mjs'; import { makeProvider } from "./provider.mjs"; import { interactionRequest } from "./interactions.mjs"; import { BoundaryError, SerialGate, WindowLimit, OAUTH_SCOPES, parseForm, readBody, - requestBoundary, sendJson, equalSecret, privateDirectory } from "../../../shared/oauth-common.mjs"; -import path from "node:path"; - -function acquireLease(file) { - const lease = `${file}.process-lock`; - privateDirectory(path.dirname(file), { create: true }); - for (let attempt = 0; attempt < 2; attempt++) { - try { - const fd = fs.openSync(lease, "wx", 0o600); - fs.writeFileSync(fd, String(process.pid)); - const inode = fs.fstatSync(fd).ino; - fs.closeSync(fd); - return () => { if (fs.existsSync(lease) && fs.lstatSync(lease).ino === inode) fs.unlinkSync(lease); }; - } catch (error) { - if (error.code !== "EEXIST") throw error; - const stat = fs.lstatSync(lease); - if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Invalid authorization process lock"); - const pid = Number(fs.readFileSync(lease, "utf8")); - if (!Number.isInteger(pid) || pid <= 0) throw new Error("Invalid authorization process lock"); - try { process.kill(pid, 0); throw new Error("Only one authorization server may own this database"); } - catch (probe) { if (probe.code !== "ESRCH") throw probe; } - if (fs.lstatSync(lease).ino === stat.ino) fs.unlinkSync(lease); - } - } - throw new Error("Authorization process lock unavailable"); -} + requestBoundary, sendJson, equalSecret } from "../../../shared/oauth-common.mjs"; +import {acquireAuthorizationLease} from './process-lease.mjs'; +import {storageDoctor} from '../../../server/lib/storage-policy.mjs'; +import {invalidateBrowserAuthorization} from './browser-session.mjs'; function bootstrapMetadata(config) { return { issuer: config.issuer, authorization_endpoint: `${config.issuer}/authorize`, @@ -61,15 +42,22 @@ export function createAuthorizationServer(input, { isolated = false, logger = () let store, accounts, provider, secrets, release; try { if (config.mode === "oauth") { + if(config.identity_mode==='multi_account_v1') storageDoctor({ + identity_database:config.database_file,identity_key:config.identity.encryption_key_file, + }); secrets = loadAuthSecrets(config); - release = acquireLease(config.database_file); - store = new AuthStore(config.database_file); - accounts = new Accounts(config.accounts_file, store); - accounts.read(); + release = acquireAuthorizationLease(config.database_file); + store = new AuthStore(config.database_file,{identity:config.identity_mode==='multi_account_v1'}); + accounts = config.identity_mode==='multi_account_v1' ? new IdentityRepository(store,{ + keyFile:config.identity.encryption_key_file,issuer:config.issuer,batchLimit:config.identity.invitation_batch_limit, + sessionTtl:config.identity.console_session_ttl_seconds}) : new Accounts(config.accounts_file, store); + if(config.identity_mode==='legacy_owner') accounts.read(); + else store.identity=accounts; provider = makeProvider(config, secrets, store, accounts); } } catch (error) { store?.close(); release?.(); throw error; } const gate = new SerialGate(); + const consoleGate = new SerialGate(); const limits = new WindowLimit(); const callback = provider?.callback(); const origin = new URL(config.issuer); @@ -89,7 +77,8 @@ export function createAuthorizationServer(input, { isolated = false, logger = () const url = requestBoundary(request, origin, { isolated }); limits.take(`peer:${request.socket.remoteAddress}`, 1500); if (request.method === "GET" && ["/livez", "/readyz"].includes(url.pathname)) { - const ready = config.mode === "oauth" && store.ready({ writeProbe: url.pathname === "/readyz" }) && accounts.read().enabled; + const ready = config.mode === "oauth" && store.ready({ writeProbe: url.pathname === "/readyz" }) + && (config.identity_mode==='multi_account_v1' || accounts.read().enabled); return sendJson(response, url.pathname === "/livez" || ready ? 200 : 503, { service: "mnemuron-oauth", mode: config.mode, ready, production_ready: false }); } @@ -107,6 +96,10 @@ export function createAuthorizationServer(input, { isolated = false, logger = () request.url = "/.well-known/openid-configuration"; return callback(request, response); } + const handleConsole=()=>consoleRequest(request,response,{config,accounts,store,url, + invalidateAuthorization:nextSubject=>invalidateBrowserAuthorization(request,response,provider,{nextSubject}),coreFor:subject=>new ConsoleCore(config.identity?.core, + accounts.principal(subject),accounts.bindings(subject).find(b=>b.purpose==='console'))}); + if(await (request.method==='POST'&&/^\/(register|login)(\/|$)/.test(url.pathname)?consoleGate.run(handleConsole):handleConsole()))return; if (url.pathname.startsWith("/interaction/")) { return await gate.run(() => interactionRequest(request, response, { provider, store, accounts, config, url })); } @@ -163,6 +156,16 @@ export function createAuthorizationServer(input, { isolated = false, logger = () return await gate.run(() => callback(request, response)); } catch (error) { errorCode = error instanceof BoundaryError ? error.code : "AUTH_DEPENDENCY_UNAVAILABLE"; + const route=request.url.split('?')[0],interaction=route.match(/^\/interaction\/([A-Za-z0-9_-]{1,128})(?:\/(login|confirm|abort))?$/); + if(!response.headersSent&&config.identity_mode==='multi_account_v1'&&request.headers.accept?.includes('text/html') + && (/^\/(register|login|recover)(\/|$)/.test(route)||interaction)) { + const status=error instanceof BoundaryError?error.status:503; + const restart=['AUTHORIZATION_RESTART_REQUIRED','INTERACTION_EXPIRED','INTERACTION_MISMATCH'].includes(errorCode); + const message=restart?'restartAuthorization':errorCode==='LOGIN_FAILED'?'loginFailed':status===429?'rateLimited':status>=500?'unavailable':errorCode==='BLOCKED_POLICY'?'blockedNote':'pendingStep'; + const back=interaction&&!restart?`/interaction/${interaction[1]}`:route.startsWith('/register')?'/register':'/login'; + const navigation=interaction&&restart?label('oauthRestartHelp','p'):`${label(interaction?'oauthRetry':'back')}`; + sendPage(response,{title:'error',auth:true,authPurpose:interaction?'oauth':'console',body:`
    ${label(message,'p')}
    ${navigation}`},{status});return; + } if (!response.headersSent) sendJson(response, error instanceof BoundaryError ? error.status : 503, { error: error instanceof BoundaryError && error.status < 500 ? "invalid_request" : "temporarily_unavailable", error_code: error instanceof BoundaryError ? error.code : "AUTH_DEPENDENCY_UNAVAILABLE" }); diff --git a/services/oauth/src/sqlite-adapter.mjs b/services/oauth/src/sqlite-adapter.mjs index 4d8fe18..f7e0a66 100644 --- a/services/oauth/src/sqlite-adapter.mjs +++ b/services/oauth/src/sqlite-adapter.mjs @@ -4,7 +4,8 @@ import { DatabaseSync } from "node:sqlite"; import { privateDirectory, requireConfig, seconds, secretHash, randomSecret, BoundaryError } from "../../../shared/oauth-common.mjs"; export class AuthStore { - constructor(file) { + constructor(file, { identity = false } = {}) { + this.identityMode=identity; privateDirectory(path.dirname(file), { create: true }); if (!fs.existsSync(file)) fs.closeSync(fs.openSync(file, "wx", 0o600)); const stat = fs.lstatSync(file); @@ -13,6 +14,8 @@ export class AuthStore { this.db = new DatabaseSync(file); try { const tables = new Set(["oauth_records", "oauth_revoked_grants", "oauth_mfa_steps", "oauth_rate_limits", "oauth_csrf"]); + if (identity) for (const name of ['identity_accounts','identity_invitations','identity_sessions', + 'identity_bindings','identity_operations','identity_audit','identity_recovery_claims']) tables.add(name); const existing = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all(); requireConfig(existing.every(row => tables.has(row.name)), "separate OAuth database; unknown business tables"); this.db.exec(` @@ -36,11 +39,17 @@ export class AuthStore { } transaction(callback) { + this.assertCompatible(); this.db.exec("BEGIN IMMEDIATE"); try { const value = callback(); this.db.exec("COMMIT"); return value; } catch (error) { this.db.exec("ROLLBACK"); throw error; } } + assertCompatible() { + if(!this.identityMode&&this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='identity_accounts'").get()) + throw new Error('Incompatible legacy writer: database is multi-account'); + } + adapter() { const store = this; return class SQLiteAdapter { @@ -53,7 +62,8 @@ export class AuthStore { } const previous = store.db.prepare("SELECT payload FROM oauth_records WHERE model=? AND id=?").get(this.model, id); const consumed = previous && JSON.parse(previous.payload).consumed; - const data = consumed ? { ...payload, consumed } : payload; + const account = payload.accountId && store.identity?.account(payload.accountId); + const data = {...payload,...(consumed?{consumed}:{}),...(account?{securityVersion:payload.securityVersion??account.security_version}:{})}; store.db.prepare(`INSERT INTO oauth_records(model,id,payload,expires,grant_id,uid,user_code) VALUES(?,?,?,?,?,?,?) ON CONFLICT(model,id) DO UPDATE SET payload=excluded.payload, expires=excluded.expires,grant_id=excluded.grant_id,uid=excluded.uid,user_code=excluded.user_code`) @@ -65,12 +75,14 @@ export class AuthStore { async findByUid(uid) { return store.find(this.model, "uid", uid); } async findByUserCode(code) { return store.find(this.model, "user_code", code); } async consume(id) { + store.assertCompatible(); const changed = store.db.prepare(`UPDATE oauth_records SET payload=json_set(payload,'$.consumed',?) WHERE model=? AND id=? AND expires>? AND json_extract(payload,'$.consumed') IS NULL`) .run(seconds(), this.model, id, seconds()).changes; if (changed !== 1) throw new Error("Authorization artifact already consumed or expired"); } async destroy(id) { + store.assertCompatible(); if (this.model === "Grant") store.revokeGrant(id); else store.db.prepare("DELETE FROM oauth_records WHERE model=? AND id=?").run(this.model, id); } @@ -79,10 +91,17 @@ export class AuthStore { } find(model, field, value) { + this.assertCompatible(); if (!["id", "uid", "user_code"].includes(field)) throw new Error("Invalid index"); const row = this.db.prepare(`SELECT payload FROM oauth_records AS r WHERE model=? AND ${field}=? AND expires>? AND NOT EXISTS(SELECT 1 FROM oauth_revoked_grants WHERE id=r.grant_id)`).get(model, value, seconds()); - return row ? JSON.parse(row.payload) : undefined; + if(!row)return undefined; + const data=JSON.parse(row.payload); + if(this.identity && data.accountId) { + const account=this.identity.account(data.accountId); + if(!this.identity.eligible(data.accountId)||data.securityVersion!==account?.security_version)return undefined; + } + return data; } revokeGrant(id) { @@ -93,6 +112,7 @@ export class AuthStore { } revoke({ subject, clientId, all = false } = {}) { + this.assertCompatible(); if (!subject && !clientId && !all) throw new Error("A grant revocation selector is required"); const rows = this.db.prepare("SELECT id,payload FROM oauth_records WHERE model='Grant'").all(); let count = 0; @@ -103,12 +123,22 @@ export class AuthStore { count += 1; } } - // Clear browser state too: a fresh link must not reuse a prior login/consent after an operator reset. - this.db.exec("DELETE FROM oauth_records WHERE model IN ('Session','Interaction'); DELETE FROM oauth_csrf;"); + // Revoke only the selected subject/client, including unfinished interactions. + const browser = this.db.prepare("SELECT model,id,payload FROM oauth_records WHERE model IN ('Session','Interaction')").all(); + for (const row of browser) { + const data = JSON.parse(row.payload); + const account = data.accountId || data.session?.accountId || data.result?.login?.accountId; + const client = data.clientId || data.params?.client_id; + if (all || ((!subject || account === subject) && (!clientId || client === clientId || data.authorizations?.[clientId]))) { + this.db.prepare('DELETE FROM oauth_records WHERE model=? AND id=?').run(row.model,row.id); + this.db.prepare('DELETE FROM oauth_csrf WHERE uid=?').run(data.uid || row.id); + } + } return count; } consumeStep(subject, epoch) { + this.assertCompatible(); const changed = this.db.prepare(`INSERT INTO oauth_mfa_steps(subject,step) VALUES(?,?) ON CONFLICT(subject) DO UPDATE SET step=excluded.step WHERE step?") .run(uid, secretHash(token), seconds()).changes === 1; } cleanup() { + this.assertCompatible(); this.db.prepare("DELETE FROM oauth_records WHERE expires<=?").run(seconds()); this.db.prepare("DELETE FROM oauth_csrf WHERE expires<=?").run(seconds()); this.db.prepare("DELETE FROM oauth_rate_limits WHERE until<=?").run(seconds()); + if(this.identityMode)this.db.prepare('DELETE FROM identity_sessions WHERE expires<=?').run(seconds()); // Tombstones outlive the maximum grant/family lifetime, preventing delayed writes from reviving grants. this.db.prepare("DELETE FROM oauth_revoked_grants WHERE revoked_at? GROUP BY model").all(seconds()); } close() { this.db.close(); } diff --git a/services/oauth/test/auth-purpose.test.mjs b/services/oauth/test/auth-purpose.test.mjs new file mode 100644 index 0000000..2f47790 --- /dev/null +++ b/services/oauth/test/auth-purpose.test.mjs @@ -0,0 +1,40 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {consoleFixture} from './helpers/identity-fixture.mjs'; + +test('console and OAuth login clearly distinguish purpose without changing authentication',async t=>{ + const f=await consoleFixture(t); + const consolePage=await f.browser.request('/login'); + assert.equal(consolePage.status,200); + assert.match(consolePage.text,/data-i18n="consoleLogin"/); + assert.match(consolePage.text,/data-i18n="consoleLoginNote"/); + assert.match(consolePage.text,/
    /); + const params=new URLSearchParams({client_id:f.config.chatgpt_client.client_id, + redirect_uri:f.config.chatgpt_client.redirect_uris[0],response_type:'code',scope:'openid offline_access memory:read', + resource:f.config.resource,state:'synthetic-purpose-state',code_challenge_method:'S256', + code_challenge:createHash('sha256').update('synthetic-purpose-verifier'.repeat(3)).digest('base64url')}); + const start=await f.browser.request(`/authorize?${params}`); + assert.equal(start.status,303); + const page=await f.browser.request(start.headers.get('location')); + assert.equal(page.status,200); + assert.match(page.text,/data-i18n="oauthLogin"/); + assert.match(page.text,/data-i18n="oauthLoginNote"/); + assert.match(page.text,//); + for(const name of ['csrf','username','password','otp'])assert.ok(page.text.includes(`name="${name}"`)); + assert.ok(!page.text.includes('href="/login"')); + assert.ok(![...f.browser.cookies.keys()].some(k=>k.includes('_console'))); + assert.match(page.headers.get('content-security-policy'),/frame-ancestors 'none'/); +}); + +test('expired OAuth links explain how to restart, never redirect into console login',async t=>{ + const f=await consoleFixture(t); + const response=await f.browser.request('/interaction/synthetic-expired-link',{headers:{accept:'text/html'}}); + assert.equal(response.status,403); + assert.equal(response.headers.get('location'),null); + assert.match(response.text,/data-i18n="restartAuthorization"/); + assert.match(response.text,/data-i18n="oauthRestartHelp"/); + assert.ok(!response.text.includes('href="/login"')); + assert.ok(!/{const r=await browser.request(url);return {status:r.status,data:JSON.parse(r.text)};}; + +test('ISO-01..05 ISO-11..12 UI-07 INT-06: real console sessions isolate every read, source, summary and policy route',async t=>{ + const core=await memoryFixture(t),f=await consoleFixture(t,{core}),ids=f.app.accounts,owners=[]; + for(const name of ['Synthetic_Console_A','Synthetic_Console_B']) { + const a=await pendingAccount({identities:ids},name);ids.takeRecoveryCodes(a.session.token);ids.acknowledgeRecovery(a.session.token);owners.push({name,...a}); + } + provisionIdentities(ids,core.store,{credentialDirectory:path.join(f.directory,'keys'),identityMapFile:path.join(f.directory,'map.json')}); + const model=organizer(),jobs=new MemoryJobs(core.store); + for(const owner of owners) { + owner.user=ids.byId(owner.account.account_id).user_id;const writer=core.issue(owner.user,`writer-${owner.user}`);owner.auth=writer.auth; + owner.memory=core.store.saveMemory(writer.auth,{scope:'user',content:'Same synthetic console memory 😀 '.repeat(110)}).memory; + scheduleLibrary(core.store,jobs,{userId:owner.user,organizer:model,taxonomy,type:'summary',periods:['daily'],includeOpen:true}); + owner.browser=await login(f,owner); + } + await new MemoryWorker(core.store,jobs,model).drain(); + for(const [index,owner] of owners.entries()) { + const other=owners[1-index]; + for(const view of ['overview','memories','summaries','jobs','connections','audit','security','storage']) { + const r=await json(owner.browser,`/console-api/${view}`);assert.equal(r.status,200,view); + assert.ok(!JSON.stringify(r.data).includes(other.memory.memory_id),view);assert.ok(!JSON.stringify(r.data).includes(other.user),view); + } + for(const route of ['models','accounts','invitations','export','restore','../../v1/admin'])assert.notEqual((await json(owner.browser,`/console-api/${route}`)).status,200,route); + assert.equal((await json(owner.browser,`/console-api/memories?user_id=${other.user}`)).status,400); + const own=await json(owner.browser,`/console-api/memory?memory_id=${owner.memory.memory_id}&content_limit=64`);assert.equal(own.status,200);assert.equal(own.data.content_complete,false); + assert.equal((await json(other.browser,`/console-api/memory?${new URLSearchParams(own.data.next_request)}`)).status,404); + const summaries=await json(owner.browser,'/console-api/summaries'),summary=summaries.data.summaries[0]; + const detail=await json(owner.browser,`/console-api/summary?summary_id=${summary.summary_id}&revision=${summary.revision}`); + assert.equal(detail.status,200);assert.equal(detail.data.results[0].summary_id,summary.summary_id); + assert.ok(JSON.stringify(detail.data).includes(owner.memory.memory_id));assert.ok(!JSON.stringify(detail.data).includes(other.memory.memory_id)); + assert.equal((await json(other.browser,`/console-api/summary?summary_id=${summary.summary_id}`)).status,404); + core.store.retractMemory(owner.auth,owner.memory.memory_id); + assert.equal((await json(owner.browser,`/console-api/summary?summary_id=${summary.summary_id}&revision=${summary.revision}`)).status,409); + } +}); + +test('ISO-10 INT-05: delayed authenticated A read is rejected after logout while B continues',async t=>{ + const core=await memoryFixture(t),f=await consoleFixture(t,{core}),ids=f.app.accounts,owners=[]; + for(const name of ['Synthetic_Late_A','Synthetic_Late_B']) { + const a=await pendingAccount({identities:ids},name);ids.takeRecoveryCodes(a.session.token);ids.acknowledgeRecovery(a.session.token);owners.push({name,...a}); + } + provisionIdentities(ids,core.store,{credentialDirectory:path.join(f.directory,'keys'),identityMapFile:path.join(f.directory,'map.json')}); + for(const owner of owners){owner.user=ids.byId(owner.account.account_id).user_id;owner.memory=core.store.saveMemory(core.issue(owner.user,owner.name).auth,{scope:'user',content:'Synthetic delayed request marker'}).memory;owner.browser=await login(f,owner);} + const [a,b]=owners,original=ConsoleCore.prototype.view;let release,entered; + const held=new Promise(resolve=>{release=resolve;}),started=new Promise(resolve=>{entered=resolve;}); + ConsoleCore.prototype.view=async function(view,params){const data=await original.call(this,view,params);if(this.principal.user_id===a.user&&view==='memory'){entered();await held;}return data;}; + t.after(()=>{ConsoleCore.prototype.view=original;release();}); + const late=json(a.browser,`/console-api/memory?memory_id=${a.memory.memory_id}`);await started; + const me=await json(a.browser,'/console-api/me');assert.equal((await a.browser.post('/console-api/logout',{csrf:me.data.csrf})).status,303); + const own=await json(b.browser,'/console-api/memories');assert.equal(own.status,200);assert.deepEqual(own.data.results.map(m=>m.memory_id),[b.memory.memory_id]); + release();const denied=await late;assert.equal(denied.status,401);assert.ok(!JSON.stringify(denied.data).includes(a.memory.memory_id)); +}); diff --git a/services/oauth/test/console-ui.test.mjs b/services/oauth/test/console-ui.test.mjs new file mode 100644 index 0000000..01cc892 --- /dev/null +++ b/services/oauth/test/console-ui.test.mjs @@ -0,0 +1,57 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import {renderPage,routeTitle} from '../../../web/console/render.mjs'; +import {catalog} from '../../../web/console/catalog.mjs'; +import {SessionState} from '../../../web/console/session-state.mjs'; +test('UI-00 UI-01 INT-02: fixed geometry and full bilingual palette catalogue',()=>{ + const css=fs.readFileSync(new URL('../../../web/console/styles.css',import.meta.url),'utf8'); + assert.match(css,/--sidebar-width:232px/);assert.match(css,/--topbar-height:72px/); + assert.match(css,/--radius-card:16px/);assert.match(css,/prefers-reduced-motion/); + for(const theme of ['a','b','c'])for(const mode of ['light','dark'])assert.ok(css.includes(`[data-theme="${theme}"][data-mode="${mode}"]`)); + assert.deepEqual(Object.keys(catalog.en).sort(),Object.keys(catalog['zh-CN']).sort()); + for(const [selector,block] of [...css.matchAll(/(\[data-theme[^}]+)\{([^}]+)\}/g)].map(m=>[m[1],m[2]])) { + assert.ok(!/radius|font-size|padding|width|height|gap/.test(block),selector); + } +}); +test('ISO-10 INT-05: delayed A responses are discarded after account exit',()=>{ + const a=new SessionState('synthetic-account-A'),ticket=a.ticket();assert.ok(a.accepts(ticket)); + a.clear();a.account='synthetic-account-B';assert.equal(a.accepts(ticket),false);assert.ok(ticket.controller.signal.aborted); + const b=a.ticket();assert.ok(a.accepts(b));a.finish(b); +}); +test('UI-01 INT-04: auth shell loads local assets and keeps transaction form intact',()=>{ + const body='
    '; + const page=renderPage({title:'login',body,auth:true}); + assert.ok(page.includes(body));assert.match(page,/\/assets\/appearance.mjs/);assert.ok(!/https?:\/\//.test(page)); + assert.equal(routeTitle('/app/memories'),'memories');assert.equal(routeTitle('/authorize'),null); +}); +test('appearance controls wait for their local handlers before accepting input',()=>{ + const page=renderPage({title:'login',auth:true}); + assert.equal([...page.matchAll(/]*data-pref="[^"]+"[^>]*disabled/g)].length,3); + const script=fs.readFileSync(new URL('../../../web/console/appearance.mjs',import.meta.url),'utf8'); + assert.match(script,/node\.disabled=false/); +}); +test('UI-01 UI-04: shell chrome is translatable and the skip link has a target',()=>{ + for(const auth of [true,false]) { + const page=renderPage({title:'login',auth}); + assert.match(page,/data-i18n="systemLabel"/);assert.match(page,/id="main"/); + assert.match(page,/class="skip-link"[^>]*data-i18n="continue"/); + } +}); +test('six palettes keep small body, navigation and action text at 4.5:1 contrast',()=>{ + const css=fs.readFileSync(new URL('../../../web/console/styles.css',import.meta.url),'utf8'); + assert.match(css,/button:not\(\.primary\):not\(:disabled\),input,select\{border-color:var\(--muted\)\}/); + const luminance=color=>{ + const v=color.match(/[0-9a-f]{2}/ig).map(x=>parseInt(x,16)/255).map(c=>c<=0.04045?c/12.92:((c+0.055)/1.055)**2.4); + return v[0]*0.2126+v[1]*0.7152+v[2]*0.0722; + }; + const palettes=[...css.matchAll(/\[data-theme="([abc])"\]\[data-mode="(light|dark)"\]\{([^}]+)\}/g)]; + assert.equal(palettes.length,6); + for(const palette of palettes){ + const colors=Object.fromEntries([...palette[3].matchAll(/--([a-z-]+):(#\w+)/g)].map(m=>[m[1],m[2]])); + for(const [foreground,background] of [['text','surface'],['muted','surface'],['muted','bg'],['accent','soft'],['on-accent','accent']]){ + const a=luminance(colors[foreground]),b=luminance(colors[background]),ratio=(Math.max(a,b)+0.05)/(Math.min(a,b)+0.05); + assert.ok(ratio>=4.5,`${palette[1]} ${palette[2]} ${foreground}/${background}: ${ratio}`); + } + } +}); diff --git a/services/oauth/test/helpers/console-preview.mjs b/services/oauth/test/helpers/console-preview.mjs new file mode 100644 index 0000000..c1b22cc --- /dev/null +++ b/services/oauth/test/helpers/console-preview.mjs @@ -0,0 +1,66 @@ +// Isolated manual UI fixture: actual authentication/BFF, synthetic database, loopback only. +import path from 'node:path'; +import readline from 'node:readline'; +import {createHash} from 'node:crypto'; +import {generate} from 'otplib'; +import {consoleFixture,pendingAccount} from './identity-fixture.mjs'; +import {memoryFixture} from '../../../../server/test/helpers/core-memory-fixture.mjs'; +import {organizer,taxonomy} from '../../../../server/test/helpers/memory-models.mjs'; +import {MemoryJobs} from '../../../../server/lib/memory-jobs/store.mjs'; +import {MemoryWorker,scheduleLibrary} from '../../../../server/lib/memory-jobs/worker.mjs'; +import {provisionIdentities} from '../../src/provisioning.mjs'; +import {ConsoleCore} from '../../src/console-core.mjs'; +import {randomSecret} from '../../../../shared/oauth-common.mjs'; +const cleanup=[],t={after:fn=>cleanup.push(fn)}; +const core=await memoryFixture(t),f=await consoleFixture(t,{core}),ids=f.app.accounts; +const owner=await pendingAccount({identities:ids},'Synthetic_Visual'); +ids.takeRecoveryCodes(owner.session.token);ids.acknowledgeRecovery(owner.session.token); +const second=await pendingAccount({identities:ids},'Synthetic_Visual_B'); +ids.takeRecoveryCodes(second.session.token);ids.acknowledgeRecovery(second.session.token); +provisionIdentities(ids,core.store,{credentialDirectory:path.join(f.directory,'keys'),identityMapFile:path.join(f.directory,'map.json')}); +const bWriter=core.issue(ids.byId(second.account.account_id).user_id,'visual-synthetic-b-writer'); +core.store.saveMemory(bWriter.auth,{scope:'user',content:'Only-B synthetic sentinel; the A detail must never appear here.'}); +const writer=core.issue(ids.byId(owner.account.account_id).user_id,'visual-synthetic-writer'); +for(const content of [ + '合成验证记录:蓝色纸船停在测试码头。仅用于隔离界面验收,不是个人记忆。', + 'Synthetic UI acceptance: authorization is read-only. Registration is local and invite-gated.', + '多语言分页合成记录 😀:来源、版本与原文保持对应。'.repeat(120), + '合成决定:未批准的操作保持关闭,不调用真实付费模型。', + 'LongSyntheticIdentifierForDesktopLayoutAndKeyboardAccessibilityWithoutAnyPersonalInformation'.repeat(6), +])core.store.saveMemory(writer.auth,{scope:'user',content}); +const model=organizer(),jobs=new MemoryJobs(core.store); +for(const type of ['classification','summary']){ + scheduleLibrary(core.store,jobs,{userId:writer.auth.user_id,organizer:model,taxonomy,type,periods:['daily'],includeOpen:true}); + await new MemoryWorker(core.store,jobs,model).drain(); +} +console.log(JSON.stringify({url:f.config.issuer+'/login',username:'Synthetic_Visual',password:'Synthetic password with spaces ',fixture:true,production_ready:false})); +let hold=false,release; +const coreView=ConsoleCore.prototype.view; +ConsoleCore.prototype.view=async function(view,params){ + const result=await coreView.call(this,view,params); + if(hold&&view==='memory'&&this.principal.user_id===writer.auth.user_id){ + console.log(JSON.stringify({synthetic_detail_held:true}));await new Promise(resolve=>{release=resolve;}); + } + return result; +}; +console.log('Commands: otp, otp-b, authorize, hold-detail, release-detail, core-off/core-on, stop. Synthetic loopback fixture only.'); +const lines=readline.createInterface({input:process.stdin}); +async function close(){hold=false;release?.();ConsoleCore.prototype.view=coreView;lines.close();for(const fn of cleanup.reverse())await fn();process.exit(0);} +lines.on('line',async line=>{ + if(line==='otp')console.log(JSON.stringify({synthetic_otp:await generate({secret:owner.setup.secret})})); + if(line==='otp-b')console.log(JSON.stringify({synthetic_otp:await generate({secret:second.setup.secret})})); + if(line==='authorize') { + const params={client_id:f.config.chatgpt_client.client_id,redirect_uri:f.config.chatgpt_client.redirect_uris[0],response_type:'code', + scope:'openid offline_access memory:read project:read',resource:f.config.resource,state:randomSecret(),code_challenge_method:'S256', + code_challenge:createHash('sha256').update(randomSecret()).digest('base64url')}; + console.log(JSON.stringify({synthetic_authorize_url:f.config.issuer+'/authorize?'+new URLSearchParams(params)})); + } + if(line==='hold-detail'){hold=true;console.log(JSON.stringify({synthetic_hold_enabled:true}));} + if(line==='release-detail'){hold=false;release?.();console.log(JSON.stringify({synthetic_detail_released:true}));} + if(line==='core-off'||line==='core-on') { + f.app.config.identity.core.base_url=line==='core-off'?'http://127.0.0.1:1':core.baseUrl; + console.log(JSON.stringify({synthetic_core_available:line==='core-on'})); + } + if(line==='stop')await close(); +}); +for(const signal of ['SIGINT','SIGTERM'])process.once(signal,close); diff --git a/services/oauth/test/helpers/identity-fixture.mjs b/services/oauth/test/helpers/identity-fixture.mjs new file mode 100644 index 0000000..2d96e71 --- /dev/null +++ b/services/oauth/test/helpers/identity-fixture.mjs @@ -0,0 +1,30 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {generate} from 'otplib'; +import {AuthStore} from '../../src/sqlite-adapter.mjs'; +import {IdentityRepository} from '../../src/identity-repository.mjs'; +import {randomSecret,writePrivate,seconds} from '../../../../shared/oauth-common.mjs'; +import {fixture} from '../fixture.mjs'; +export function identityFixture(t) { + const directory=fs.mkdtempSync(path.join(os.tmpdir(),'mnemuron-identity-test-'));fs.chmodSync(directory,0o700); + const keyFile=path.join(directory,'key');writePrivate(keyFile,randomSecret()); + const store=new AuthStore(path.join(directory,'oauth.sqlite3'),{identity:true}); + const identities=new IdentityRepository(store,{keyFile,issuer:'http://127.0.0.1:49001',batchLimit:10,sessionTtl:3600}); + t.after(()=>{store.close();fs.rmSync(directory,{recursive:true,force:true});});return {directory,keyFile,store,identities}; +} +export async function pendingAccount(f,name='Synthetic_A') { + const [code]=f.identities.issueInvitations({count:1,ttlMinutes:10,issuer:'synthetic-operator'}).codes; + const session=f.identities.reserveInvitation(code); + await f.identities.prepareRegistration(session.token,name,'Synthetic password with spaces '); + const setup=f.identities.enrollment(session.token); + await f.identities.verifyEnrollment(session.token,await generate({secret:setup.secret,epoch:seconds()-30})); + return {session,setup,account:f.identities.registrationState(session.token)}; +} +export async function consoleFixture(t,{core}={}) { + const f=await fixture(t,{start:false,mutate:c=>{ + c.identity_mode='multi_account_v1';c.login.registration_enabled=true; + c.identity={encryption_key_file:path.join(path.dirname(c.database_file),'identity-key'),invitation_batch_limit:10,console_session_ttl_seconds:3600,...(core?{core:{base_url:core.baseUrl}}:{})}; + }}); + writePrivate(f.config.identity.encryption_key_file,randomSecret());await f.start();return f; +} diff --git a/services/oauth/test/identity-boundaries.test.mjs b/services/oauth/test/identity-boundaries.test.mjs new file mode 100644 index 0000000..7da96a6 --- /dev/null +++ b/services/oauth/test/identity-boundaries.test.mjs @@ -0,0 +1,88 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import path from 'node:path'; +import fs from 'node:fs'; +import {generate} from 'otplib'; +import {identityFixture} from './helpers/identity-fixture.mjs'; +import {seconds,secretHash} from '../../../shared/oauth-common.mjs'; +import {fixture} from './fixture.mjs'; +import {createAuthorizationServer} from '../src/server.mjs'; + +async function prepared(f,name='Synthetic_A') { + const issued=f.identities.issueInvitations({count:1,ttlMinutes:10,issuer:'synthetic-operator'}); + const session=f.identities.reserveInvitation(issued.codes[0]); + await f.identities.prepareRegistration(session.token,name,'Synthetic password with spaces '); + return {issued,session,setup:f.identities.enrollment(session.token)}; +} +function finishInProcess(f,session,otp) { + const script=`import {AuthStore} from ${JSON.stringify(new URL('../src/sqlite-adapter.mjs',import.meta.url).href)}; + import {IdentityRepository} from ${JSON.stringify(new URL('../src/identity-repository.mjs',import.meta.url).href)}; + let input='';for await(const chunk of process.stdin)input+=chunk;const p=JSON.parse(input); + const store=new AuthStore(p.file,{identity:true});try { + const ids=new IdentityRepository(store,{keyFile:p.keyFile,issuer:p.issuer,batchLimit:10,sessionTtl:3600}); + console.log(JSON.stringify(await ids.verifyEnrollment(p.token,p.otp))); + } catch(e){console.log(JSON.stringify({error:e.code||'REFUSED'}));} finally{store.close();}`; + return new Promise((resolve,reject)=>{ + const child=spawn(process.execPath,['--input-type=module','-e',script],{stdio:['pipe','pipe','pipe']});let out=''; + child.stdout.on('data',c=>out+=c);child.on('error',reject); + child.on('close',code=>code===0?resolve(JSON.parse(out)):reject(new Error('Synthetic child failed'))); + child.stdin.end(JSON.stringify({file:path.join(f.directory,'oauth.sqlite3'),keyFile:f.keyFile,issuer:f.identities.issuer,token:session.token,otp})); + }); +} + +test('INV-08/14: concurrent processes and lost completion response consume once and return the same account',async t=>{ + const f=identityFixture(t),p=await prepared(f),otp=await generate({secret:p.setup.secret,epoch:seconds()-30}); + const replies=await Promise.all([finishInProcess(f,p.session,otp),finishInProcess(f,p.session,otp)]); + assert.ok(replies.every(r=>r.account_id===replies[0].account_id && r.status==='provisioning'),JSON.stringify(replies)); + const retry=await f.identities.verifyEnrollment(p.session.token,otp); + assert.equal(retry.account_id,replies[0].account_id); + assert.equal(f.store.db.prepare('SELECT COUNT(*) n FROM identity_accounts').get().n,1); + assert.equal(f.store.db.prepare("SELECT COUNT(*) n FROM identity_audit WHERE action='registration.mfa_verified'").get().n,1); + assert.equal(f.store.db.prepare("SELECT COUNT(*) n FROM identity_invitations WHERE state='consumed'").get().n,1); + const codes=f.identities.takeRecoveryCodes(p.session.token);assert.equal(codes.length,8); + assert.throws(()=>f.identities.takeRecoveryCodes(p.session.token)); +}); +test('INV-10/11: commit rechecks expiry and an abandoned unverified reservation can be reclaimed without inheriting identity',async t=>{ + const f=identityFixture(t),p=await prepared(f),otp=await generate({secret:p.setup.secret,epoch:seconds()-30}); + const pending=f.identities.verifyEnrollment(p.session.token,otp); + f.store.db.prepare("UPDATE identity_invitations SET reserved_until=? WHERE digest=?").run(seconds()-1,secretHash(p.issued.codes[0])); + await assert.rejects(pending); + assert.equal(f.identities.eligible(p.setup.subject),false); + const replacement=f.identities.reserveInvitation(p.issued.codes[0]); + await f.identities.prepareRegistration(replacement.token,'Synthetic_A','Another synthetic password'); + const next=f.identities.enrollment(replacement.token); + assert.notEqual(next.subject,p.setup.subject);assert.notEqual(next.secret,p.setup.secret); + assert.equal(f.identities.account(p.setup.subject).status,'registration_expired'); + assert.throws(()=>f.identities.registrationState(p.session.token)); +}); +test('MFA-04..06/10: local enrollment display is one-time and pending accounts cannot exchange factors',async t=>{ + const f=identityFixture(t),a=await prepared(f),b=await prepared(f,'Synthetic_B'); + assert.equal(f.identities.enrollment(a.session.token).already_shown,true); + for(const token of ['000000',await generate({secret:a.setup.secret,epoch:seconds()-120}),await generate({secret:b.setup.secret})]) + await assert.rejects(()=>f.identities.verifyEnrollment(a.session.token,token)); + const result=await f.identities.verifyEnrollment(a.session.token,await generate({secret:a.setup.secret,epoch:seconds()-30})); + assert.equal(result.status,'provisioning');assert.throws(()=>f.identities.enrollment(a.session.token)); + assert.equal(f.identities.account(b.setup.subject).mfa_verified,0); +}); +test('INV-13/15 MFA-12: duplicate normalized username, disabled role input, revoked batch and immutable consumed history',async t=>{ + const f=identityFixture(t),a=await prepared(f); + const issue=f.identities.issueInvitations({count:2,ttlMinutes:1,issuer:'synthetic-operator'}),b=f.identities.reserveInvitation(issue.codes[0]); + await assert.rejects(()=>f.identities.prepareRegistration(b.token,'synthetic_a','Synthetic password with spaces ')); + await assert.rejects(()=>f.identities.prepareRegistration(b.token,'模拟用户','Synthetic password with spaces ')); + await assert.rejects(()=>f.identities.prepareRegistration(b.token,'Synthetic_B','too short')); + await f.identities.verifyEnrollment(a.session.token,await generate({secret:a.setup.secret,epoch:seconds()-30})); + assert.equal(f.identities.revokeBatch(a.issued.batch_id),0); + assert.equal(f.identities.revokeBatch(issue.batch_id),2); + assert.throws(()=>f.identities.reserveInvitation(issue.codes[1])); + assert.equal(f.identities.registrationState(a.session.token).status,'provisioning'); +}); +test('multi-account startup rejects an in-worktree encryption key before opening its database',async t=>{ + const f=await fixture(t,{start:false}); + f.config.identity_mode='multi_account_v1'; + f.config.database_file=path.join(f.directory,'unopened-identity.sqlite3'); + f.config.identity={encryption_key_file:path.resolve('never-created-identity-key')}; + assert.throws(()=>createAuthorizationServer(f.config,{isolated:true}),{errorCode:'PRIVATE_PATH_IN_SOURCE'}); + assert.equal(fs.existsSync(f.config.database_file),false); + assert.equal(fs.existsSync(f.config.database_file+'.process-lock'),false); +}); diff --git a/services/oauth/test/identity-cli-migration.test.mjs b/services/oauth/test/identity-cli-migration.test.mjs new file mode 100644 index 0000000..0cf0abe --- /dev/null +++ b/services/oauth/test/identity-cli-migration.test.mjs @@ -0,0 +1,79 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import {spawnSync} from 'node:child_process'; +import {backup} from 'node:sqlite'; +import {fixture} from './fixture.mjs'; +import {AuthStore} from '../src/sqlite-adapter.mjs'; +import {IdentityRepository} from '../src/identity-repository.mjs'; +import {acquireAuthorizationLease} from '../src/process-lease.mjs'; +import {MnemuronStore} from '../../../server/lib/store.mjs'; +import {memoryFixture,businessSnapshot} from '../../../server/test/helpers/core-memory-fixture.mjs'; +import {pendingAccount} from './helpers/identity-fixture.mjs'; +import {readPrivate,writePrivate,randomSecret} from '../../../shared/oauth-common.mjs'; + +function multiConfig(f) { + const config={...f.config,identity_mode:'multi_account_v1',identity:{encryption_key_file:path.join(f.directory,'identity-key'),invitation_batch_limit:3,console_session_ttl_seconds:600}}; + writePrivate(config.identity.encryption_key_file,randomSecret()); + const file=path.join(f.directory,'multi.json');writePrivate(file,config);return {config,file}; +} +const cli=(file,...args)=>spawnSync(process.execPath,['services/oauth/bin/identity.mjs',args[0],'--config',file,'--isolated-fixture',...args.slice(1)],{encoding:'utf8',timeout:15000}); + +test('INV-01..07 INV-13: actual CLI has strict arguments and private one-time output',async t=>{ + const f=await fixture(t,{start:false}),{file,config}=multiConfig(f),output=path.join(f.directory,'batch.json'); + const args=['invite-issue','--count','3','--ttl-minutes','1440','--issuer','synthetic-operator','--output',output]; + const created=cli(file,...args);assert.equal(created.status,0,created.stderr); + const batch=readPrivate(output,{json:true});assert.equal(batch.codes.length,3); + assert.equal(fs.statSync(output).mode&0o777,0o600);assert.ok(batch.codes.every(code=>!created.stdout.includes(code)&&!created.stderr.includes(code))); + assert.notEqual(cli(file,...args).status,0,'no overwrite'); + for(const [count,ttl] of [['0','1'],['4','1'],['1','1.5'],['1','0'],['1','1441']]) + assert.notEqual(cli(file,'invite-issue','--count',count,'--ttl-minutes',ttl,'--issuer','synthetic','--output',path.join(f.directory,'invalid.json')).status,0); + assert.equal(fs.existsSync(path.join(f.directory,'invalid.json')),false); + assert.notEqual(cli(file,'status','--role','admin').status,0,'unknown options are not silently ignored'); + assert.notEqual(cli(file,'invite-issue','--count','1','--ttl-minutes','1','--issuer','synthetic','--output',path.resolve('synthetic-invite-MUST-NOT-EXIST.json')).status,0); + assert.equal(fs.existsSync(path.resolve('synthetic-invite-MUST-NOT-EXIST.json')),false); + const list=cli(file,'invite-list');assert.equal(list.status,0);assert.ok(batch.codes.every(code=>!list.stdout.includes(code))); + assert.equal(cli(file,'invite-revoke','--batch-id',batch.batch_id,'--confirm').status,0); + const store=new AuthStore(config.database_file,{identity:true});t.after(()=>store.close()); + const ids=new IdentityRepository(store,{issuer:config.issuer,keyFile:config.identity.encryption_key_file,batchLimit:3,sessionTtl:600}); + assert.equal(ids.listInvitations().length,3);assert.ok(ids.listInvitations().every(i=>i.state==='revoked')); +}); + +test('BASE-03..07: nonempty migration, repeat, exclusive lease and isolated restore preserve old and new data',async t=>{ + const f=await fixture(t),core=await memoryFixture(t); + const authorization=await f.authorize();assert.equal((await f.exchange(authorization)).status,200);await f.stop(); + const memory=core.store.saveMemory(core.a.auth,{scope:'user',content:'Synthetic legacy record and provenance',operation_id:'legacy-operation'}).memory; + const before=businessSnapshot(core.store),sourceBefore=core.store.db.prepare('SELECT * FROM memory_sources ORDER BY rowid').all(),revisionBefore=core.store.db.prepare('SELECT * FROM memory_revisions ORDER BY rowid').all(); + const owner=readPrivate(f.config.accounts_file,{json:true}),ownerBytes=fs.readFileSync(f.config.accounts_file); + const {file,config}=multiConfig(f),mapping=path.join(f.directory,'legacy-map.json'); + writePrivate(mapping,{mappings:[{issuer:config.issuer,subject:owner.subject,mnemuron_user_id:core.a.auth.user_id,agent_instance_id:'legacy-web',enabled:true}]}); + const old=new AuthStore(config.database_file),records=old.db.prepare('SELECT * FROM oauth_records ORDER BY model,id').all();t.after(()=>old.close()); + const release=acquireAuthorizationLease(config.database_file); + try {assert.notEqual(cli(file,'migrate-owner','--legacy-file',f.config.accounts_file,'--mapping-file',mapping,'--confirm').status,0);} + finally {release();} + const migrate=()=>cli(file,'migrate-owner','--legacy-file',f.config.accounts_file,'--mapping-file',mapping,'--confirm'); + const first=migrate();assert.equal(first.status,0,first.stderr);const second=migrate();assert.equal(second.status,0,second.stderr); + assert.equal(JSON.parse(first.stdout).account_id,JSON.parse(second.stdout).account_id); + assert.throws(()=>old.csrf('legacy-writer',60),/incompatible|multi.account/i); + assert.equal(cli(file,'provision','--core-database',core.databasePath,'--credential-directory',path.join(f.directory,'keys'),'--identity-map',path.join(f.directory,'map.json'),'--confirm').status,0); + const store=new AuthStore(config.database_file,{identity:true});t.after(()=>store.close()); + const ids=new IdentityRepository(store,{issuer:config.issuer,keyFile:config.identity.encryption_key_file,batchLimit:3,sessionTtl:600}); + assert.equal(ids.principal(owner.subject).user_id,core.a.auth.user_id);assert.deepEqual(fs.readFileSync(f.config.accounts_file),ownerBytes); + assert.deepEqual(businessSnapshot(core.store),before);assert.deepEqual(core.store.db.prepare('SELECT * FROM memory_sources ORDER BY rowid').all(),sourceBefore); + assert.deepEqual(core.store.db.prepare('SELECT * FROM memory_revisions ORDER BY rowid').all(),revisionBefore); + assert.deepEqual(store.db.prepare('SELECT * FROM oauth_records ORDER BY model,id').all(),records); + const restoredDirectory=path.join(f.directory,'isolated-restored-copy');fs.mkdirSync(restoredDirectory,{mode:0o700}); + const authBackup=path.join(restoredDirectory,'auth.sqlite3'),coreBackup=path.join(restoredDirectory,'core.sqlite3'); + await backup(store.db,authBackup);await backup(core.store.db,coreBackup);fs.chmodSync(authBackup,0o600);fs.chmodSync(coreBackup,0o600); + const added=await pendingAccount({identities:ids},'Synthetic_After_Backup'); + const addedMemory=core.store.saveMemory(core.a.auth,{scope:'user',content:'Synthetic post-backup memory must survive'}).memory; + const restoredAuth=new AuthStore(authBackup,{identity:true}),restoredCore=new MnemuronStore(coreBackup); + try { + const restoredIds=new IdentityRepository(restoredAuth,{issuer:config.issuer,keyFile:config.identity.encryption_key_file,batchLimit:3,sessionTtl:600}); + assert.equal(restoredIds.account(owner.subject).user_id,core.a.auth.user_id);assert.equal(restoredIds.byId(added.account.account_id),undefined); + assert.deepEqual(businessSnapshot(restoredCore),before);assert.equal(restoredCore.db.prepare('SELECT memory_id FROM memories WHERE memory_id=?').get(memory.memory_id).memory_id,memory.memory_id); + assert.equal(fs.statSync(authBackup).mode&0o777,0o600); + } finally {restoredAuth.close();restoredCore.close();} + assert.ok(ids.byId(added.account.account_id));assert.ok(core.store.db.prepare('SELECT 1 FROM memories WHERE memory_id=?').get(addedMemory.memory_id)); +}); diff --git a/services/oauth/test/identity-repository.test.mjs b/services/oauth/test/identity-repository.test.mjs new file mode 100644 index 0000000..f7d851b --- /dev/null +++ b/services/oauth/test/identity-repository.test.mjs @@ -0,0 +1,72 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {generate} from 'otplib'; +import {AuthStore} from '../src/sqlite-adapter.mjs'; +import {IdentityRepository} from '../src/identity-repository.mjs'; +import {Accounts,createOwner} from '../src/accounts.mjs'; +import {randomSecret,writePrivate,readPrivate,seconds} from '../../../shared/oauth-common.mjs'; + +import {identityFixture,pendingAccount} from './helpers/identity-fixture.mjs'; + +test('INV-01..06: strict TTL/count validation and metadata-only invitations',t=>{ + const f=identityFixture(t); + for(const ttl of [0,-1,1441,1.5,NaN,Infinity]) assert.throws(()=>f.identities.issueInvitations({count:1,ttlMinutes:ttl,issuer:'test'})); + for(const count of [0,-1,1.5,11]) assert.throws(()=>f.identities.issueInvitations({count,ttlMinutes:1,issuer:'test'})); + assert.equal(f.identities.listInvitations().length,0); + const a=f.identities.issueInvitations({count:10,ttlMinutes:1,issuer:'test'}); + const b=f.identities.issueInvitations({count:1,ttlMinutes:1440,issuer:'test'}); + assert.equal(new Set([...a.codes,...b.codes]).size,11); + assert.ok(a.codes.every(c=>/^[A-Za-z0-9_-]{43}$/.test(c))); + const dump=JSON.stringify(f.identities.listInvitations());assert.ok(a.codes.every(c=>!dump.includes(c))); + assert.equal(f.identities.listInvitations()[0].expires-f.identities.listInvitations()[0].created,60); +}); +test('INV-09..13, MFA-02..05: pending MFA, reservation ownership, replay and expiry',async t=>{ + const f=identityFixture(t);const r=f.identities.issueInvitations({count:2,ttlMinutes:1,issuer:'test'}); + const s=f.identities.reserveInvitation(r.codes[0]);assert.throws(()=>f.identities.reserveInvitation(r.codes[0])); + await f.identities.prepareRegistration(s.token,'Synthetic_A','Synthetic password with spaces '); + const setup=f.identities.enrollment(s.token);assert.equal(f.identities.eligible(setup.subject),false); + assert.equal(await f.identities.authenticate('Synthetic_A','Synthetic password with spaces ','000000'),null); + await assert.rejects(()=>f.identities.verifyEnrollment('wrong-session','123456')); + f.store.db.prepare('UPDATE identity_invitations SET expires=?').run(seconds()-1); + await assert.rejects(()=>f.identities.verifyEnrollment(s.token,generate({secret:setup.secret}))); + assert.equal(f.identities.eligible(setup.subject),false); + assert.throws(()=>f.identities.reserveInvitation(r.codes[1])); +}); +test('MFA-07..12, INV-14: encrypted secrets, pending provisioning and recovery acknowledgement',async t=>{ + const f=identityFixture(t);const {session,setup,account}=await pendingAccount(f); + assert.equal(account.status,'provisioning');assert.equal(f.identities.eligible(setup.subject),false); + const row=f.store.db.prepare('SELECT * FROM identity_accounts').get(); + assert.ok(!JSON.stringify(row).includes(setup.secret));assert.ok(!JSON.stringify(row).includes('Synthetic password')); + assert.throws(()=>f.identities.enrollment(session.token)); + const codes=f.identities.takeRecoveryCodes(session.token);assert.equal(codes.length,8); + assert.throws(()=>f.identities.takeRecoveryCodes(session.token)); + f.identities.acknowledgeRecovery(session.token); + assert.equal(f.identities.registrationState(session.token).status,'provisioning'); + assert.equal(f.identities.registrationState(session.token).account_id,account.account_id); +}); +test('BASE-03..06: repeatable legacy migration preserves subject, hashes and Core binding',async t=>{ + const f=identityFixture(t);const file=path.join(f.directory,'legacy.json'); + await createOwner(file,'Legacy_Name','Synthetic legacy password');const owner=readPrivate(file,{json:true}); + owner.mfa.verified=true;writePrivate(file,owner,{replace:true}); + const mapping={issuer:f.identities.issuer,subject:owner.subject,mnemuron_user_id:'existing-user',agent_instance_id:'existing-web',enabled:true}; + const first=f.identities.importLegacy(owner,mapping); + const again=f.identities.importLegacy(owner,mapping);assert.equal(first.account_id,again.account_id); + const row=f.identities.account(owner.subject);assert.equal(row.user_id,'existing-user');assert.equal(row.subject,owner.subject); + assert.deepEqual(JSON.parse(row.password_json),owner.password);assert.equal(f.identities.eligible(owner.subject),false); + assert.throws(()=>f.identities.importLegacy({...owner,subject:randomSecret()},mapping)); + assert.throws(()=>new AuthStore(path.join(f.directory,'oauth.sqlite3'))); +}); +test('ISO-13: revoking A does not erase B browser sessions or CSRF',t=>{ + const f=identityFixture(t); + for(const subject of ['account-A','account-B']) { + f.store.db.prepare('INSERT INTO oauth_records(model,id,payload,expires) VALUES(?,?,?,?)').run('Session',subject,JSON.stringify({accountId:subject}),seconds()+100); + f.store.csrf(subject,100); + } + f.store.revoke({subject:'account-A'}); + assert.ok(f.store.find('Session','id','account-B')); + assert.equal(f.store.find('Session','id','account-A'),undefined); + assert.ok(f.store.db.prepare('SELECT 1 FROM oauth_csrf WHERE uid=?').get('account-B')); +}); diff --git a/services/oauth/test/ingress.mjs b/services/oauth/test/ingress.mjs index b8703e3..109b5a4 100644 --- a/services/oauth/test/ingress.mjs +++ b/services/oauth/test/ingress.mjs @@ -2,11 +2,11 @@ import fs from "node:fs"; import http from "node:http"; // Exercise the documented Cloudflare path expressions without a public tunnel. -export function testIngress(origin, ports) { - const template = fs.readFileSync(new URL("../../../docs/chatgpt-web-oauth-v0.1/config/cloudflared.ingress.example.yml", import.meta.url), "utf8"); +export function testIngress(origin, ports, {consoleEnabled=false}={}) { + const template = fs.readFileSync(new URL(consoleEnabled?"../../../docs/console-ingress.example.yml":"../../../docs/chatgpt-web-oauth-v0.1/config/cloudflared.ingress.example.yml", import.meta.url), "utf8"); const rules = [...template.matchAll(/ path: '([^']+)'\n service: http:\/\/127\.0\.0\.1:(47832|47833)/g)] .map((match) => ({ pattern: new RegExp(match[1]), port: match[2] === "47832" ? ports.gatewayPort : ports.authPort })); - if (rules.length !== 4 || !template.includes("- service: http_status:404")) throw new Error("Unrecognized ingress fixture"); + if (rules.length !== (consoleEnabled?5:4) || !template.includes("- service: http_status:404")) throw new Error("Unrecognized ingress fixture"); const host = new URL(origin).host; return http.createServer((request, response) => { response.setHeader("cache-control", "no-store"); diff --git a/services/oauth/test/ownership-inventory.test.mjs b/services/oauth/test/ownership-inventory.test.mjs new file mode 100644 index 0000000..b643d20 --- /dev/null +++ b/services/oauth/test/ownership-inventory.test.mjs @@ -0,0 +1,9 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import {identityFixture} from './helpers/identity-fixture.mjs'; +test('BASE-02: every OAuth table has an account or protocol owner classification',t=>{ + const f=identityFixture(t),inventory=JSON.parse(fs.readFileSync(new URL('../../../docs/architecture/account-ownership.json',import.meta.url),'utf8')); + const actual=f.store.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all().map(r=>r.name); + const classified=Object.values(inventory.oauth).flat();assert.equal(new Set(classified).size,classified.length);assert.deepEqual(classified.sort(),actual); +}); diff --git a/services/oauth/test/private-ingress.test.mjs b/services/oauth/test/private-ingress.test.mjs new file mode 100644 index 0000000..91fc355 --- /dev/null +++ b/services/oauth/test/private-ingress.test.mjs @@ -0,0 +1,119 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import http from 'node:http'; +import https from 'node:https'; +import tls from 'node:tls'; +import {X509Certificate} from 'node:crypto'; +import {spawnSync} from 'node:child_process'; +import {once} from 'node:events'; +import {createPrivateIngress,validateIngressConfig} from '../src/private-ingress.mjs'; + +function certificates(t){ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'mnemuron-private-ingress-test-')); + fs.chmodSync(dir,0o700); + t.after(()=>fs.rmSync(dir,{recursive:true,force:true})); + const run=args=>{ + const result=spawnSync('/usr/bin/openssl',args,{cwd:dir,encoding:'utf8'}); + assert.equal(result.status,0,'synthetic certificate generation failed'); + }; + run(['ecparam','-name','prime256v1','-genkey','-noout','-out','ca.key']); + run(['req','-new','-x509','-sha256','-key','ca.key','-out','ca.pem','-days','1','-subj','/CN=Synthetic isolated CA']); + for(const name of ['server','client','other']){ + run(['ecparam','-name','prime256v1','-genkey','-noout','-out',`${name}.key`]); + run(['req','-new','-key',`${name}.key`,'-out',`${name}.csr`,'-subj',`/CN=${name}.example.test`]); + fs.writeFileSync(path.join(dir,`${name}.ext`),`basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\nextendedKeyUsage=${name==='server'?'serverAuth':'clientAuth'}\n${name==='server'?'subjectAltName=DNS:server.example.test\n':''}`,{mode:0o600}); + run(['x509','-req','-sha256','-in',`${name}.csr`,'-CA','ca.pem','-CAkey','ca.key','-set_serial',String({server:2,client:3,other:4}[name]),'-out',`${name}.pem`,'-days','1','-extfile',`${name}.ext`]); + } + for(const file of fs.readdirSync(dir))fs.chmodSync(path.join(dir,file),0o600); + return {dir,read:name=>fs.readFileSync(path.join(dir,name))}; +} + +test('private ingress: mTLS, exact source and leaf pin, byte preservation and bounded shutdown',async t=>{ + const cert=certificates(t),requests=[]; + const origin=http.createServer(async(req,res)=>{ + const chunks=[];for await(const chunk of req)chunks.push(chunk); + const body=Buffer.concat(chunks).toString(); + requests.push({url:req.url,headers:req.headers,body}); + res.setHeader('set-cookie','synthetic=session; Secure; HttpOnly; SameSite=Lax'); + res.end(body||'synthetic login'); + }); + origin.listen(0,'127.0.0.1');await once(origin,'listening'); + t.after(()=>{origin.closeAllConnections();origin.close();}); + const base={listen_host:'127.0.0.1',listen_port:0,allowed_peer:'127.0.0.1', + server_name:'server.example.test',upstream_port:origin.address().port, + ca_file:path.join(cert.dir,'ca.pem'),cert_file:path.join(cert.dir,'server.pem'),key_file:path.join(cert.dir,'server.key'), + client_fingerprint_sha256:new X509Certificate(cert.read('client.pem')).fingerprint256.replaceAll(':','').toLowerCase(), + idle_timeout_ms:5000,shutdown_timeout_ms:100}; + const ingress=createPrivateIngress(base,{isolated:true}); + ingress.server.listen(0,'127.0.0.1');await once(ingress.server,'listening'); + t.after(()=>ingress.close()); + const request=(overrides={},body='',pauseResponseMs=0)=>new Promise((resolve,reject)=>{ + const req=https.request({host:'127.0.0.1',port:ingress.server.address().port,servername:'server.example.test', + ca:cert.read('ca.pem'),cert:cert.read('client.pem'),key:cert.read('client.key'),agent:false, + method:body?'POST':'GET',path:'/interaction/synthetic/login', + headers:{host:'server.example.test',origin:'https://server.example.test',cookie:'synthetic=csrf',connection:'close'}, + timeout:3000,...overrides},res=>{ + const chunks=[];res.on('data',c=>chunks.push(c));res.on('end',()=>resolve({status:res.statusCode,headers:res.headers,body:Buffer.concat(chunks).toString()})); + res.on('error',reject); + if(pauseResponseMs){res.pause();setTimeout(()=>res.resume(),pauseResponseMs);} + });req.on('error',reject);req.on('timeout',()=>req.destroy(new Error('synthetic request timeout')));req.end(body); + }); + await t.test('valid peer forwards bytes and security headers without rewriting',async()=>{ + const body='csrf=synthetic&value='+('memory-is-not-read-🙂'.repeat(16000)); + const response=await request({},body); + assert.equal(response.status,200);assert.equal(response.body,body); + assert.match(response.headers['set-cookie'][0],/Secure; HttpOnly; SameSite=Lax/); + assert.equal(requests[0].headers.origin,'https://server.example.test'); + assert.equal(requests[0].headers.cookie,'synthetic=csrf');assert.equal(requests[0].url,'/interaction/synthetic/login'); + }); + for(const [name,overrides] of [ + ['missing client certificate',{cert:undefined,key:undefined}], + ['same CA but wrong leaf',{cert:cert.read('other.pem'),key:cert.read('other.key')}], + ['wrong SNI',{servername:'wrong.example.test',checkServerIdentity:()=>undefined}], + ['missing SNI',{servername:'',checkServerIdentity:()=>undefined}], + ['untrusted server CA',{ca:cert.read('other.pem')}], + ])await t.test(name,async()=>{await assert.rejects(request(overrides));assert.equal(requests.length,1);}); + await t.test('wrong source is rejected before forwarding',async()=>{ + const denied=createPrivateIngress({...base,allowed_peer:'127.0.0.2'},{isolated:true}); + denied.server.listen(0,'127.0.0.1');await once(denied.server,'listening'); + try{await assert.rejects(request({port:denied.server.address().port}));assert.equal(requests.length,1);}finally{await denied.close();} + }); + await t.test('production config rejects loopback, wildcards, public peers and non-loopback upstream',()=>{ + assert.throws(()=>validateIngressConfig(base)); + const syntheticPrivate=last=>[192,168,50,last].join('.'); + const production={...base,listen_host:syntheticPrivate(10),listen_port:47835,allowed_peer:syntheticPrivate(11)}; + assert.doesNotThrow(()=>validateIngressConfig(production)); + for(const change of [{listen_host:'0.0.0.0'},{allowed_peer:'8.8.8.8'},{upstream_host:syntheticPrivate(12)}, + {client_fingerprint_sha256:''},{server_name:'*'},{connection_limit:1000000}])assert.throws(()=>validateIngressConfig({...production,...change})); + }); + await t.test('insecure key permissions fail closed',()=>{ + fs.chmodSync(base.key_file,0o644); + try{assert.throws(()=>createPrivateIngress(base,{isolated:true}));}finally{fs.chmodSync(base.key_file,0o600);} + }); + await t.test('connection limit and bounded drain include idle authenticated clients',async()=>{ + const limited=createPrivateIngress({...base,connection_limit:1},{isolated:true}); + limited.server.listen(0,'127.0.0.1');await once(limited.server,'listening'); + const peer=tls.connect({host:'127.0.0.1',port:limited.server.address().port,servername:'server.example.test', + ca:cert.read('ca.pem'),cert:cert.read('client.pem'),key:cert.read('client.key')}); + peer.on('error',()=>{}); + try{ + await once(peer,'secureConnect');await assert.rejects(request({port:limited.server.address().port})); + const started=Date.now();await limited.close();assert.ok(Date.now()-started<1500); + }finally{peer.destroy();await limited.close();} + }); + await t.test('slow reader receives the complete response when origin closes',async()=>{ + const body='synthetic-response-'.repeat(500000); + const response=await request({},body,200); + assert.equal(response.status,200);assert.ok(response.body===body,'all response bytes must drain before closure'); + }); + await t.test('unavailable origin never fabricates a successful response',async()=>{ + origin.closeAllConnections();await new Promise(resolve=>origin.close(resolve)); + await assert.rejects(request()); + }); + await t.test('shutdown closes the listener',async()=>{ + await ingress.close();assert.equal(ingress.server.listening,false); + }); +}); diff --git a/services/oauth/test/provisioning.test.mjs b/services/oauth/test/provisioning.test.mjs new file mode 100644 index 0000000..1fbae61 --- /dev/null +++ b/services/oauth/test/provisioning.test.mjs @@ -0,0 +1,65 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import {spawnSync} from 'node:child_process'; +import {AuthStore} from '../src/sqlite-adapter.mjs'; +import {IdentityRepository} from '../src/identity-repository.mjs'; +import {identityFixture,pendingAccount} from './helpers/identity-fixture.mjs'; +import {provisionIdentities} from '../src/provisioning.mjs'; +import {memoryFixture,businessSnapshot} from '../../../server/test/helpers/core-memory-fixture.mjs'; +import {readPrivate} from '../../../shared/oauth-common.mjs'; +import {ReadonlyCoreClient} from '../../../adapters/chatgpt-web/src/core-client.mjs'; + +test('BASE-06 MFA-08..09: interrupted Core commit recovers same credential without data rewrites',async t=>{ + const f=identityFixture(t),core=await memoryFixture(t);const {session,account}=await pendingAccount(f); + const before=businessSnapshot(core.store);f.identities.takeRecoveryCodes(session.token);f.identities.acknowledgeRecovery(session.token); + const options={credentialDirectory:path.join(f.directory,'credentials'),identityMapFile:path.join(f.directory,'map.json')}; + assert.throws(()=>provisionIdentities(f.identities,core.store,{...options,afterCore:()=>{throw new Error('synthetic crash');}})); + assert.equal(f.identities.registrationState(session.token).status,'provisioning'); + const ids=core.store.db.prepare('SELECT credential_id FROM credentials WHERE user_id=? ORDER BY credential_id').all(f.identities.byId(account.account_id).user_id); + assert.equal(ids.length,2);provisionIdentities(f.identities,core.store,options);provisionIdentities(f.identities,core.store,options); + assert.deepEqual(core.store.db.prepare('SELECT credential_id FROM credentials WHERE user_id=? ORDER BY credential_id').all(f.identities.byId(account.account_id).user_id),ids); + assert.deepEqual(businessSnapshot(core.store),before);assert.equal(f.identities.registrationState(session.token).status,'active'); +}); +test('ISO-01..07 OAUTH-01: two provisioned owners use separate credentials under interleaved HTTP',async t=>{ + const f=identityFixture(t),core=await memoryFixture(t),accounts=[]; + for(const name of ['Synthetic_A','Synthetic_B']) {const a=await pendingAccount(f,name);f.identities.takeRecoveryCodes(a.session.token);f.identities.acknowledgeRecovery(a.session.token);accounts.push(a);} + const options={credentialDirectory:path.join(f.directory,'credentials'),identityMapFile:path.join(f.directory,'map.json')}; + provisionIdentities(f.identities,core.store,options);const maps=readPrivate(options.identityMapFile,{json:true}).mappings; + const memories=maps.map(m=>{const author=core.issue(m.mnemuron_user_id,'writer-'+m.account_id); + const memory=core.store.saveMemory(author.auth,{scope:'user',content:'synthetic same query',operation_id:'same-operation'}).memory; + const latest=core.store.revisions.latest(author.auth.user_id,memory.memory_id);core.store.webVisibility.set(author.auth,memory.memory_id,{allow:true,revision:latest.revision,state_hash:latest.state_hash});return memory;}); + const clients=maps.map(m=>new ReadonlyCoreClient({core:{base_url:core.baseUrl,credential_file:m.credential_file,timeout_ms:5000,max_response_bytes:262144}})); + const results=await Promise.all(Array.from({length:20},(_,i)=>clients[i%2].call('mnemuron_search_memories',{query:'synthetic same query'},maps[i%2]))); + for(const [i,r] of results.entries())assert.deepEqual(r.results.map(m=>m.memory_id),[memories[i%2].memory_id]); + await assert.rejects(()=>clients[0].call('mnemuron_get_memory',{memory_id:memories[1].memory_id},maps[0]),e=>e.code==='MEMORY_NOT_FOUND'); + await assert.rejects(()=>clients[0].checkIdentity(maps[1]),e=>e.code==='CORE_AUTH_UNAVAILABLE'); + assert.throws(()=>core.store.registerAgent(core.a.auth,{user_id:maps[0].mnemuron_user_id,device_id:'test',agent_id:'test',agent_instance_id:'test'})); +}); +test('BASE-06: a terminated provisioning process resumes its persisted intent from a new repository instance',async t=>{ + const f=identityFixture(t),core=await memoryFixture(t),a=await pendingAccount(f); + f.identities.takeRecoveryCodes(a.session.token);f.identities.acknowledgeRecovery(a.session.token); + const options={credentialDirectory:path.join(f.directory,'keys'),identityMapFile:path.join(f.directory,'map.json')}; + const script=`import {AuthStore} from ${JSON.stringify(new URL('../src/sqlite-adapter.mjs',import.meta.url).href)}; + import {IdentityRepository} from ${JSON.stringify(new URL('../src/identity-repository.mjs',import.meta.url).href)}; + import {provisionIdentities} from ${JSON.stringify(new URL('../src/provisioning.mjs',import.meta.url).href)}; + import {MnemuronStore} from ${JSON.stringify(new URL('../../../server/lib/store.mjs',import.meta.url).href)}; + let text='';for await(const c of process.stdin)text+=c;const p=JSON.parse(text); + const store=new AuthStore(p.file,{identity:true}),ids=new IdentityRepository(store,{keyFile:p.keyFile,issuer:p.issuer,batchLimit:10,sessionTtl:3600}); + const core=new MnemuronStore(p.core);provisionIdentities(ids,core,{...p.options,afterCore:()=>process.exit(73)});`; + const child=spawnSync(process.execPath,['--input-type=module','-e',script],{encoding:'utf8',timeout:15000, + input:JSON.stringify({file:path.join(f.directory,'oauth.sqlite3'),keyFile:f.keyFile,issuer:f.identities.issuer,core:core.databasePath,options})}); + assert.equal(child.status,73,'controlled process termination after Core commit'); + const row=f.identities.byId(a.account.account_id); + assert.equal(row.status,'provisioning');assert.equal(row.binding_ready,0); + const credentials=core.store.db.prepare('SELECT credential_id FROM credentials WHERE user_id=? ORDER BY credential_id').all(row.user_id); + assert.equal(credentials.length,2); + const restarted=new AuthStore(path.join(f.directory,'oauth.sqlite3'),{identity:true}); + try { + const ids=new IdentityRepository(restarted,{keyFile:f.keyFile,issuer:f.identities.issuer,batchLimit:10,sessionTtl:3600}); + assert.equal(provisionIdentities(ids,core.store,options).completed,1); + assert.equal(ids.byId(a.account.account_id).status,'active'); + assert.equal(provisionIdentities(ids,core.store,options).completed,0); + } finally {restarted.close();} + assert.deepEqual(core.store.db.prepare('SELECT credential_id FROM credentials WHERE user_id=? ORDER BY credential_id').all(row.user_id),credentials); +}); diff --git a/services/oauth/test/recovery.test.mjs b/services/oauth/test/recovery.test.mjs new file mode 100644 index 0000000..f376324 --- /dev/null +++ b/services/oauth/test/recovery.test.mjs @@ -0,0 +1,59 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {generate} from 'otplib'; +import path from 'node:path'; +import {memoryFixture} from '../../../server/test/helpers/core-memory-fixture.mjs'; +import {provisionIdentities} from '../src/provisioning.mjs'; +import {identityFixture,pendingAccount} from './helpers/identity-fixture.mjs'; +import {RecoveryService} from '../src/recovery.mjs'; +import {readPrivate,seconds} from '../../../shared/oauth-common.mjs'; +test('REC-01..04 REC-07..08: missing proof policy denies; synthetic approved proofs issue restricted one-use recovery',async t=>{ + const f=identityFixture(t),core=await memoryFixture(t),a=await pendingAccount(f),codes=f.identities.takeRecoveryCodes(a.session.token); + f.identities.acknowledgeRecovery(a.session.token); + const options={credentialDirectory:path.join(f.directory,'keys'),identityMapFile:path.join(f.directory,'map.json')}; + provisionIdentities(f.identities,core.store,options); + const defaultRecovery=new RecoveryService(f.identities); + await assert.rejects(()=>defaultRecovery.begin({username:'Synthetic_A',action:'password',recoveryCode:codes[0]}),e=>e.code==='BLOCKED_POLICY'); + const recovery=new RecoveryService(f.identities,{policy:{password:['totp','recovery_code'],totp:['password','recovery_code']}}); + const token=await recovery.begin({username:'Synthetic_A',action:'password',otp:await generate({secret:a.setup.secret}),recoveryCode:codes[0]}); + assert.throws(()=>f.identities.session(token.token,'console')); + await assert.rejects(()=>recovery.begin({username:'Synthetic_B',action:'password',otp:'000000',recoveryCode:codes[0]})); + await assert.rejects(async()=>recovery.begin({username:'Synthetic_A',action:'password',otp:await generate({secret:a.setup.secret}),recoveryCode:codes[0]})); + assert.throws(()=>f.identities.registrationState(a.session.token)); + const revokeCore=async({user_id,credential_ids})=>{ + for(const id of credential_ids)core.store.db.prepare('UPDATE credentials SET revoked_at=? WHERE user_id=? AND credential_id=?').run(new Date().toISOString(),user_id,id); + return true; + }; + await assert.rejects(()=>recovery.complete(token.token,{password:'Synthetic changed password'},{revokeCore:async()=>{throw new Error('synthetic unavailable');}})); + assert.equal(f.identities.byId(a.account.account_id).status,'recovery_pending'); + const result=await recovery.complete(token.token,{password:'Synthetic changed password'},{revokeCore}); + assert.equal(result.login_required,true);assert.equal(result.status,'provisioning'); + provisionIdentities(f.identities,core.store,options); + assert.equal(f.identities.byId(a.account.account_id).status,'active'); +}); +test('REC-02..10: synthetic TOTP recovery is owner-bound, atomic, revokes only A and requires fresh login',async t=>{ + const f=identityFixture(t),core=await memoryFixture(t),ids=f.identities,owners=[]; + for(const name of ['Synthetic_Recovery_A','Synthetic_Recovery_B']){const a=await pendingAccount(f,name),codes=ids.takeRecoveryCodes(a.session.token);ids.acknowledgeRecovery(a.session.token);owners.push({...a,name,codes});} + const options={credentialDirectory:path.join(f.directory,'keys'),identityMapFile:path.join(f.directory,'map.json')};provisionIdentities(ids,core.store,options); + f.store.identity=ids; + const [a,b]=owners; + for(const owner of owners){owner.row=ids.byId(owner.account.account_id);owner.console=ids.newSession('console',{accountId:owner.row.account_id}); + const Adapter=f.store.adapter();await new Adapter('AccessToken').upsert(owner.name,{accountId:owner.row.subject},300); + await new Adapter('RefreshToken').upsert(owner.name,{accountId:owner.row.subject},300);} + const aKey=readPrivate(ids.bindings(a.row.subject)[0].credential_file),bKey=readPrivate(ids.bindings(b.row.subject)[0].credential_file); + const recovery=new RecoveryService(ids,{policy:{password:['totp','recovery_code'],totp:['password','recovery_code']}}); + await assert.rejects(()=>recovery.begin({username:b.name,action:'totp',password:'Synthetic password with spaces ',recoveryCode:a.codes[0]})); + const outcomes=await Promise.allSettled([0,1].map(()=>recovery.begin({username:a.name,action:'totp',password:'Synthetic password with spaces ',recoveryCode:a.codes[0]}))); + assert.equal(outcomes.filter(r=>r.status==='fulfilled').length,1);const token=outcomes.find(r=>r.status==='fulfilled').value; + assert.throws(()=>ids.session(a.console.token,'console'));assert.ok(ids.session(b.console.token,'console')); + for(const model of ['AccessToken','RefreshToken']){assert.equal(f.store.find(model,'id',a.name),undefined);assert.ok(f.store.find(model,'id',b.name));} + const next=recovery.enrollment(token.token); + const revokeCore=async({user_id,credential_ids})=>{for(const id of credential_ids)core.store.db.prepare('UPDATE credentials SET revoked_at=? WHERE user_id=? AND credential_id=?').run(new Date().toISOString(),user_id,id);return true;}; + const result=await recovery.complete(token.token,{otp:await generate({secret:next.secret,epoch:seconds()})},{revokeCore}); + assert.equal(result.login_required,true);assert.throws(()=>ids.session(token.token,'console')); + assert.throws(()=>core.store.authenticate(aKey));assert.ok(core.store.authenticate(bKey)); + provisionIdentities(ids,core.store,options);assert.equal(ids.byId(a.row.account_id).user_id,a.row.user_id);assert.equal(ids.byId(a.row.account_id).subject,a.row.subject); + assert.equal(await ids.authenticate(a.name,'Synthetic password with spaces ',await generate({secret:a.setup.secret})),null); + const audit=JSON.stringify(f.store.db.prepare('SELECT * FROM identity_audit').all()); + assert.ok(![aKey,bKey,next.secret,...a.codes].some(secret=>audit.includes(secret))); +}); diff --git a/services/oauth/test/registration-console.test.mjs b/services/oauth/test/registration-console.test.mjs new file mode 100644 index 0000000..5d9eac1 --- /dev/null +++ b/services/oauth/test/registration-console.test.mjs @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import {generate} from 'otplib'; +import {fixture,Browser} from './fixture.mjs'; +import {writePrivate,randomSecret,seconds} from '../../../shared/oauth-common.mjs'; +import {memoryFixture} from '../../../server/test/helpers/core-memory-fixture.mjs'; +import {provisionIdentities} from '../src/provisioning.mjs'; +const csrf=page=>page.text.match(/name="csrf" value="([^"]+)"/)?.[1]; +import {consoleFixture} from './helpers/identity-fixture.mjs'; +test('MFA-01..06, UI-05..06: real registration routes require invitation, CSRF and verified MFA',async t=>{ + const f=await consoleFixture(t),browser=new Browser(f.config.issuer),ids=f.app.accounts; + const invitation=ids.issueInvitations({count:1,ttlMinutes:10,issuer:'synthetic-test'}).codes[0]; + let page=await browser.request('/register');assert.equal(page.status,200);assert.match(page.text,/autocomplete="off"/); + assert.equal((await browser.post('/register/reserve',{csrf:'wrong',code:invitation})).status,401); + page=await browser.request('/register');let result=await browser.post('/register/reserve',{csrf:csrf(page),code:invitation});assert.equal(result.status,303); + page=await browser.request(result.headers.get('location')); + assert.equal((await browser.post('/register/account',{csrf:csrf(page),username:'Synthetic_Registration',password:'Synthetic password with spaces ',password_confirm:'Synthetic password with spaces ',role:'admin'})).status,400); + assert.equal(ids.db.prepare('SELECT COUNT(*) n FROM identity_accounts').get().n,0); + result=await browser.post('/register/account',{csrf:csrf(page),username:'Synthetic_Registration',password:'Synthetic password with spaces ',password_confirm:'Synthetic password with spaces '});assert.equal(result.status,303); + page=await browser.request(result.headers.get('location'));assert.match(page.text,/([^<]+){ + const f=await consoleFixture(t),browser=new Browser(f.config.issuer); + const direct=await new Browser(f.config.issuer).post('/register/account',{username:'Synthetic_Blocked',password:'Synthetic password with spaces '}); + assert.equal(direct.status,401); + let page=await browser.request('/register'); + const tooLarge=await browser.post('/register/reserve',{csrf:csrf(page),code:'synthetic-invalid-'.repeat(1024)}); + assert.equal(tooLarge.status,413); + for(let i=0;i<31;i++) { + page=await browser.request('/register'); + const response=await browser.post('/register/reserve',{csrf:csrf(page),code:'synthetic-invalid-code'}); + assert.equal(response.status,i===30?429:400); + } + assert.equal(f.app.store.db.prepare('SELECT COUNT(*) n FROM identity_accounts').get().n,0); + assert.ok(!JSON.stringify(f.logs).includes('synthetic-invalid-code')); +}); diff --git a/web/console/app.mjs b/web/console/app.mjs new file mode 100644 index 0000000..b286b73 --- /dev/null +++ b/web/console/app.mjs @@ -0,0 +1,97 @@ +import {translate as t} from './appearance.mjs'; +import {SessionState} from './session-state.mjs'; +const root=document.getElementById('console-root'),dialog=document.getElementById('memory-dialog'),detail=document.getElementById('memory-content'); +const state=new SessionState(document.body.dataset.account); +const page=document.body.dataset.page; +let requestSequence=0,detailSequence=0,currentData=null,detailData=null,lastFocus=null,query='',offset=0,detailKind='memory'; +const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +const l=(key,tag='span')=>`<${tag} data-i18n="${key}">${esc(t(key))}`; +const tag=v=>`${esc(v)}`; +const disabled=key=>``; +const date=value=>value?new Date(typeof value==='number'?value*1000:value).toLocaleString(document.documentElement.lang):'—'; +function clear() {state.clear();requestSequence++;detailSequence++;currentData=null;detailData=null;query='';offset=0;detail.replaceChildren();dialog.close();root.replaceChildren();document.body.removeAttribute('data-csrf');for(const input of document.querySelectorAll('input'))input.value='';} +async function api(view,params={}) { + const ticket=state.ticket();try { + const response=await fetch(`/console-api/${view}?${new URLSearchParams(params)}`,{credentials:'same-origin',cache:'no-store',signal:ticket.controller.signal}); + if(response.status===401){clear();location.replace('/login');throw new Error('SESSION_REQUIRED');} + const data=await response.json();if(!state.accepts(ticket))throw new Error('STALE_ACCOUNT'); + if(!response.ok)throw new Error(data.error_code||'UNAVAILABLE');return data; + } finally {state.finish(ticket);} +} +const empty=()=>`
    ${l('empty')}
    `; +function memoryRows(rows=[]) {return rows.length?rows.map(m=>`
    ${tag(m.status||'active')}
    `).join(''):empty();} +function table(rows,columns){return rows?.length?`${columns.map(([key,label])=>``).join('')}${rows.map(r=>`${columns.map(([key])=>``).join('')}`).join('')}
    ${esc(t(label||key))}
    ${esc(r[key]??'—')}
    `:empty();} +function policy(note='blockedNote',actions=[]) {return `
    ${l('blocked')}${l(note,'p')}
    ${actions.map(disabled).join('')}
    `;} +function render(data) { + let html='';const heading=`

    ${esc(t('workspaceLabel'))}

    ${l(page==='overview'?'hero':page,'h1')}${l(page==='overview'?'heroNote':'noDemo','p')}
    ${l('readOnly')}
    `; + if(page==='overview')html=`

    ${esc(t('heroLabel'))}

    Mnemuron · ${l('workspace')}

    ${l('consentNote','p')}${l('connections')} →
    ${[['memories','memoryCount'],['sources','sourceCount'],['summaries','summaryCount'],['jobs','jobCount']].map(([key,label])=>`
    ${tag('↗')}${l(label)}${Number.isInteger(data.counts?.[key])?data.counts[key]:'—'}${l('workspace','small')}
    `).join('')}
    ${l('recent','h2')}${l('viewAll')} →
    ${memoryRows(data.recent)}
    ${l('policy','h2')}${policy('consentNote')}${l('pendingPolicies','p')}
    ${disabled('newMemory')}${disabled('organize')}${disabled('export')}
    `; + else if(page==='memories')html=`
    ${disabled('newMemory')}
    ${memoryRows(data.results)}
    `; + else if(page==='summaries')html=`
    ${l('summaries','h2')}${data.summaries?.length?data.summaries.map(s=>`
    ${tag(s.status)}
    `).join(''):empty()}
    ${table(data.categories,[['category','scope'],['count','memoryCount']])}${policy('blockedNote',['organize'])}
    `; + else if(page==='jobs')html=`
    ${table(data.jobs,[['job_id','identity'],['job_type','scope'],['state','state'],['processed','complete'],['total','sourceCount'],['last_error_code','error']])}${policy('blockedNote',['organize'])}
    `; + else if(page==='connections')html=`
    ${table(data.connections?.map(c=>({...c,expires:date(c.expires)})),[['client_id','identity'],['expires','state']])}${l('connections','h2')}${table(data.core_connections,[['label','identity'],['agent_id','scope'],['last_used_at','created']])}${policy('blockedNote',['revoke'])}${l('consentNote','p')}
    `; + else if(page==='security')html=`

    ${esc(data.username)}

    ${l(data.mfa_verified?'passwordTotp':'pending')}${l('securityNote','p')}${l('recover')} →
    ${table(data.sessions?.map(s=>({...s,created:date(s.created),expires:date(s.expires)})),[['purpose','scope'],['created','created'],['expires','state']])}
    `; + else if(page==='audit')html=`
    ${table([...data.entries||[],...(data.core_entries||[]).map(e=>({...e,created:e.created_at}))].map(e=>({...e,created:date(e.created)})),[['action','scope'],['outcome','state'],['created','created']])}
    `; + else if(page==='storage')html=`
    ${l('storageNote','p')}${table(Object.entries(data.counts||{}).map(([kind,count])=>({kind,count})),[['kind','scope'],['count','memoryCount']])}${policy('storageNote',['export','restore'])}
    `; + else if(page==='appearance')html=`
    ${l('appearanceNote','p')}

    Neural Indigo · Signal Teal · Paper Amber

    ${l('theme','h2')}${l('appearanceNote','p')}

    ${l('mode')}: ${esc(t(document.documentElement.dataset.mode))}

    `; + else if(['models','invitations','accounts'].includes(page))html=`
    ${policy(page==='models'?'modelsNote':'platformNote',[page==='models'?'configure':page==='invitations'?'issue':'manage'])}
    `; + root.innerHTML=heading+html; +} +async function load() { + const sequence=++requestSequence; + if(['appearance','models','invitations','accounts'].includes(page)){currentData={};render(currentData);return;} + try { + const data=await api(page,page==='memories'?query?{query}:{offset}:{}); + if(sequence!==requestSequence||!state.account)return;currentData=data;render(data); + }catch(e){if(sequence!==requestSequence||!state.account||e.name==='AbortError')return; + root.innerHTML=`
    ${l(page,'h1')}
    `;} +} +function showDetail(data) { + detail.innerHTML=`
    ${tag(data.memory?.memory_type)}${l('revisions')} ${data.revision}${tag(data.memory?.status)}
    ${esc(data.memory?.content)}

    ${data.content_complete?l('complete'):`${data.content_offset} / ${data.content_length}`}

    ${l('sources','h3')}${(data.source_manifest?.sources||[]).map(s=>`
    ${esc(s.source_kind||s.source_id)}${esc(s.source_id)}
    ${esc(JSON.stringify(s,null,2))}
    `).join('')||empty()}${data.next_source_request?``:''}`; +} +function beginDetail(kind,title) { + detailKind=kind; + if(!dialog.open)lastFocus=document.activeElement; + detail.innerHTML=l('loading','p'); + const heading=document.getElementById('detail-title');heading.dataset.i18n=title;heading.textContent=t(title); + if(!dialog.open)dialog.showModal(); +} +async function openMemory(params,first=false) { + const sequence=++detailSequence; + if(first)beginDetail('memory','detail'); + try{const data=await api('memory',params);if(!state.account||sequence!==detailSequence)return;detailData=data;showDetail(data);} + catch(e){if(!state.account||sequence!==detailSequence||e.name==='AbortError')return;detailData=null;detail.innerHTML=`
    ${l(/VERSION_CHANGED|MANIFEST_CHANGED/.test(e.message)?'changed':'unavailable','p')}
    `;} +} +async function openSummary(params,first=false) { + const sequence=++detailSequence; + if(first)beginDetail('summary','summaries'); + try { + const data=await api('summary',params);if(!state.account||sequence!==detailSequence)return;detailData=data; + const summary=data.results[0]; + detail.innerHTML=`
    ${tag(summary.category)}${l('revisions')} ${summary.revision}
    ${summary.claims.map(c=>`

    ${esc(c.quote)}

    `).join('')||empty()}${l(data.complete?'complete':'next','p')}${data.next_request?``:''}`; + }catch(e){if(!state.account||sequence!==detailSequence||e.name==='AbortError')return;detailData=null;detail.innerHTML=`
    ${l(/VERSION_CHANGED|MANIFEST_CHANGED/.test(e.message)?'changed':'unavailable','p')}
    `;} +} +document.addEventListener('click',event=>{ + const memory=event.target.closest('[data-memory]');if(memory){void openMemory({memory_id:memory.dataset.memory,content_limit:1024,...(memory.dataset.revision?{revision:memory.dataset.revision}:{})},true);return;} + const summary=event.target.closest('[data-summary]');if(summary){void openSummary({summary_id:summary.dataset.summary,revision:summary.dataset.revision},true);return;} + if(event.target.closest('[data-close]')){dialog.close();return;} + if(event.target.closest('[data-detail-next]')&&detailData?.next_request)void (detailKind==='summary'?openSummary:openMemory)(detailData.next_request); + if(event.target.closest('[data-source-next]')&&detailData?.next_source_request)void openMemory(detailData.next_source_request); + const pagination=event.target.closest('[data-offset]');if(pagination){offset=Number(pagination.dataset.offset);void load();} + if(event.target.closest('[data-retry]'))void load(); +}); +document.addEventListener('submit',event=>{ + if(event.target.id==='search-form'){event.preventDefault();query=String(new FormData(event.target).get('query')||'');offset=0;void load();} + if(event.target.action?.endsWith('/console-api/logout')){requestSequence++;detailSequence++;for(const c of state.controllers)c.abort();root.replaceChildren();detail.replaceChildren();dialog.close();currentData=null;detailData=null;} +}); +dialog.addEventListener('close',()=>{detailSequence++;detailData=null;detail.replaceChildren();lastFocus?.focus();lastFocus=null;}); +window.addEventListener('pagehide',clear); +window.addEventListener('pageshow',event=>{if(event.persisted)location.reload();}); +document.addEventListener('appearancechange',()=>{ + // Language changes translate chrome only; do not replace a form, drawer, user content or a pending request. + document.title=`Mnemuron · ${t(page)}`; + const mode=document.querySelector('[data-current-mode]');if(mode){mode.dataset.i18n=document.documentElement.dataset.mode;mode.textContent=t(mode.dataset.i18n);} +}); +try { + const me=await api('me');if(me.account_id!==state.account){clear();location.replace('/login');} + else{for(const field of document.querySelectorAll('input[name="csrf"]'))field.value=me.csrf;await load();} +}catch(e){if(state.account)root.innerHTML=``;} diff --git a/web/console/appearance.mjs b/web/console/appearance.mjs new file mode 100644 index 0000000..82721ee --- /dev/null +++ b/web/console/appearance.mjs @@ -0,0 +1,31 @@ +import {text} from './catalog.mjs'; +const defaults={theme:'a',mode:'light',locale:'zh-CN'}; +const valid={theme:['a','b','c'],mode:['light','dark'],locale:['zh-CN','en']}; +const account=document.body.dataset.account || 'signed-out'; +const key=`mnemuron.appearance.v1.${account}`; +let prefs={...defaults}; +try {const saved=JSON.parse(localStorage.getItem(key)||'{}');for(const k of Object.keys(valid))if(valid[k].includes(saved[k]))prefs[k]=saved[k];}catch{} +export const translate=key=>text(key,prefs.locale); +function apply() { + document.documentElement.dataset.theme=prefs.theme;document.documentElement.dataset.mode=prefs.mode;document.documentElement.lang=prefs.locale; + for(const node of document.querySelectorAll('[data-i18n]'))node.textContent=translate(node.dataset.i18n); + for(const node of document.querySelectorAll('[data-i18n-placeholder]'))node.placeholder=translate(node.dataset.i18nPlaceholder); + for(const node of document.querySelectorAll('[data-pref]')){node.value=prefs[node.dataset.pref];node.disabled=false;} +} +apply(); +document.addEventListener('change',event=>{ + const property=event.target.dataset.pref;if(!valid[property]?.includes(event.target.value))return; + prefs={...prefs,[property]:event.target.value}; + try{localStorage.setItem(key,JSON.stringify(prefs));}catch{} + apply();document.dispatchEvent(new CustomEvent('appearancechange',{detail:{...prefs}})); +}); +document.addEventListener('click',async event=>{ + const button=event.target.closest('[data-password-toggle],[data-copy]');if(!button)return; + if(button.dataset.passwordToggle) { + const field=document.getElementById(button.dataset.passwordToggle);if(!field)return; + field.type=field.type==='password'?'text':'password';button.dataset.i18n=field.type==='password'?'showPassword':'hidePassword';button.textContent=translate(button.dataset.i18n); + } else { + const node=document.getElementById(button.dataset.copy);if(!node)return; + try{await navigator.clipboard.writeText(node.textContent);document.getElementById('live-status').textContent=translate('copied');}catch{} + } +}); diff --git a/web/console/catalog.mjs b/web/console/catalog.mjs new file mode 100644 index 0000000..2acf831 --- /dev/null +++ b/web/console/catalog.mjs @@ -0,0 +1,33 @@ +export const catalog={ + 'zh-CN':{ + consoleLogin:'登录管理控制台',consoleLoginNote:'管理自己的记忆和连接。此登录不会自动向 ChatGPT 授权。',oauthLogin:'连接 ChatGPT',oauthLoginNote:'先验证你的 Mnemuron 账户,再明确允许只读访问。这不会登录管理控制台。',oauthConsent:'允许 ChatGPT 只读访问',oauthRestartHelp:'此链接只用于本次授权,并非固定的集成页面。请关闭此页,回到 ChatGPT 重新发起连接。',oauthRetry:'重试本次授权', + overview:'概览',memories:'记忆库',summaries:'分类与摘要',jobs:'整理任务',connections:'连接管理',models:'模型配置',security:'账户安全',audit:'审计记录',storage:'存储与备份',appearance:'外观设置',invitations:'注册码管理',accounts:'账户管理',handoff:'Handoff · 延后', + workspace:'我的空间',settings:'连接与设置',platform:'平台管理 · 权限待定',brandNote:'个人记忆,持续连接。',hero:'你的记忆,井然有序。',heroNote:'保留每条信息的来源,让上下文在会话间延续。', + login:'登录 Mnemuron',register:'创建你的账户',recover:'账户恢复',username:'用户名',password:'密码',otp:'动态验证码',invitation:'一次性注册码',continue:'继续',signIn:'登录',signOut:'退出当前会话',cancel:'取消',allow:'允许只读访问', + authNote:'请使用 Mnemuron 账户,不要输入 ChatGPT 密码。',inviteNote:'注册码由服务器管理员签发,仅可使用一次。',totp:'绑定验证器',totpNote:'使用验证器扫描二维码,或手动输入密钥。密钥只在绑定期间可见。',verify:'验证并继续',copy:'复制',copied:'已复制',showPassword:'显示密码',hidePassword:'隐藏密码', + recoveryCodes:'保存恢复码',recoveryNote:'这些恢复码仅显示一次。请离线保存,不要上传或分享。',acknowledge:'我已保存恢复码',pending:'正在等待安全绑定',pendingNote:'MFA 已验证。核心身份供应与恢复码确认全部完成后才能登录。',registered:'注册完成,请正常登录', + blocked:'策略待确认',blockedNote:'此操作尚未获准。页面展示不代表操作已开放。',unavailable:'暂不可用',retry:'重试',loading:'正在加载…',empty:'目前没有可显示的记录',noConnection:'尚无已授权连接',authenticated:'已验证账户',restartAuthorization:'账户或授权会话已变化。请返回请求连接的应用,重新发起授权。', + search:'搜索记忆',query:'搜索内容',readOnly:'只读',memoryCount:'原子记忆',sourceCount:'来源记录',summaryCount:'摘要',jobCount:'整理任务',recent:'最近的记忆',viewAll:'查看全部',detail:'记忆详情',sources:'来源',revisions:'版本',next:'下一页',previous:'上一页',close:'关闭',complete:'内容已完整读取',changed:'来源或版本已变化,请重新读取。', + appearanceNote:'配色不改变布局、功能或权限。偏好按账户分别保存。',theme:'颜色主题',mode:'明暗模式',language:'语言',light:'浅色',dark:'深色',a:'Neural Indigo',b:'Signal Teal',c:'Paper Amber', + securityNote:'登录需要密码与动态验证码。恢复证明组合尚待确认,不启用弱恢复。',modelsNote:'模型资源与费用策略待确认。不显示密钥或其他账户配置。',storageNote:'只展示本人记录的统计。整库备份、恢复与下载不在网页权限内。',platformNote:'当前账户没有平台管理授权。此页不返回其他账户的身份或统计。', + error:'请求未完成',errorNote:'请返回并重试。未授权、过期或依赖故障不会跳过安全校验。',identity:'当前账户',state:'状态',created:'创建时间',scope:'范围',status:'状态',consent:'授权连接',consentNote:'仅允许读取你授权的记忆和项目。不会写入记忆、切换任务或确认 Resume。', + policy:'权限边界',pendingPolicies:'未批准操作保持关闭',notProduction:'尚未生产晋级',noDemo:'所有数据来自当前账户;不可用时不会显示演示数据。',newMemory:'新建记忆',organize:'手动整理',export:'导出记忆',restore:'整库恢复',configure:'修改配置',issue:'网页签发',manage:'角色管理',revoke:'撤销授权', + passwordConfirm:'再次输入密码',alreadyShown:'密钥已显示过。本页不会重新展示;请使用已保存的验证器或恢复码。',oauthClient:'请求连接的应用',scopeIdentity:'识别你的 Mnemuron 账户',scopeOffline:'使用可撤销、轮换的刷新令牌保持连接',scopeMemory:'读取你授权的记忆',scopeProject:'读取你授权的项目上下文',systemLabel:'个人记忆系统',workspaceLabel:'个人工作空间',heroLabel:'让记忆持续连接',passwordTotp:'密码与动态验证码',back:'返回安全入口',expired:'此步骤已过期,请从安全入口重新开始。',loginFailed:'登录信息无效或账户尚不可用。请检查凭证后重试。',rateLimited:'尝试过于频繁,请稍后重试。',pendingStep:'此步骤暂不可用,请重新检查当前注册状态。', + }, + en:{ + consoleLogin:'Sign in to the console',consoleLoginNote:'Manage your memories and connections. This sign-in does not authorize ChatGPT.',oauthLogin:'Connect ChatGPT',oauthLoginNote:'Verify your Mnemuron account, then explicitly allow read-only access. This does not sign you in to the console.',oauthConsent:'Allow ChatGPT read-only access',oauthRestartHelp:'This link belongs to one authorization request, not a permanent integration page. Close this page and start a new connection from ChatGPT.',oauthRetry:'Retry this authorization', + overview:'Overview',memories:'Memory library',summaries:'Categories & summaries',jobs:'Organizer jobs',connections:'Connections',models:'Model settings',security:'Account security',audit:'Audit log',storage:'Storage & backup',appearance:'Appearance',invitations:'Registration codes',accounts:'Accounts',handoff:'Handoff · deferred', + workspace:'My workspace',settings:'Connections & settings',platform:'Platform · policy pending',brandNote:'Personal memory. Lasting context.',hero:'Your memory, thoughtfully organized.',heroNote:'Keep the provenance of every detail. Carry context across conversations.', + login:'Sign in to Mnemuron',register:'Create your account',recover:'Account recovery',username:'Username',password:'Password',otp:'Authenticator code',invitation:'One-time registration code',continue:'Continue',signIn:'Sign in',signOut:'Sign out of this session',cancel:'Cancel',allow:'Allow read-only access', + authNote:'Use your Mnemuron account, not your ChatGPT password.',inviteNote:'Registration codes are issued by your operator and can only be used once.',totp:'Connect your authenticator',totpNote:'Scan the QR code or enter the key manually. The key is available only during enrollment.',verify:'Verify and continue',copy:'Copy',copied:'Copied',showPassword:'Show password',hidePassword:'Hide password', + recoveryCodes:'Save your recovery codes',recoveryNote:'These codes are shown once. Store them offline. Do not upload or share them.',acknowledge:'I have saved my recovery codes',pending:'Waiting for secure identity binding',pendingNote:'MFA is verified. Login requires completed Core provisioning and recovery-code acknowledgement.',registered:'Registration complete. Please sign in.', + blocked:'Policy pending',blockedNote:'This operation has not been approved. Visibility does not grant permission.',unavailable:'Currently unavailable',retry:'Retry',loading:'Loading…',empty:'No records to display yet',noConnection:'No authorized connection yet',authenticated:'Verified account',restartAuthorization:'The account or authorization session changed. Return to the requesting application and start a new authorization.', + search:'Search memories',query:'Search query',readOnly:'Read-only',memoryCount:'Atomic memories',sourceCount:'Source records',summaryCount:'Summaries',jobCount:'Organizer jobs',recent:'Recent memories',viewAll:'View all',detail:'Memory detail',sources:'Sources',revisions:'Revision',next:'Next page',previous:'Previous page',close:'Close',complete:'Complete content returned',changed:'The source or revision changed. Read it again.', + appearanceNote:'Colors never change layout, capabilities or permissions. Preferences are stored per account.',theme:'Color theme',mode:'Color mode',language:'Language',light:'Light',dark:'Dark',a:'Neural Indigo',b:'Signal Teal',c:'Paper Amber', + securityNote:'Sign-in requires a password and authenticator code. Recovery proof policy is pending; no weaker recovery is enabled.',modelsNote:'Model resources and cost policy are pending. Keys and other accounts’ configuration are never shown.',storageNote:'Only your record totals are shown. Whole-database backup, restore and download are not web capabilities.',platformNote:'This account has no platform management grant. This page returns no other account identities or statistics.', + error:'Request not completed',errorNote:'Go back and try again. Authorization, expiry and dependency checks are never bypassed.',identity:'Current account',state:'State',created:'Created',scope:'Scope',status:'Status',consent:'Authorize connection',consentNote:'Read only memories and projects you authorize. No memory writes, task switching or Resume confirmation.', + policy:'Permission boundary',pendingPolicies:'Unapproved operations stay disabled',notProduction:'Not promoted to production',noDemo:'All data belongs to the current account. No demo fallback when unavailable.',newMemory:'New memory',organize:'Run organizer',export:'Export memories',restore:'Restore database',configure:'Change settings',issue:'Issue in browser',manage:'Manage roles',revoke:'Revoke grant', + passwordConfirm:'Confirm password',alreadyShown:'The secret was already displayed. It is not shown again; use your saved authenticator or recovery codes.',oauthClient:'Application requesting access',scopeIdentity:'Identify your Mnemuron account',scopeOffline:'Keep this connection with revocable, rotating refresh tokens',scopeMemory:'Read memories you authorize',scopeProject:'Read project context you authorize',systemLabel:'PERSONAL MEMORY SYSTEM',workspaceLabel:'PERSONAL WORKSPACE',heroLabel:'YOUR MEMORY, CONNECTED',passwordTotp:'Password and authenticator code',back:'Return to the safe entry point',expired:'This step expired. Start again from the safe entry point.',loginFailed:'The sign-in details or account are unavailable. Check your credentials and try again.',rateLimited:'Too many attempts. Please try again later.',pendingStep:'This step is unavailable. Check your current registration status.', + } +}; +export const text=(key,locale='zh-CN')=>catalog[locale]?.[key]??catalog.en[key]??key; diff --git a/web/console/render.mjs b/web/console/render.mjs new file mode 100644 index 0000000..933d189 --- /dev/null +++ b/web/console/render.mjs @@ -0,0 +1,27 @@ +import fs from 'node:fs'; +import {text} from './catalog.mjs'; +export const escapeHtml=value=>String(value).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +export const pages=['overview','memories','summaries','jobs','connections','models','security','audit','storage','appearance','invitations','accounts']; +export const routeTitle=route=>route==='/app' || route==='/app/'?'overview':pages.find(p=>route===`/app/${p}`)??null; +export const label=(key,tag='span')=>`<${tag} data-i18n="${key}">${text(key)}`; +export const appearanceControls=()=>`
    `; +export function renderPage({title,body='',auth=false,authPurpose='console',account=null,csrf='',page='overview'}) { + const brandContent='Mnemuron'; + const brand=auth&&authPurpose==='oauth'?`${brandContent}`:`${brandContent}`; + const nav=pages.map((p,i)=>`${i===0?label('workspace','h2'):i===4?label('settings','h2'):i===10?label('platform','h2'):''}${label(p)}`).join(''); + const inside=auth?`
    ${brand}

    ${text('systemLabel')}

    ${label('hero','h1')}${label('heroNote','p')}
    ${label('brandNote','p')}
    ${appearanceControls()}
    ${label(title,'h1')}${body}
    `: + `
    ${label('workspace')} ${label(title)}
    ${appearanceControls()}
    ${body||`
    ${label(title,'h1')}${label('loading','p')}
    `}
    ${label('readOnly')} · ${label('notProduction')}
    ${label('detail','h2').replace('${text('close')}
    `; + return `Mnemuron · ${text(title)}${auth?'':''}${inside}

    `; +} +export function serveAsset(request,response,pathname) { + const file=pathname.match(/^\/assets\/(styles\.css|appearance\.mjs|catalog\.mjs|app\.mjs|session-state\.mjs)$/)?.[1]; + if(!file||request.method!=='GET')return false; + const content=fs.readFileSync(new URL(file,import.meta.url)); + response.writeHead(200,{'content-type':file.endsWith('.css')?'text/css; charset=utf-8':'text/javascript; charset=utf-8','cache-control':'no-cache','x-content-type-options':'nosniff'});response.end(content);return true; +} +export function sendPage(response,options,{status=200,redirectUri=''}={}) { + response.writeHead(status,{'content-type':'text/html; charset=utf-8','cache-control':'no-store', + 'content-security-policy':`default-src 'none'; style-src 'self'; script-src 'self'; connect-src 'self'; img-src 'self'; form-action 'self' ${redirectUri}; frame-ancestors 'none'; base-uri 'none'`, + 'x-frame-options':'DENY','referrer-policy':'same-origin','x-content-type-options':'nosniff'}); + response.end(renderPage(options)); +} diff --git a/web/console/session-state.mjs b/web/console/session-state.mjs new file mode 100644 index 0000000..f32d0fb --- /dev/null +++ b/web/console/session-state.mjs @@ -0,0 +1,8 @@ +// A late response belongs to the request's account epoch, never to the next login. +export class SessionState { + constructor(account){this.account=account;this.epoch=0;this.controllers=new Set();} + ticket(){const controller=new AbortController();this.controllers.add(controller);return {account:this.account,epoch:this.epoch,controller};} + accepts(ticket){return !!this.account&&ticket.account===this.account&&ticket.epoch===this.epoch&&!ticket.controller.signal.aborted;} + finish(ticket){this.controllers.delete(ticket.controller);} + clear(){this.epoch++;this.account=null;for(const c of this.controllers)c.abort();this.controllers.clear();} +} diff --git a/web/console/styles.css b/web/console/styles.css new file mode 100644 index 0000000..2de9bd6 --- /dev/null +++ b/web/console/styles.css @@ -0,0 +1,15 @@ +:root{--sidebar-width:232px;--topbar-height:72px;--radius-card:16px;--radius-control:9px;--gutter:32px;--gap:20px;--metric-gap:16px;--card-padding:22px;--text-size:14px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:14px;color-scheme:light} +[data-theme="a"][data-mode="light"]{--bg:#F6F6FB;--surface:#FFFFFF;--surface-2:#FAFAFE;--text:#20212C;--muted:#646879;--border:#E5E5EF;--accent:#6554D9;--soft:#EFECFF;--on-accent:#FFFFFF} +[data-theme="a"][data-mode="dark"]{--bg:#11121B;--surface:#1B1D2B;--surface-2:#222434;--text:#F1F2F7;--muted:#A7ADBF;--border:#35384D;--accent:#AC9EFF;--soft:#2F294C;--on-accent:#171227} +[data-theme="b"][data-mode="light"]{--bg:#F2F7F6;--surface:#FFFFFF;--surface-2:#F7FBFA;--text:#16332F;--muted:#57706C;--border:#D6E5E1;--accent:#08776B;--soft:#DFF3EE;--on-accent:#FFFFFF} +[data-theme="b"][data-mode="dark"]{--bg:#0C1818;--surface:#142626;--surface-2:#193030;--text:#E9F5F1;--muted:#9FBBB5;--border:#2A4943;--accent:#53D5BD;--soft:#183D35;--on-accent:#09211C} +[data-theme="c"][data-mode="light"]{--bg:#F8F5EE;--surface:#FFFDF9;--surface-2:#F9F6EF;--text:#352C25;--muted:#776B5E;--border:#E7DED1;--accent:#8D5720;--soft:#F2E7D6;--on-accent:#FFFFFF} +[data-theme="c"][data-mode="dark"]{--bg:#1B1814;--surface:#28231D;--surface-2:#302A23;--text:#F4EADC;--muted:#C0AF99;--border:#4D4032;--accent:#E6B57B;--soft:#3D3022;--on-accent:#271C10} +[data-mode="light"]{--good:#16745D;--warn:#956112;--bad:#B33752} +[data-mode="dark"]{--good:#6BDBB6;--warn:#EDC579;--bad:#FF8CA3;color-scheme:dark} +*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);line-height:1.65}a{color:var(--accent);text-decoration:none}button,input,select{font:inherit}button,select{cursor:pointer}button{border:1px solid var(--border);border-radius:var(--radius-control);background:var(--surface);color:var(--text);padding:9px 14px;min-height:40px}button.primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent);font-weight:600}button:disabled{cursor:not-allowed;color:var(--muted);background:var(--surface-2)}input,select{border:1px solid var(--border);border-radius:var(--radius-control);background:var(--surface);color:var(--text);padding:10px 12px;min-width:0}input{width:100%;min-height:46px}label{font-size:13px;display:block;margin:14px 0 6px}button:focus-visible,input:focus-visible,select:focus-visible,a:focus-visible,summary:focus-visible{outline:3px solid var(--accent);outline-offset:3px}h1,h2,h3,p{margin-top:0}h1{font-size:30px;line-height:1.3;letter-spacing:-.6px;margin-bottom:12px}h2{font-size:17px;line-height:1.5}p{color:var(--muted)}small{display:block;font-size:12px;color:var(--muted)}.brand{display:flex;align-items:center;gap:10px;font-size:24px;letter-spacing:-.8px;font-weight:750;color:var(--text)}.brand-icon{display:grid;place-items:center;background:var(--accent);color:var(--on-accent);width:32px;height:32px;border-radius:10px;font-size:21px;font-weight:500}.eyebrow{font-size:9px;letter-spacing:2px;margin:6px 0 24px;color:var(--muted)}.sidebar{width:var(--sidebar-width);position:fixed;inset:0 auto 0 0;background:var(--surface);border-right:1px solid var(--border);padding:24px 16px;overflow:auto;z-index:2}.sidebar>.brand,.sidebar>.eyebrow{margin-left:8px}.account-badge{display:flex;align-items:center;gap:10px;background:var(--surface-2);border:1px solid var(--border);border-radius:12px;padding:12px 10px;margin:24px 0}.account-badge strong{font-size:13px;overflow-wrap:anywhere}.avatar{flex:none;display:grid;place-items:center;width:32px;height:36px;background:var(--soft);color:var(--accent);border-radius:9px;font-weight:600}.sidebar nav h2{font-size:10px;color:var(--muted);font-weight:400;margin:20px 10px 7px}.sidebar nav a{display:flex;align-items:center;gap:12px;min-height:42px;border-radius:9px;padding:9px 12px;color:var(--muted);line-height:1.4;margin:3px 0}.sidebar nav a[aria-current]{background:var(--soft);color:var(--accent);font-weight:600}.nav-icon{font-size:18px;min-width:17px}.handoff{border-top:1px solid var(--border);font-size:11px;padding:20px 10px 0;margin-top:24px}.workspace{margin-left:var(--sidebar-width);min-width:0}.topbar{min-height:var(--topbar-height);display:flex;align-items:center;justify-content:space-between;gap:16px;padding:12px var(--gutter);background:var(--surface);border-bottom:1px solid var(--border);font-size:12px}.appearance-controls{display:flex;gap:8px;flex-wrap:wrap;align-items:center}.appearance-controls select{padding:5px 8px;font-size:12px;min-height:32px;max-width:160px}.topbar form{margin:0}.quiet{font-size:12px}#main{padding:var(--gutter);max-width:1800px;margin:auto;outline:none}.page-heading{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:24px}.page-heading p{margin-bottom:0}.hero{display:flex;align-items:center;justify-content:space-between;gap:32px;background:var(--soft);border:1px solid var(--border);border-radius:var(--radius-card);padding:32px;margin-bottom:20px;overflow:hidden}.hero h2{font-size:24px}.hero p{max-width:650px}.orb{display:grid;place-items:center;flex:none;width:100px;height:100px;border:1px solid var(--accent);border-radius:50%;color:var(--accent);font-size:48px}.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--metric-gap);margin:20px 0 24px}.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-card);padding:var(--card-padding);min-width:0}.metric strong{font-size:30px;letter-spacing:-1px;display:block;margin:12px 0 2px}.metric .tag{float:right}.tag{display:inline-block;background:var(--soft);color:var(--accent);border-radius:6px;padding:2px 8px;font-size:11px;overflow-wrap:anywhere}.tag.warning{color:var(--warn);background:var(--surface-2)}.columns{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(260px,1fr);gap:var(--gap)}.card-heading{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:12px}.card-heading h2{margin:0}.memory-row{display:flex;align-items:center;gap:12px;padding:18px 0;border-top:1px solid var(--border)}.memory-row .memory-link{border:0;padding:0;background:none;text-align:left;flex:1;overflow-wrap:anywhere}.memory-row .glyph{background:var(--soft);color:var(--accent);border-radius:8px;padding:8px 10px}.memory-row small{margin-top:4px}.policy-box,.empty{border:1px solid var(--border);background:var(--surface-2);border-radius:12px;padding:20px;margin:16px 0;overflow-wrap:anywhere}.empty{padding:50px 24px;text-align:center}.toolbar{display:flex;align-items:end;gap:12px;margin:16px 0 24px}.toolbar label{flex:1;margin:0}.toolbar input{margin-top:6px}.pagination{display:flex;justify-content:flex-end;gap:12px;margin-top:20px}table{border-collapse:collapse;width:100%;font-size:13px;table-layout:fixed}td,th{text-align:left;vertical-align:top;border-bottom:1px solid var(--border);padding:14px 10px;overflow-wrap:anywhere}th{font-weight:500;color:var(--muted);font-size:12px}pre,code{font-family:ui-monospace,SFMono-Regular,monospace;overflow-wrap:anywhere;white-space:pre-wrap}.body-content{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.8}footer{padding:18px var(--gutter);color:var(--muted);font-size:11px;display:flex;gap:12px}.actions{display:flex;gap:10px;flex-wrap:wrap}.form-content form button.primary{width:100%;margin-top:20px}.form-content form .actions button{width:auto}.auth-layout{display:grid;grid-template-columns:40% 60%;min-height:100vh}.auth-brand{padding:42px;display:flex;flex-direction:column;justify-content:space-between;background:var(--soft);border-right:1px solid var(--border);min-width:0}.auth-brand h1{font-size:30px;max-width:370px}.auth-brand p{max-width:370px}.auth-orbit{width:200px;height:200px;display:grid;place-items:center;border:1px solid var(--accent);border-radius:50%;margin:40px auto;color:var(--accent)}.auth-orbit span{border:1px solid var(--accent);border-radius:24px;width:80px;height:80px;text-align:center;font-size:48px}.auth-form{display:flex;flex-direction:column;background:var(--surface);min-width:0;padding:24px 36px}.auth-form>header{display:flex;justify-content:flex-end;min-height:48px}.form-content{width:100%;max-width:440px;margin:70px auto}.form-content h1{font-size:30px}.form-links{display:flex;justify-content:space-between;gap:20px;margin-top:28px}.qr{display:block;width:220px;max-width:100%;background:#fff;border-radius:12px;margin:22px auto;padding:10px}.qr svg{display:block;width:100%;height:auto}.secret{font-size:13px;padding:14px;border:1px solid var(--border);border-radius:9px}.error-message{color:var(--bad);border:1px solid var(--bad);padding:12px;border-radius:9px}.recovery-codes{font-family:ui-monospace,monospace;font-size:12px;padding:12px;background:var(--surface-2);overflow-wrap:anywhere}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;padding:0;border:0;margin:-1px}.skip-link{position:fixed;top:-80px;left:20px;background:var(--surface);padding:10px;z-index:10}.skip-link:focus{top:8px}dialog{margin:0 0 0 auto;max-width:min(640px,90vw);width:640px;max-height:100vh;height:100vh;border:0;border-left:1px solid var(--border);background:var(--surface);color:var(--text);padding:28px;overflow:auto}dialog::backdrop{background:#12132166}.dialog-header{display:flex;align-items:center;justify-content:space-between;gap:16px;position:sticky;top:-28px;background:var(--surface);padding:16px 0;z-index:1}.dialog-header h2{margin:0}.detail-meta{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:20px}.detail-source{padding:12px 0;border-bottom:1px solid var(--border);overflow-wrap:anywhere}details{margin:12px 0}summary{cursor:pointer;color:var(--accent)} +#main.auth-layout{padding:0;max-width:none;margin:0} +button:not(.primary):not(:disabled),input,select{border-color:var(--muted)} +@media(max-width:1280px){:root{--gutter:24px}.topbar{flex-wrap:wrap}.auth-brand{padding:32px}.metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.columns{grid-template-columns:minmax(0,1fr)}} +@media(max-width:800px){.sidebar{position:static;width:auto;border-right:0}.sidebar nav{display:flex;flex-wrap:wrap}.sidebar nav h2{width:100%}.workspace{margin-left:0}.auth-layout{grid-template-columns:minmax(0,1fr)}.auth-brand{display:none}.auth-form{padding:24px}.form-content{margin:32px auto}.metrics{grid-template-columns:minmax(0,1fr)}.topbar{align-items:start}.toolbar{flex-wrap:wrap}.hero .orb{display:none}} +@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}