From edfe0dff6a64f1f2b6d2e86758729a37a814e58a Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Wed, 18 Mar 2026 12:46:59 -0400 Subject: [PATCH 1/8] Add brief ingestion to factory entrypoint --- packages/software-factory/README.md | 29 +- .../docs/one-shot-factory-go-plan.md | 39 ++- .../docs/software-factory-testing-strategy.md | 3 + packages/software-factory/package.json | 11 +- .../src/cli/factory-entrypoint.ts | 41 ++- .../software-factory/src/factory-brief.ts | 329 ++++++++++++++++++ .../src/factory-entrypoint.ts | 85 ++++- .../src/prompts/brief-judgment.ts | 37 ++ packages/software-factory/src/realm-auth.ts | 164 +++++++++ .../tests/factory-brief.test.ts | 132 +++++++ .../factory-entrypoint.integration.test.ts | 160 ++++++--- .../tests/factory-entrypoint.test.ts | 103 +++++- packages/software-factory/tests/index.ts | 2 + .../software-factory/tests/realm-auth.test.ts | 198 +++++++++++ pnpm-lock.yaml | 45 ++- 15 files changed, 1268 insertions(+), 110 deletions(-) create mode 100644 packages/software-factory/src/factory-brief.ts create mode 100644 packages/software-factory/src/prompts/brief-judgment.ts create mode 100644 packages/software-factory/src/realm-auth.ts create mode 100644 packages/software-factory/tests/factory-brief.test.ts create mode 100644 packages/software-factory/tests/realm-auth.test.ts diff --git a/packages/software-factory/README.md b/packages/software-factory/README.md index d526fc48ca..12e9e2310b 100644 --- a/packages/software-factory/README.md +++ b/packages/software-factory/README.md @@ -39,7 +39,7 @@ startup do not require a separate external realm server on `http://localhost:420 - `pnpm smoke:realm` - Boots the isolated realm server, fetches `project-demo` as card JSON, and exits - `pnpm factory:go -- --brief-url --target-realm-path ` - - Validates one-shot factory inputs and prints a machine-readable run summary + - Fetches and normalizes a brief, validates one-shot inputs, and prints a machine-readable run summary - `pnpm test` - Runs package tests from `tests/*.test.ts` and `tests/*.spec.ts` - `pnpm test:node` @@ -71,6 +71,7 @@ Usage: pnpm factory:go -- \ --brief-url http://localhost:4201/software-factory/Wiki/sticky-note \ --target-realm-path /path/to/target-realm \ + [--auth-token "Bearer "] \ [--target-realm-url http://localhost:4201/hassan/personal/] \ [--mode implement] ``` @@ -79,6 +80,10 @@ Parameters: - `--brief-url` - Required. Absolute URL for the source brief card the factory should use as input. + - The command fetches card source JSON from this URL and includes normalized brief metadata in the summary. +- `--auth-token` + - Optional. Explicit `Authorization` header value to use when fetching the brief. + - When omitted, `factory:go` will try to resolve realm auth from the active Boxel profile for matching realm-server URLs. - `--target-realm-path` - Required. Local filesystem path to the Boxel realm where the factory should write output. - `--target-realm-url` @@ -88,6 +93,28 @@ Parameters: - `--help` - Optional. Prints the command usage and exits. +Getting an auth token: + +- If the brief lives on a private realm and you want to pass the token explicitly, first ask the package session helper for the realm token: + +```bash +pnpm boxel:session -- --realm http://localhost:4201/software-factory/ +``` + +- The command prints JSON with a `boxelSession` object keyed by realm URL. Extract the token for the brief's realm and pass it through `--auth-token`: + +```bash +AUTH_TOKEN="$(pnpm --silent boxel:session -- --realm http://localhost:4201/software-factory/ | jq -r '.boxelSession[\"http://localhost:4201/software-factory/\"]')" + +pnpm factory:go -- \ + --brief-url http://localhost:4201/software-factory/Wiki/sticky-note \ + --auth-token "$AUTH_TOKEN" \ + --target-realm-path /path/to/target-realm +``` + +- When the brief is in a public realm, you do not need this flag. +- When the brief is in a private realm, pass `--auth-token` with a realm token that can read that brief. + ## Layout - `test-fixtures/darkfactory-adopter/` diff --git a/packages/software-factory/docs/one-shot-factory-go-plan.md b/packages/software-factory/docs/one-shot-factory-go-plan.md index 21f5e9c400..038e728ad1 100644 --- a/packages/software-factory/docs/one-shot-factory-go-plan.md +++ b/packages/software-factory/docs/one-shot-factory-go-plan.md @@ -111,8 +111,8 @@ Required behavior: - fetch the brief card JSON - normalize the brief into a concise internal representation -- detect whether the brief is vague -- if vague, automatically bias toward a thin MVP +- prepare a prompt for the AI to decide whether to default to a thin MVP +- prepare a prompt for the AI to create clarification or review tickets when the brief needs more guidance ### Phase 2: Target Realm Preparation @@ -243,9 +243,15 @@ The first version should support: Add a script: ```json -"factory:go": "ts-node --esm --transpileOnly scripts/factory-go.ts" +"factory:go": "ts-node --transpileOnly src/cli/factory-entrypoint.ts" ``` +For software-factory CLI entrypoints, favor `ts-node --transpileOnly` over `tsx`. + +- it matches the execution model already used by `realm-server` +- it avoids the decorator/runtime incompatibilities we hit when `tsx` imports `runtime-common` auth code +- it keeps package CLI entrypoints aligned with the `runtime-common` auth infrastructure instead of forcing parallel implementations + Expected usage: ```bash @@ -333,15 +339,26 @@ Responsibilities: - fetch a brief card by URL - extract useful fields from card JSON -- normalize vague briefs into a simple planning shape +- normalize the brief into a concise planning input - emit metadata like: - title - summary - content - source URL - - ambiguity score or `isVague` flag + - AI judgment prompt for thin-MVP vs broader-first-pass planning + - AI judgment prompt instructions for clarification and review follow-up tickets + +For version one, this helper can stay deterministic by generating a stable prompt template rather than trying to compute vagueness heuristically in code. -For version one, the `isVague` check can be heuristic and simple. +### D1. `src/prompts/brief-judgment.ts` + +Dedicated prompt template module for brief judgment. + +Responsibilities: + +- hold the reusable runtime prompt instructions for thin-MVP vs broader-first-pass decisions +- keep prompt wording out of the brief-normalization helper +- let the brief helper inject brief-specific context into one shared template instead of repeating an inline string ### E. `scripts/lib/factory-loop.ts` @@ -446,7 +463,7 @@ Optional later additions: "brief": { "url": "http://localhost:4201/software-factory/Wiki/sticky-note", "title": "Sticky Note", - "isVague": true + "aiJudgmentPrompt": "Review this factory brief and decide whether to default to a thin MVP..." }, "targetRealm": { "path": "/.../personal", @@ -472,6 +489,12 @@ Optional later additions: This keeps the process inspectable and resumable. +Brief intake should not assume public realm access. + +- `factory:go` should fetch the brief with `Accept: application/vnd.card+source` +- when the brief URL is on the active Boxel realm-server origin, the CLI should try to resolve a realm JWT from the active Boxel profile before fetching +- the CLI should also allow an explicit brief auth override for cases where the caller already has an `Authorization` header value to use + ## Acceptance Criteria For The First `factory:go` - a user can point to a brief URL and a target realm path @@ -479,7 +502,7 @@ This keeps the process inspectable and resumable. - exactly one ticket becomes active - rerunning does not create duplicate starter artifacts - the flow can proceed directly into implementation work -- the system prefers a thin MVP when the brief is vague +- the brief normalization output gives the AI enough context to choose thin-MVP vs broader-first-pass planning and request clarification or review tickets when needed ## Recommended Delivery Order diff --git a/packages/software-factory/docs/software-factory-testing-strategy.md b/packages/software-factory/docs/software-factory-testing-strategy.md index 63e9790ba8..018ef8acfc 100644 --- a/packages/software-factory/docs/software-factory-testing-strategy.md +++ b/packages/software-factory/docs/software-factory-testing-strategy.md @@ -140,8 +140,11 @@ These should be covered with unit tests and focused integration tests. Hermetic requirement for this layer: - deterministic `factory:go` tests must not depend on an ambient realm server on `http://localhost:4201/` +- deterministic `factory:go` tests must not hard-code `localhost:4201` or port `4201` just to get an absolute URL shape - when a test only needs an absolute URL shape, use a synthetic URL such as `https://briefs.example.test/...` +- prefer reserved synthetic hosts such as `*.example.test` or dynamically assigned local test-server ports over canonical dev ports - when a test needs a live realm, use the isolated software-factory harness rather than external local infrastructure +- the only acceptable exceptions are harness-level redirect tests that intentionally intercept a canonical realm URL without depending on a server actually listening on that port Debugging note: diff --git a/packages/software-factory/package.json b/packages/software-factory/package.json index ab54127e43..d41ea9cfd7 100644 --- a/packages/software-factory/package.json +++ b/packages/software-factory/package.json @@ -8,8 +8,8 @@ "boxel:pick-ticket": "NODE_NO_WARNINGS=1 ts-node --transpileOnly scripts/pick-ticket.ts", "boxel:search": "NODE_NO_WARNINGS=1 ts-node --transpileOnly scripts/boxel-search.ts", "boxel:session": "NODE_NO_WARNINGS=1 ts-node --transpileOnly scripts/boxel-session.ts", - "cache:prepare": "tsx src/cli/cache-realm.ts", - "factory:go": "tsx src/cli/factory-entrypoint.ts", + "cache:prepare": "NODE_NO_WARNINGS=1 ts-node --transpileOnly src/cli/cache-realm.ts", + "factory:go": "NODE_NO_WARNINGS=1 ts-node --transpileOnly src/cli/factory-entrypoint.ts", "lint": "concurrently \"pnpm:lint:*(!fix)\" --names \"lint:\"", "lint:fix": "concurrently \"pnpm:lint:*:fix\" --names \"fix:\"", "lint:js": "eslint . --report-unused-disable-directives --cache", @@ -17,9 +17,9 @@ "lint:format": "prettier --check .", "lint:format:fix": "prettier --write .", "lint:glint": "glint", - "serve:realm": "tsx src/cli/serve-realm.ts", - "serve:support": "tsx src/cli/serve-support.ts", - "smoke:realm": "tsx src/cli/smoke-realm.ts", + "serve:realm": "NODE_NO_WARNINGS=1 ts-node --transpileOnly src/cli/serve-realm.ts", + "serve:support": "NODE_NO_WARNINGS=1 ts-node --transpileOnly src/cli/serve-support.ts", + "smoke:realm": "NODE_NO_WARNINGS=1 ts-node --transpileOnly src/cli/smoke-realm.ts", "test": "NODE_NO_WARNINGS=1 ts-node --transpileOnly scripts/test.ts", "test:all": "NODE_NO_WARNINGS=1 ts-node --transpileOnly scripts/test.ts", "test:node": "NODE_NO_WARNINGS=1 ts-node --transpileOnly scripts/test.ts --node-only", @@ -55,7 +55,6 @@ "qunit": "catalog:", "tmp": "catalog:", "ts-node": "^10.9.1", - "tsx": "^4.21.0", "typescript": "catalog:" }, "volta": { diff --git a/packages/software-factory/src/cli/factory-entrypoint.ts b/packages/software-factory/src/cli/factory-entrypoint.ts index 6c24c52835..4cee7fc5d1 100644 --- a/packages/software-factory/src/cli/factory-entrypoint.ts +++ b/packages/software-factory/src/cli/factory-entrypoint.ts @@ -1,27 +1,36 @@ import { FactoryEntrypointUsageError, - buildFactoryEntrypointSummary, getFactoryEntrypointUsage, parseFactoryEntrypointArgs, + runFactoryEntrypoint, wantsFactoryEntrypointHelp, } from '../factory-entrypoint'; +import { FactoryBriefError } from '../factory-brief'; -try { - if (wantsFactoryEntrypointHelp(process.argv.slice(2))) { - console.log(getFactoryEntrypointUsage()); +async function main(): Promise { + try { + if (wantsFactoryEntrypointHelp(process.argv.slice(2))) { + console.log(getFactoryEntrypointUsage()); + process.exit(0); + } + + let options = parseFactoryEntrypointArgs(process.argv.slice(2)); + let summary = await runFactoryEntrypoint(options); + console.log(JSON.stringify(summary, null, 2)); process.exit(0); - } + } catch (error) { + if (error instanceof FactoryEntrypointUsageError) { + console.error(error.message); + console.error(''); + console.error(getFactoryEntrypointUsage()); + } else if (error instanceof FactoryBriefError) { + console.error(error.message); + } else { + console.error(error); + } - let options = parseFactoryEntrypointArgs(process.argv.slice(2)); - let summary = buildFactoryEntrypointSummary(options); - console.log(JSON.stringify(summary, null, 2)); -} catch (error) { - if (error instanceof FactoryEntrypointUsageError) { - console.error(error.message); - console.error(''); - console.error(getFactoryEntrypointUsage()); - } else { - console.error(error); + process.exit(1); } - process.exit(1); } + +void main(); diff --git a/packages/software-factory/src/factory-brief.ts b/packages/software-factory/src/factory-brief.ts new file mode 100644 index 0000000000..61089a8d93 --- /dev/null +++ b/packages/software-factory/src/factory-brief.ts @@ -0,0 +1,329 @@ +import type { LooseSingleCardDocument } from '@cardstack/runtime-common'; + +import { renderFactoryBriefJudgmentPrompt } from './prompts/brief-judgment'; + +const markdownLinkPattern = /\[([^\]]+)\]\([^)]+\)/g; +const wikiLinkPattern = /\[\[([^[\]]+)\]\]/g; +const markdownHeadingReplacePattern = /^\s*#{1,6}\s+/gm; +const markdownListReplacePattern = /^\s*[-*+]\s+/gm; +const whitespacePattern = /\s+/g; +const cardSourceMimeType = 'application/vnd.card+source'; + +export interface FactoryBrief { + title: string; + sourceUrl: string; + content: string; + contentSummary: string; + tags: string[]; + aiJudgmentPrompt: string; +} + +export interface FactoryBriefFetchRequestInit { + headers?: Record; +} + +export interface FactoryBriefFetchResponse { + ok: boolean; + status: number; + statusText: string; + json(): Promise; +} + +export interface FactoryBriefFetch { + ( + input: string | URL, + init?: FactoryBriefFetchRequestInit, + ): Promise; +} + +interface BoxelBriefCardInfo { + name?: string | null; + summary?: string | null; +} + +interface FactoryBriefCardAttributes { + title?: string | null; + name?: string | null; + content?: string | null; + tags?: Array | null; + cardInfo?: BoxelBriefCardInfo | null; +} + +interface FactoryBriefLoadOptions { + fetch?: FactoryBriefFetch; + authorization?: string; +} + +export class FactoryBriefError extends Error { + constructor(message: string) { + super(message); + this.name = 'FactoryBriefError'; + } +} + +export async function loadFactoryBrief( + sourceUrl: string, + options?: FactoryBriefLoadOptions, +): Promise { + let fetchImpl = options?.fetch ?? globalThis.fetch; + + if (typeof fetchImpl !== 'function') { + throw new FactoryBriefError('Global fetch is not available'); + } + + let response; + + try { + response = await fetchImpl(sourceUrl, { + headers: { + accept: cardSourceMimeType, + ...(options?.authorization + ? { authorization: options.authorization } + : {}), + }, + }); + } catch (error) { + throw new FactoryBriefError( + `Failed to fetch brief from ${sourceUrl}: ${formatErrorMessage(error)}`, + ); + } + + if (!response.ok) { + throw new FactoryBriefError( + `Failed to fetch brief from ${sourceUrl}: HTTP ${response.status} ${response.statusText}`.trim(), + ); + } + + let payload; + + try { + payload = await response.json(); + } catch (error) { + throw new FactoryBriefError( + `Brief response from ${sourceUrl} was not valid JSON: ${formatErrorMessage(error)}`, + ); + } + + return normalizeFactoryBrief(payload, sourceUrl); +} + +export function normalizeFactoryBrief( + payload: unknown, + sourceUrl: string, +): FactoryBrief { + let document = parseBriefDocument(payload); + let attributes = parseFactoryBriefCardAttributes(document); + let cardInfo = attributes.cardInfo ?? {}; + let explicitTitle = firstNonEmptyString([ + valueAsTrimmedString(cardInfo.name), + valueAsTrimmedString(attributes.title), + valueAsTrimmedString(attributes.name), + ]); + let title = explicitTitle ?? inferTitleFromUrl(sourceUrl); + let summary = valueAsTrimmedString(cardInfo.summary); + let content = valueAsTrimmedString(attributes.content) ?? ''; + let tags = normalizeTags(attributes.tags); + let contentSummary = buildContentSummary(summary, content, title); + + return { + title, + sourceUrl, + content, + contentSummary, + tags, + aiJudgmentPrompt: renderFactoryBriefJudgmentPrompt({ + title, + sourceUrl, + contentSummary, + content, + tags, + }), + }; +} + +function parseBriefDocument(payload: unknown): LooseSingleCardDocument { + if (!isObject(payload)) { + throw new FactoryBriefError('Expected brief card payload to be an object'); + } + + let data = payload.data; + + if (!isObject(data)) { + throw new FactoryBriefError( + 'Expected brief card payload to include data.attributes', + ); + } + + let attributes = data.attributes; + + if (!isObject(attributes)) { + throw new FactoryBriefError( + 'Expected brief card payload to include data.attributes', + ); + } + + return payload as unknown as LooseSingleCardDocument; +} + +function buildContentSummary( + summary: string | undefined, + content: string, + title: string, +): string { + if (summary) { + return summary; + } + + let normalizedContent = collapseWhitespace(stripMarkdown(content)); + + if (normalizedContent === '') { + return `No content summary was available for ${title}.`; + } + + let firstSentence = normalizedContent.match(/^(.{1,220}?[.!?])(?:\s|$)/); + + if (firstSentence) { + return firstSentence[1]; + } + + if (normalizedContent.length <= 220) { + return normalizedContent; + } + + let truncated = normalizedContent.slice(0, 217); + let lastSpace = truncated.lastIndexOf(' '); + + if (lastSpace >= 120) { + truncated = truncated.slice(0, lastSpace); + } + + return `${truncated}...`; +} + +function normalizeTags(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((tag) => valueAsTrimmedString(tag)) + .filter((tag): tag is string => Boolean(tag)); +} + +function inferTitleFromUrl(sourceUrl: string): string { + let url = new URL(sourceUrl); + let segments = url.pathname.split('/').filter(Boolean); + let slug = segments.at(-1) ?? 'brief'; + + return slug + .split(/[-_]+/g) + .filter(Boolean) + .map((segment) => segment[0].toUpperCase() + segment.slice(1)) + .join(' '); +} + +function stripMarkdown(value: string): string { + return value + .replace(markdownLinkPattern, '$1') + .replace(wikiLinkPattern, '$1') + .replace(markdownHeadingReplacePattern, '') + .replace(markdownListReplacePattern, '') + .replace(/[*_`>#]/g, ' '); +} + +function firstNonEmptyString( + values: Array, +): string | undefined { + for (let value of values) { + if (value) { + return value; + } + } + + return undefined; +} + +function valueAsTrimmedString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + let trimmed = value.trim(); + + return trimmed === '' ? undefined : trimmed; +} + +function collapseWhitespace(value: string): string { + return value.replace(whitespacePattern, ' ').trim(); +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function parseFactoryBriefCardAttributes( + document: LooseSingleCardDocument, +): FactoryBriefCardAttributes { + let attributes = document.data.attributes; + + if (!isObject(attributes)) { + return {}; + } + + return { + title: parseOptionalString(attributes.title), + name: parseOptionalString(attributes.name), + content: parseOptionalString(attributes.content), + tags: parseOptionalStringArray(attributes.tags), + cardInfo: parseBriefCardInfo(attributes.cardInfo), + }; +} + +function parseBriefCardInfo( + value: unknown, +): BoxelBriefCardInfo | null | undefined { + if (value === null) { + return null; + } + + if (!isObject(value)) { + return undefined; + } + + return { + name: parseOptionalString(value.name), + summary: parseOptionalString(value.summary), + }; +} + +function parseOptionalString(value: unknown): string | null | undefined { + if (value === null) { + return null; + } + + return typeof value === 'string' ? value : undefined; +} + +function parseOptionalStringArray( + value: unknown, +): Array | null | undefined { + if (value === null) { + return null; + } + + if (!Array.isArray(value)) { + return undefined; + } + + return value.map((item) => { + if (item === null) { + return null; + } + + return typeof item === 'string' ? item : null; + }); +} + +function formatErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/software-factory/src/factory-entrypoint.ts b/packages/software-factory/src/factory-entrypoint.ts index a749965297..25f74b1155 100644 --- a/packages/software-factory/src/factory-entrypoint.ts +++ b/packages/software-factory/src/factory-entrypoint.ts @@ -2,29 +2,39 @@ import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; import { parseArgs as parseNodeArgs } from 'node:util'; +import { + loadFactoryBrief, + type FactoryBrief, + type FactoryBriefFetch, +} from './factory-brief'; +import { createBoxelRealmFetch } from './realm-auth'; + const allowedModes = ['bootstrap', 'implement', 'resume'] as const; export type FactoryEntrypointMode = (typeof allowedModes)[number]; -export type FactoryEntrypointOptions = { +export interface FactoryEntrypointOptions { briefUrl: string; + authToken: string | null; targetRealmPath: string; targetRealmUrl: string | null; mode: FactoryEntrypointMode; -}; +} -export type FactoryEntrypointAction = { +export interface FactoryEntrypointAction { name: string; status: 'ok'; detail: string; -}; +} -export type FactoryEntrypointSummary = { +export interface FactoryEntrypointBriefSummary extends FactoryBrief { + url: string; +} + +export interface FactoryEntrypointSummary { command: 'factory:go'; mode: FactoryEntrypointMode; - brief: { - url: string; - }; + brief: FactoryEntrypointBriefSummary; targetRealm: { path: string; url: string | null; @@ -35,7 +45,16 @@ export type FactoryEntrypointSummary = { status: 'ready'; nextStep: string; }; -}; +} + +export interface RunFactoryEntrypointDependencies { + fetch?: FactoryBriefFetch; + createBriefFetch?: ( + briefUrl: string, + authToken: string | null, + fetch?: FactoryBriefFetch, + ) => FactoryBriefFetch; +} export class FactoryEntrypointUsageError extends Error { constructor(message: string) { @@ -54,6 +73,7 @@ export function getFactoryEntrypointUsage(): string { ' --target-realm-path Local filesystem path to the target realm', '', 'Options:', + ' --auth-token Optional Authorization header override for fetching the brief', ' --target-realm-url Absolute URL for the target realm when known', ' --mode One of: bootstrap, implement, resume', ' --help Show this usage information', @@ -75,6 +95,9 @@ export function parseFactoryEntrypointArgs( 'brief-url': { type: 'string', }, + 'auth-token': { + type: 'string', + }, 'target-realm-path': { type: 'string', }, @@ -101,6 +124,7 @@ export function parseFactoryEntrypointArgs( } let briefUrl = requireStringValue(parsed.values['brief-url'], '--brief-url'); + let authToken = optionalStringValue(parsed.values['auth-token']); let targetRealmPath = requireStringValue( parsed.values['target-realm-path'], '--target-realm-path', @@ -110,6 +134,7 @@ export function parseFactoryEntrypointArgs( return { briefUrl: normalizeUrl(briefUrl, '--brief-url'), + authToken: authToken ?? null, targetRealmPath, targetRealmUrl: targetRealmUrl ? normalizeUrl(targetRealmUrl, '--target-realm-url') @@ -123,8 +148,37 @@ export function wantsFactoryEntrypointHelp(argv: string[]): boolean { return normalizedArgv.includes('--help'); } +export async function runFactoryEntrypoint( + options: FactoryEntrypointOptions, + dependencies?: RunFactoryEntrypointDependencies, +): Promise { + let fetchImpl = (dependencies?.createBriefFetch ?? createFactoryBriefFetch)( + options.briefUrl, + options.authToken, + dependencies?.fetch, + ); + + let brief = await loadFactoryBrief(options.briefUrl, { + fetch: fetchImpl, + }); + + return buildFactoryEntrypointSummary(options, brief); +} + +function createFactoryBriefFetch( + briefUrl: string, + authToken: string | null, + fetch?: FactoryBriefFetch, +): FactoryBriefFetch { + return createBoxelRealmFetch(briefUrl, { + authorization: authToken ?? undefined, + fetch, + }); +} + export function buildFactoryEntrypointSummary( options: FactoryEntrypointOptions, + brief: FactoryBrief, ): FactoryEntrypointSummary { let resolvedTargetRealmPath = resolve(process.cwd(), options.targetRealmPath); let targetRealmExists = existsSync(resolvedTargetRealmPath); @@ -134,6 +188,16 @@ export function buildFactoryEntrypointSummary( status: 'ok', detail: 'accepted required CLI inputs', }, + { + name: 'fetched-brief', + status: 'ok', + detail: brief.sourceUrl, + }, + { + name: 'normalized-brief', + status: 'ok', + detail: 'prepared-ai-judgment-prompt', + }, { name: 'resolved-target-realm-path', status: 'ok', @@ -153,7 +217,8 @@ export function buildFactoryEntrypointSummary( command: 'factory:go', mode: options.mode, brief: { - url: options.briefUrl, + ...brief, + url: brief.sourceUrl, }, targetRealm: { path: resolvedTargetRealmPath, diff --git a/packages/software-factory/src/prompts/brief-judgment.ts b/packages/software-factory/src/prompts/brief-judgment.ts new file mode 100644 index 0000000000..c0483dc298 --- /dev/null +++ b/packages/software-factory/src/prompts/brief-judgment.ts @@ -0,0 +1,37 @@ +export interface FactoryBriefJudgmentPromptInput { + title: string; + sourceUrl: string; + contentSummary: string; + content: string; + tags: string[]; +} + +const briefJudgmentInstructions = [ + 'Review this factory brief and decide how narrowly the first implementation pass should be scoped.', + '', + 'Instructions:', + '- Decide whether to default to a thin MVP for the first implementation pass or keep a broader first pass.', + '- If the brief is underspecified, create one or more follow-up clarification tickets that name the missing decisions.', + '- If the brief should proceed but still needs a human checkpoint, create a review ticket and call out the specific areas that deserve attention.', + '- Return the judgment in a way the factory can act on during planning and ticket bootstrap.', +] as const; + +export function renderFactoryBriefJudgmentPrompt( + input: FactoryBriefJudgmentPromptInput, +): string { + let promptSections = [ + briefJudgmentInstructions[0], + '', + `Title: ${input.title}`, + `Source URL: ${input.sourceUrl}`, + `Summary: ${input.contentSummary}`, + `Tags: ${input.tags.length > 0 ? input.tags.join(', ') : '(none)'}`, + '', + 'Body:', + input.content === '' ? '(no body content provided)' : input.content, + '', + ...briefJudgmentInstructions.slice(2), + ]; + + return promptSections.join('\n'); +} diff --git a/packages/software-factory/src/realm-auth.ts b/packages/software-factory/src/realm-auth.ts new file mode 100644 index 0000000000..db6e82b381 --- /dev/null +++ b/packages/software-factory/src/realm-auth.ts @@ -0,0 +1,164 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import { authorizationMiddleware } from '@cardstack/runtime-common/authorization-middleware'; +import { fetcher } from '@cardstack/runtime-common/fetcher'; +import { MatrixClient } from '@cardstack/runtime-common/matrix-client'; +import { RealmAuthDataSource } from '@cardstack/runtime-common/realm-auth-data-source'; + +const profilesFile = join(homedir(), '.boxel-cli', 'profiles.json'); + +interface BoxelStoredProfile { + matrixUrl: string; + realmServerUrl: string; + password: string; +} + +interface BoxelProfilesConfig { + profiles: { + [profileId: string]: BoxelStoredProfile; + }; + activeProfile: string | null; +} + +export interface ActiveBoxelProfile { + profileId: string | null; + username: string; + matrixUrl: string; + realmServerUrl: string; + password: string; +} + +export interface CreateBoxelRealmFetchOptions { + authorization?: string; + fetch?: typeof globalThis.fetch; + profile?: ActiveBoxelProfile | null; +} + +export function createBoxelRealmFetch( + resourceUrl: string, + options?: CreateBoxelRealmFetchOptions, +): typeof globalThis.fetch { + let fetchImpl = options?.fetch ?? globalThis.fetch; + + if (typeof fetchImpl !== 'function') { + throw new Error('Global fetch is not available'); + } + + let explicitAuthorization = normalizeOptionalString(options?.authorization); + + if (explicitAuthorization) { + return withAuthorization(fetchImpl, explicitAuthorization); + } + + let profile = + options && 'profile' in options + ? (options.profile ?? undefined) + : getOptionalActiveProfile(); + + if (!profile || !sharesOrigin(resourceUrl, profile.realmServerUrl)) { + return fetchImpl; + } + + let matrixClient = new MatrixClient({ + matrixURL: new URL(profile.matrixUrl), + username: profile.username, + password: profile.password, + }); + let realmAuthDataSource = new RealmAuthDataSource( + matrixClient, + () => fetchImpl, + ); + + return fetcher(fetchImpl, [authorizationMiddleware(realmAuthDataSource)]); +} + +function getOptionalActiveProfile(): ActiveBoxelProfile | undefined { + let config = parseProfilesConfig(); + + if (config.activeProfile && config.profiles[config.activeProfile]) { + let profile = config.profiles[config.activeProfile]; + + return { + profileId: config.activeProfile, + username: config.activeProfile.replace(/^@/, '').replace(/:.*$/, ''), + matrixUrl: profile.matrixUrl, + realmServerUrl: ensureTrailingSlash(profile.realmServerUrl), + password: profile.password, + }; + } + + let matrixUrl = normalizeOptionalString(process.env.MATRIX_URL); + let username = normalizeOptionalString(process.env.MATRIX_USERNAME); + let password = normalizeOptionalString(process.env.MATRIX_PASSWORD); + let realmServerUrl = normalizeOptionalString(process.env.REALM_SERVER_URL); + + if (!matrixUrl || !username || !password || !realmServerUrl) { + return undefined; + } + + return { + profileId: null, + username, + matrixUrl, + realmServerUrl: ensureTrailingSlash(realmServerUrl), + password, + }; +} + +function parseProfilesConfig(): BoxelProfilesConfig { + if (!existsSync(profilesFile)) { + return { profiles: {}, activeProfile: null }; + } + + return JSON.parse(readFileSync(profilesFile, 'utf8')) as BoxelProfilesConfig; +} + +function withAuthorization( + fetchImpl: typeof globalThis.fetch, + authorization: string, +): typeof globalThis.fetch { + return async (input, init) => { + if (input instanceof Request) { + let request = new Request(input, init); + + if (!request.headers.has('Authorization')) { + request.headers.set('Authorization', authorization); + } + + return await fetchImpl(request); + } + + let headers = new Headers(init?.headers); + + if (!headers.has('Authorization')) { + headers.set('Authorization', authorization); + } + + return await fetchImpl(input, { + ...init, + headers, + }); + }; +} + +function ensureTrailingSlash(url: string): string { + return url.endsWith('/') ? url : `${url}/`; +} + +function normalizeOptionalString( + value: string | undefined, +): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + let trimmed = value.trim(); + + return trimmed === '' ? undefined : trimmed; +} + +function sharesOrigin(left: string, right: string): boolean { + return new URL(left).origin === new URL(right).origin; +} diff --git a/packages/software-factory/tests/factory-brief.test.ts b/packages/software-factory/tests/factory-brief.test.ts new file mode 100644 index 0000000000..21dc20b961 --- /dev/null +++ b/packages/software-factory/tests/factory-brief.test.ts @@ -0,0 +1,132 @@ +import { readFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { resolve } from 'node:path'; +import { module, test } from 'qunit'; + +import { + FactoryBriefError, + loadFactoryBrief, + normalizeFactoryBrief, +} from '../src/factory-brief'; + +const stickyNoteFixture = JSON.parse( + readFileSync(resolve(__dirname, '../realm/Wiki/sticky-note.json'), 'utf8'), +) as unknown; + +module('factory-brief', function () { + test('normalizeFactoryBrief extracts a stable shape from the sticky-note wiki card', function (assert) { + let sourceUrl = + 'https://briefs.example.test/software-factory/Wiki/sticky-note'; + let brief = normalizeFactoryBrief(stickyNoteFixture, sourceUrl); + + assert.strictEqual(brief.title, 'Sticky Note'); + assert.strictEqual(brief.sourceUrl, sourceUrl); + assert.strictEqual( + brief.contentSummary, + 'Colorful, short-form note designed for spatial arrangement on boards and artboards.', + ); + assert.deepEqual(brief.tags, ['documents-content', 'sticky', 'note']); + assert.true(brief.aiJudgmentPrompt.includes('Sticky Note')); + assert.true(brief.aiJudgmentPrompt.includes(sourceUrl)); + assert.true(brief.aiJudgmentPrompt.includes('thin MVP')); + }); + + test('normalizeFactoryBrief prepares an AI judgment prompt for clarification and review follow-up', function (assert) { + let brief = normalizeFactoryBrief( + { + data: { + attributes: { + cardInfo: { + name: 'Idea Board', + summary: 'Maybe a tool for notes.', + }, + content: + 'Build something for notes and maybe tasks or other stuff. Keep it flexible and figure out the details later.', + tags: ['notes'], + }, + }, + }, + 'https://briefs.example.test/software-factory/Wiki/idea-board', + ); + + assert.true(brief.aiJudgmentPrompt.includes('Idea Board')); + assert.true(brief.aiJudgmentPrompt.includes('clarification tickets')); + assert.true(brief.aiJudgmentPrompt.includes('review ticket')); + }); + + test('normalizeFactoryBrief falls back when card fields are missing', function (assert) { + let sourceUrl = + 'https://briefs.example.test/software-factory/Wiki/basic-brief'; + let brief = normalizeFactoryBrief( + { + data: { + attributes: { + content: 'Capture tasks on a simple board.', + }, + }, + }, + sourceUrl, + ); + + assert.strictEqual(brief.title, 'Basic Brief'); + assert.strictEqual( + brief.contentSummary, + 'Capture tasks on a simple board.', + ); + assert.deepEqual(brief.tags, []); + assert.true( + brief.aiJudgmentPrompt.includes('Capture tasks on a simple board.'), + ); + }); + + test('normalizeFactoryBrief rejects malformed payloads', function (assert) { + assert.throws( + () => + normalizeFactoryBrief( + { data: null }, + 'https://briefs.example.test/bad', + ), + (error: unknown) => + error instanceof FactoryBriefError && + error.message === + 'Expected brief card payload to include data.attributes', + ); + }); + + test('loadFactoryBrief passes an explicit authorization header when fetching and normalizing a brief', async function (assert) { + assert.expect(5); + + let server = createServer((request, response) => { + assert.strictEqual(request.url, '/software-factory/Wiki/sticky-note'); + assert.strictEqual(request.headers.accept, 'application/vnd.card+source'); + assert.strictEqual(request.headers.authorization, 'Bearer brief-token'); + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify(stickyNoteFixture)); + }); + + await new Promise((resolvePromise) => + server.listen(0, resolvePromise), + ); + let address = server.address(); + + if (!address || typeof address === 'string') { + throw new Error('Expected test server to bind to a TCP port'); + } + + try { + let brief = await loadFactoryBrief( + `http://127.0.0.1:${address.port}/software-factory/Wiki/sticky-note`, + { + authorization: 'Bearer brief-token', + }, + ); + + assert.strictEqual(brief.title, 'Sticky Note'); + assert.true(brief.aiJudgmentPrompt.includes('thin MVP')); + } finally { + await new Promise((resolvePromise, reject) => + server.close((error) => (error ? reject(error) : resolvePromise())), + ); + } + }); +}); diff --git a/packages/software-factory/tests/factory-entrypoint.integration.test.ts b/packages/software-factory/tests/factory-entrypoint.integration.test.ts index e8dd437516..d0832d3392 100644 --- a/packages/software-factory/tests/factory-entrypoint.integration.test.ts +++ b/packages/software-factory/tests/factory-entrypoint.integration.test.ts @@ -1,55 +1,108 @@ -import { mkdtempSync } from 'node:fs'; +import { readFileSync, mkdtempSync } from 'node:fs'; +import { spawn, spawnSync } from 'node:child_process'; +import { createServer } from 'node:http'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; -import { spawnSync } from 'node:child_process'; import { module, test } from 'qunit'; const packageRoot = resolve(__dirname, '..'); -const briefUrl = - 'https://briefs.example.test/software-factory/Wiki/sticky-note'; +const stickyNoteFixture = readFileSync( + resolve(__dirname, '../realm/Wiki/sticky-note.json'), + 'utf8', +); + +interface FactoryEntrypointIntegrationSummary { + command: string; + mode: string; + brief: { + url: string; + title: string; + aiJudgmentPrompt: string; + }; + targetRealm: { + path: string; + exists: boolean; + }; + result: Record; +} + +interface RunCommandOptions { + cwd: string; + encoding: BufferEncoding; +} + +interface RunCommandResult { + status: number | null; + stdout: string; + stderr: string; +} module('factory-entrypoint integration', function () { - test('factory:go package script prints a structured JSON summary', function (assert) { + test('factory:go package script prints a structured JSON summary', async function (assert) { let targetRealmPath = mkdtempSync( join(tmpdir(), 'factory-entrypoint-cli-'), ); - let result = spawnSync( - 'pnpm', - [ - '--silent', - 'factory:go', - '--', - '--brief-url', - briefUrl, - '--target-realm-path', - targetRealmPath, - '--mode', - 'resume', - ], - { - cwd: packageRoot, - encoding: 'utf8', - }, + let server = createServer((request, response) => { + assert.strictEqual(request.headers.authorization, 'Bearer brief-token'); + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(stickyNoteFixture); + }); + + await new Promise((resolvePromise) => + server.listen(0, resolvePromise), ); + let address = server.address(); - assert.strictEqual(result.status, 0, result.stderr); + if (!address || typeof address === 'string') { + throw new Error('Expected test server to bind to a TCP port'); + } - let summary = JSON.parse(result.stdout) as { - command: string; - mode: string; - brief: { url: string }; - targetRealm: { path: string; exists: boolean }; - result: Record; - }; - assert.strictEqual(summary.command, 'factory:go'); - assert.strictEqual(summary.mode, 'resume'); - assert.strictEqual(summary.brief.url, briefUrl); - assert.strictEqual(summary.targetRealm.path, targetRealmPath); - assert.true(summary.targetRealm.exists); - assert.deepEqual(summary.result, { - status: 'ready', - nextStep: 'bootstrap-and-select-active-ticket', - }); + let briefUrl = `http://127.0.0.1:${address.port}/software-factory/Wiki/sticky-note`; + + try { + let result = await runCommand( + 'pnpm', + [ + '--silent', + 'factory:go', + '--', + '--brief-url', + briefUrl, + '--auth-token', + 'Bearer brief-token', + '--target-realm-path', + targetRealmPath, + '--mode', + 'resume', + ], + { + cwd: packageRoot, + encoding: 'utf8', + }, + ); + + assert.strictEqual(result.status, 0, result.stderr); + + let summary = JSON.parse( + result.stdout, + ) as FactoryEntrypointIntegrationSummary; + assert.strictEqual(summary.command, 'factory:go'); + assert.strictEqual(summary.mode, 'resume'); + assert.strictEqual(summary.brief.url, briefUrl); + assert.strictEqual(summary.brief.title, 'Sticky Note'); + assert.true(summary.brief.aiJudgmentPrompt.includes('thin MVP')); + assert.true(summary.brief.aiJudgmentPrompt.includes('review ticket')); + assert.strictEqual(summary.targetRealm.path, targetRealmPath); + assert.true(summary.targetRealm.exists); + assert.deepEqual(summary.result, { + status: 'ready', + nextStep: 'bootstrap-and-select-active-ticket', + }); + } finally { + await new Promise((resolvePromise, reject) => + server.close((error) => (error ? reject(error) : resolvePromise())), + ); + } }); test('factory:go package script fails clearly when required inputs are missing', function (assert) { @@ -83,6 +136,35 @@ module('factory-entrypoint integration', function () { assert.strictEqual(result.status, 0, result.stderr); assert.true(/Usage:/.test(result.stdout)); assert.true(/--brief-url /.test(result.stdout)); + assert.true(/--auth-token /.test(result.stdout)); assert.true(/--mode /.test(result.stdout)); }); }); + +async function runCommand( + command: string, + args: string[], + options: RunCommandOptions, +): Promise { + return await new Promise((resolvePromise, reject) => { + let child = spawn(command, args, { + cwd: options.cwd, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + + child.stdout.setEncoding(options.encoding); + child.stderr.setEncoding(options.encoding); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.once('error', reject); + child.once('close', (status) => { + resolvePromise({ status, stdout, stderr }); + }); + }); +} diff --git a/packages/software-factory/tests/factory-entrypoint.test.ts b/packages/software-factory/tests/factory-entrypoint.test.ts index 336fac980d..f3326c6b8f 100644 --- a/packages/software-factory/tests/factory-entrypoint.test.ts +++ b/packages/software-factory/tests/factory-entrypoint.test.ts @@ -8,12 +8,33 @@ import { buildFactoryEntrypointSummary, getFactoryEntrypointUsage, parseFactoryEntrypointArgs, + runFactoryEntrypoint, wantsFactoryEntrypointHelp, } from '../src/factory-entrypoint'; +import type { FactoryBrief } from '../src/factory-brief'; +import { renderFactoryBriefJudgmentPrompt } from '../src/prompts/brief-judgment'; const briefUrl = 'https://briefs.example.test/software-factory/Wiki/sticky-note'; const targetRealmUrl = 'https://realms.example.test/hassan/personal/'; +const normalizedBrief: FactoryBrief = { + title: 'Sticky Note', + sourceUrl: briefUrl, + content: + 'Structured note content with enough context to describe the first MVP.', + contentSummary: + 'Colorful, short-form note designed for spatial arrangement on boards and artboards.', + tags: ['documents-content', 'sticky', 'note'], + aiJudgmentPrompt: renderFactoryBriefJudgmentPrompt({ + title: 'Sticky Note', + sourceUrl: briefUrl, + content: + 'Structured note content with enough context to describe the first MVP.', + contentSummary: + 'Colorful, short-form note designed for spatial arrangement on boards and artboards.', + tags: ['documents-content', 'sticky', 'note'], + }), +}; module('factory-entrypoint', function () { test('parseFactoryEntrypointArgs accepts required inputs and defaults mode', function (assert) { @@ -26,12 +47,26 @@ module('factory-entrypoint', function () { assert.deepEqual(options, { briefUrl, + authToken: null, targetRealmPath: './realms/personal', targetRealmUrl: null, mode: 'implement', }); }); + test('parseFactoryEntrypointArgs accepts an optional brief auth token override', function (assert) { + let options = parseFactoryEntrypointArgs([ + '--brief-url', + briefUrl, + '--auth-token', + 'Bearer brief-token', + '--target-realm-path', + './realms/personal', + ]); + + assert.strictEqual(options.authToken, 'Bearer brief-token'); + }); + test('parseFactoryEntrypointArgs rejects invalid mode', function (assert) { assert.throws( () => @@ -65,16 +100,22 @@ module('factory-entrypoint', function () { test('buildFactoryEntrypointSummary reports structured run details', function (assert) { let targetRealmPath = mkdtempSync(join(tmpdir(), 'factory-go-summary-')); - let summary = buildFactoryEntrypointSummary({ - briefUrl, - targetRealmPath, - targetRealmUrl, - mode: 'bootstrap', - }); + let summary = buildFactoryEntrypointSummary( + { + briefUrl, + authToken: null, + targetRealmPath, + targetRealmUrl, + mode: 'bootstrap', + }, + normalizedBrief, + ); assert.strictEqual(summary.command, 'factory:go'); assert.strictEqual(summary.mode, 'bootstrap'); assert.strictEqual(summary.brief.url, briefUrl); + assert.strictEqual(summary.brief.title, 'Sticky Note'); + assert.true(summary.brief.aiJudgmentPrompt.includes('thin MVP')); assert.strictEqual(summary.targetRealm.path, targetRealmPath); assert.true(summary.targetRealm.exists); assert.strictEqual(summary.targetRealm.url, targetRealmUrl); @@ -82,6 +123,8 @@ module('factory-entrypoint', function () { summary.actions.map((action) => action.name), [ 'validated-inputs', + 'fetched-brief', + 'normalized-brief', 'resolved-target-realm-path', 'resolved-target-realm-url', ], @@ -102,9 +145,57 @@ module('factory-entrypoint', function () { let usage = getFactoryEntrypointUsage(); assert.true(/--brief-url /.test(usage)); + assert.true(/--auth-token /.test(usage)); assert.true(/--target-realm-path /.test(usage)); assert.true(/--target-realm-url /.test(usage)); assert.true(/--mode /.test(usage)); assert.true(/--help/.test(usage)); }); + + test('runFactoryEntrypoint loads and includes normalized brief data', async function (assert) { + let targetRealmPath = mkdtempSync(join(tmpdir(), 'factory-go-summary-')); + + let summary = await runFactoryEntrypoint( + { + briefUrl, + authToken: 'Bearer brief-token', + targetRealmPath, + targetRealmUrl: null, + mode: 'implement', + }, + { + fetch: async (_input, init) => { + assert.strictEqual( + new Headers(init?.headers).get('Authorization'), + 'Bearer brief-token', + ); + + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ + data: { + attributes: { + content: + 'Build a sticky note card with structured drafting, review, and reuse support.', + cardInfo: { + name: 'Sticky Note', + summary: + 'Colorful, short-form note designed for spatial arrangement on boards and artboards.', + }, + tags: ['documents-content', 'sticky', 'note'], + }, + }, + }), + }; + }, + }, + ); + + assert.strictEqual(summary.brief.title, 'Sticky Note'); + assert.strictEqual(summary.brief.sourceUrl, briefUrl); + assert.true(summary.brief.aiJudgmentPrompt.includes('thin MVP')); + assert.true(summary.brief.aiJudgmentPrompt.includes('clarification')); + }); }); diff --git a/packages/software-factory/tests/index.ts b/packages/software-factory/tests/index.ts index d852f885ad..8a3ee376e8 100644 --- a/packages/software-factory/tests/index.ts +++ b/packages/software-factory/tests/index.ts @@ -1,2 +1,4 @@ +import './factory-brief.test'; import './factory-entrypoint.test'; import './factory-entrypoint.integration.test'; +import './realm-auth.test'; diff --git a/packages/software-factory/tests/realm-auth.test.ts b/packages/software-factory/tests/realm-auth.test.ts new file mode 100644 index 0000000000..a2d86f1c37 --- /dev/null +++ b/packages/software-factory/tests/realm-auth.test.ts @@ -0,0 +1,198 @@ +import { module, test } from 'qunit'; + +import { + createBoxelRealmFetch, + type ActiveBoxelProfile, +} from '../src/realm-auth'; + +const matrixServerUrl = 'http://matrix.example.test/'; +const realmServerUrl = 'http://realm-server.example.test/'; +const briefCardUrl = + 'http://realm-server.example.test/factory/guidance-tasks/Wiki/brief-card'; +const realmUrl = 'http://realm-server.example.test/factory/guidance-tasks/'; + +const profile: ActiveBoxelProfile = { + profileId: '@factory:localhost', + username: 'factory', + matrixUrl: matrixServerUrl, + realmServerUrl, + password: 'secret', +}; + +module('realm-auth', function () { + test('createBoxelRealmFetch returns a fetch that applies an explicit authorization override', async function (assert) { + let fetchWasCalled = false; + let fetchImpl = createBoxelRealmFetch(briefCardUrl, { + authorization: 'Bearer explicit-token', + profile, + fetch: async (input, init) => { + fetchWasCalled = true; + assert.strictEqual( + typeof input === 'string' + ? new Headers(init?.headers).get('Authorization') + : null, + 'Bearer explicit-token', + ); + + return new Response('{}', { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); + }, + }); + + await fetchImpl(briefCardUrl); + assert.true(fetchWasCalled); + }); + + test('createBoxelRealmFetch reauthenticates with runtime-common auth middleware for matching realm-server urls', async function (assert) { + let calls: Array<{ url: string; authorization: string | null }> = []; + let originalFetch = globalThis.fetch; + + globalThis.fetch = (async (input, init) => { + let request = new Request(input, init); + let url = request.url; + let authorization = request.headers.get('Authorization'); + + calls.push({ url, authorization }); + + if (url === briefCardUrl) { + if (!authorization) { + return new Response('unauthorized', { + status: 401, + headers: { + 'x-boxel-realm-url': realmUrl, + }, + }); + } + + return new Response('{}', { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); + } + + if (url === 'http://matrix.example.test/_matrix/client/v3/login') { + return jsonResponse({ + access_token: 'matrix-access-token', + device_id: 'device-id', + user_id: '@factory:localhost', + }); + } + + if ( + url === + 'http://matrix.example.test/_matrix/client/v3/user/%40factory%3Alocalhost/openid/request_token' + ) { + return jsonResponse({ + access_token: 'openid-token', + expires_in: 300, + matrix_server_name: 'localhost', + token_type: 'Bearer', + }); + } + + if ( + url === + 'http://realm-server.example.test/factory/guidance-tasks/_session' + ) { + return new Response('', { + status: 201, + headers: { + Authorization: buildRealmSessionJwt(), + }, + }); + } + + if (url === 'http://matrix.example.test/_matrix/client/v3/joined_rooms') { + return jsonResponse({ + joined_rooms: [], + }); + } + + throw new Error(`Unexpected url: ${url}`); + }) as typeof globalThis.fetch; + + try { + let fetchImpl = createBoxelRealmFetch(briefCardUrl, { + profile, + }); + + let response = await fetchImpl(briefCardUrl); + + assert.strictEqual(response.status, 200); + } finally { + globalThis.fetch = originalFetch; + } + + assert.deepEqual(calls, [ + { + url: briefCardUrl, + authorization: null, + }, + { + url: 'http://matrix.example.test/_matrix/client/v3/login', + authorization: null, + }, + { + url: 'http://matrix.example.test/_matrix/client/v3/user/%40factory%3Alocalhost/openid/request_token', + authorization: 'Bearer matrix-access-token', + }, + { + url: 'http://realm-server.example.test/factory/guidance-tasks/_session', + authorization: null, + }, + { + url: 'http://matrix.example.test/_matrix/client/v3/joined_rooms', + authorization: 'Bearer matrix-access-token', + }, + { + url: briefCardUrl, + authorization: 'header.eyJzZXNzaW9uUm9vbSI6IiJ9.signature', + }, + ]); + }); + + test('createBoxelRealmFetch skips runtime-common auth wiring for non-matching origins', async function (assert) { + let fetchWasCalled = false; + + let fetchImpl = createBoxelRealmFetch( + 'http://127.0.0.1:4011/private/Wiki/brief-card', + { + profile, + fetch: async () => { + fetchWasCalled = true; + return new Response('{}', { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); + }, + }, + ); + + await fetchImpl('http://127.0.0.1:4011/private/Wiki/brief-card'); + assert.true(fetchWasCalled); + }); +}); + +function buildRealmSessionJwt(): string { + return `header.${Buffer.from( + JSON.stringify({ sessionRoom: '' }), + 'utf8', + ).toString('base64')}.signature`; +} + +function jsonResponse(value: unknown): Response { + return new Response(JSON.stringify(value), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fe600ce09..4a51e4fdbe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -731,10 +731,10 @@ importers: version: 1.3.0(typescript@5.9.3) '@glint/environment-ember-loose': specifier: 'catalog:' - version: 1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.104.1))) + version: 1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5))) '@glint/environment-ember-template-imports': specifier: 1.3.0 - version: 1.3.0(@glint/environment-ember-loose@1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.104.1))))(@glint/template@1.3.0) + version: 1.3.0(@glint/environment-ember-loose@1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5))))(@glint/template@1.3.0) '@playwright/test': specifier: 'catalog:' version: 1.57.0 @@ -1007,7 +1007,7 @@ importers: version: 1.10.2 '@glint/environment-ember-loose': specifier: 'catalog:' - version: 1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.104.1))) + version: 1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5))) '@glint/template': specifier: 1.3.0 version: 1.3.0 @@ -1134,7 +1134,7 @@ importers: version: 1.7.4 '@glint/environment-ember-loose': specifier: 'catalog:' - version: 1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.104.1))) + version: 1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5))) '@glint/template': specifier: 1.3.0 version: 1.3.0 @@ -1333,7 +1333,7 @@ importers: dependencies: '@glint/environment-ember-loose': specifier: 'catalog:' - version: 1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.104.1))) + version: 1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5))) ember-basic-dropdown: specifier: 8.0.4 version: 8.0.4(patch_hash=19b0fc5d4bd8b9aa296c4065fa5e33bdbb965db0b277810b596eacd0b9e2f428)(@ember/string@4.0.1)(@ember/test-helpers@3.3.1(@babel/core@7.28.6)(@glint/template@1.3.0)(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.104.1))(webpack@5.104.1))(@glimmer/component@2.0.0)(@glimmer/tracking@1.1.2)(@glint/environment-ember-loose@1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.104.1))))(@glint/template@1.3.0)(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.104.1)) @@ -1391,7 +1391,7 @@ importers: version: 1.3.0(typescript@5.9.3) '@glint/environment-ember-template-imports': specifier: 1.3.0 - version: 1.3.0(@glint/environment-ember-loose@1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.104.1))))(@glint/template@1.3.0) + version: 1.3.0(@glint/environment-ember-loose@1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5))))(@glint/template@1.3.0) '@glint/template': specifier: 1.3.0 version: 1.3.0 @@ -1701,7 +1701,7 @@ importers: version: link:../runtime-common '@cardstack/view-transitions': specifier: 'catalog:' - version: 0.2.0(@babel/core@7.28.6)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.104.1))) + version: 0.2.0(@babel/core@7.28.6)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5))) '@types/lodash': specifier: 'catalog:' version: 4.17.23 @@ -1777,7 +1777,7 @@ importers: version: link:../runtime-common '@cardstack/view-transitions': specifier: 'catalog:' - version: 0.2.0(@babel/core@7.28.6)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.104.1))) + version: 0.2.0(@babel/core@7.28.6)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5))) '@ember/optional-features': specifier: ^2.0.0 version: 2.3.0 @@ -1816,10 +1816,10 @@ importers: version: 1.3.0(typescript@5.9.3) '@glint/environment-ember-loose': specifier: 'catalog:' - version: 1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.104.1))) + version: 1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5))) '@glint/environment-ember-template-imports': specifier: 1.3.0 - version: 1.3.0(@glint/environment-ember-loose@1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.104.1))))(@glint/template@1.3.0) + version: 1.3.0(@glint/environment-ember-loose@1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5))))(@glint/template@1.3.0) '@glint/template': specifier: 1.3.0 version: 1.3.0 @@ -2797,10 +2797,10 @@ importers: version: 1.3.0(typescript@5.9.3) '@glint/environment-ember-loose': specifier: 'catalog:' - version: 1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.104.1))) + version: 1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5))) '@glint/environment-ember-template-imports': specifier: 1.3.0 - version: 1.3.0(@glint/environment-ember-loose@1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.104.1))))(@glint/template@1.3.0) + version: 1.3.0(@glint/environment-ember-loose@1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5))))(@glint/template@1.3.0) '@types/dompurify': specifier: 'catalog:' version: 3.2.0 @@ -2859,10 +2859,10 @@ importers: version: 1.3.0(typescript@5.9.3) '@glint/environment-ember-loose': specifier: 'catalog:' - version: 1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.104.1))) + version: 1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5))) '@glint/environment-ember-template-imports': specifier: 1.3.0 - version: 1.3.0(@glint/environment-ember-loose@1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.104.1))))(@glint/template@1.3.0) + version: 1.3.0(@glint/environment-ember-loose@1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5))))(@glint/template@1.3.0) '@glint/template': specifier: 1.3.0 version: 1.3.0 @@ -2920,9 +2920,6 @@ importers: ts-node: specifier: ^10.9.1 version: 10.9.2(@types/node@24.10.8)(typescript@5.9.3) - tsx: - specifier: ^4.21.0 - version: 4.21.0 typescript: specifier: 'catalog:' version: 5.9.3 @@ -2956,10 +2953,10 @@ importers: version: 1.3.0(typescript@5.9.3) '@glint/environment-ember-loose': specifier: 'catalog:' - version: 1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.104.1))) + version: 1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5))) '@glint/environment-ember-template-imports': specifier: ^1.3.0 - version: 1.3.0(@glint/environment-ember-loose@1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.104.1))))(@glint/template@1.3.0) + version: 1.3.0(@glint/environment-ember-loose@1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5))))(@glint/template@1.3.0) '@glint/template': specifier: ^1.3.0 version: 1.3.0 @@ -14908,7 +14905,7 @@ snapshots: '@cardstack/requirejs-monaco-ember-polyfill@0.0.1': {} - '@cardstack/view-transitions@0.2.0(@babel/core@7.28.6)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.104.1)))': + '@cardstack/view-transitions@0.2.0(@babel/core@7.28.6)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)))': dependencies: '@embroider/addon-shim': 1.10.2 decorator-transforms: 2.3.1(@babel/core@7.28.6) @@ -15321,7 +15318,7 @@ snapshots: ember-cli-babel: 7.26.11 ember-source: 5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.104.1) optionalDependencies: - '@glint/environment-ember-loose': 1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.104.1))) + '@glint/environment-ember-loose': 1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5))) '@glint/template': 1.3.0 transitivePeerDependencies: - supports-color @@ -15984,7 +15981,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@glint/environment-ember-loose@1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.104.1)))': + '@glint/environment-ember-loose@1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)))': dependencies: '@glimmer/component': 2.0.0 '@glint/template': 1.3.0 @@ -15992,9 +15989,9 @@ snapshots: ember-cli-htmlbars: 6.3.0 ember-modifier: 4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.104.1)) - '@glint/environment-ember-template-imports@1.3.0(@glint/environment-ember-loose@1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.104.1))))(@glint/template@1.3.0)': + '@glint/environment-ember-template-imports@1.3.0(@glint/environment-ember-loose@1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5))))(@glint/template@1.3.0)': dependencies: - '@glint/environment-ember-loose': 1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5)(webpack@5.104.1))) + '@glint/environment-ember-loose': 1.5.2(@glimmer/component@2.0.0)(@glint/template@1.3.0)(ember-cli-htmlbars@6.3.0)(ember-modifier@4.1.0(ember-source@5.12.0(patch_hash=2586bd032e74105d65b7953ccaae1cd1d1175047a58ba89ec0057f1f8b7b7fe1)(@glimmer/component@2.0.0)(@glint/template@1.3.0)(rsvp@4.8.5))) '@glint/template': 1.3.0 ember-template-imports: 3.4.2 transitivePeerDependencies: From c55e763015456070f47c1fc4f83ed961b39bee36 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Wed, 18 Mar 2026 12:58:14 -0400 Subject: [PATCH 2/8] more effcient token usage --- .../docs/one-shot-factory-go-plan.md | 11 ++++--- .../software-factory/src/factory-brief.ts | 10 ------ .../src/factory-entrypoint.ts | 2 +- .../tests/factory-brief.test.ts | 32 +------------------ .../factory-entrypoint.integration.test.ts | 14 ++++++-- .../tests/factory-entrypoint.test.ts | 23 ++++++------- 6 files changed, 29 insertions(+), 63 deletions(-) diff --git a/packages/software-factory/docs/one-shot-factory-go-plan.md b/packages/software-factory/docs/one-shot-factory-go-plan.md index 038e728ad1..484df53520 100644 --- a/packages/software-factory/docs/one-shot-factory-go-plan.md +++ b/packages/software-factory/docs/one-shot-factory-go-plan.md @@ -345,10 +345,10 @@ Responsibilities: - summary - content - source URL - - AI judgment prompt for thin-MVP vs broader-first-pass planning - - AI judgment prompt instructions for clarification and review follow-up tickets + - structured fields that a later AI stage can use for thin-MVP vs broader-first-pass planning + - enough context for later clarification and review follow-up ticket decisions -For version one, this helper can stay deterministic by generating a stable prompt template rather than trying to compute vagueness heuristically in code. +For version one, this helper can stay deterministic and data-oriented. Later AI stages should combine the structured brief fields with a stable prompt template rather than embedding a fully rendered prompt into `factory:go` output. ### D1. `src/prompts/brief-judgment.ts` @@ -358,7 +358,7 @@ Responsibilities: - hold the reusable runtime prompt instructions for thin-MVP vs broader-first-pass decisions - keep prompt wording out of the brief-normalization helper -- let the brief helper inject brief-specific context into one shared template instead of repeating an inline string +- let later AI boundaries combine brief-specific context with one shared template instead of repeating an inline string ### E. `scripts/lib/factory-loop.ts` @@ -463,7 +463,8 @@ Optional later additions: "brief": { "url": "http://localhost:4201/software-factory/Wiki/sticky-note", "title": "Sticky Note", - "aiJudgmentPrompt": "Review this factory brief and decide whether to default to a thin MVP..." + "contentSummary": "Colorful, short-form note designed for spatial arrangement on boards and artboards.", + "tags": ["documents-content", "sticky", "note"] }, "targetRealm": { "path": "/.../personal", diff --git a/packages/software-factory/src/factory-brief.ts b/packages/software-factory/src/factory-brief.ts index 61089a8d93..cf93ff9b9f 100644 --- a/packages/software-factory/src/factory-brief.ts +++ b/packages/software-factory/src/factory-brief.ts @@ -1,7 +1,5 @@ import type { LooseSingleCardDocument } from '@cardstack/runtime-common'; -import { renderFactoryBriefJudgmentPrompt } from './prompts/brief-judgment'; - const markdownLinkPattern = /\[([^\]]+)\]\([^)]+\)/g; const wikiLinkPattern = /\[\[([^[\]]+)\]\]/g; const markdownHeadingReplacePattern = /^\s*#{1,6}\s+/gm; @@ -15,7 +13,6 @@ export interface FactoryBrief { content: string; contentSummary: string; tags: string[]; - aiJudgmentPrompt: string; } export interface FactoryBriefFetchRequestInit { @@ -131,13 +128,6 @@ export function normalizeFactoryBrief( content, contentSummary, tags, - aiJudgmentPrompt: renderFactoryBriefJudgmentPrompt({ - title, - sourceUrl, - contentSummary, - content, - tags, - }), }; } diff --git a/packages/software-factory/src/factory-entrypoint.ts b/packages/software-factory/src/factory-entrypoint.ts index 25f74b1155..0cd67971e4 100644 --- a/packages/software-factory/src/factory-entrypoint.ts +++ b/packages/software-factory/src/factory-entrypoint.ts @@ -196,7 +196,7 @@ export function buildFactoryEntrypointSummary( { name: 'normalized-brief', status: 'ok', - detail: 'prepared-ai-judgment-prompt', + detail: 'prepared-brief-data', }, { name: 'resolved-target-realm-path', diff --git a/packages/software-factory/tests/factory-brief.test.ts b/packages/software-factory/tests/factory-brief.test.ts index 21dc20b961..82dbc5f38c 100644 --- a/packages/software-factory/tests/factory-brief.test.ts +++ b/packages/software-factory/tests/factory-brief.test.ts @@ -26,32 +26,6 @@ module('factory-brief', function () { 'Colorful, short-form note designed for spatial arrangement on boards and artboards.', ); assert.deepEqual(brief.tags, ['documents-content', 'sticky', 'note']); - assert.true(brief.aiJudgmentPrompt.includes('Sticky Note')); - assert.true(brief.aiJudgmentPrompt.includes(sourceUrl)); - assert.true(brief.aiJudgmentPrompt.includes('thin MVP')); - }); - - test('normalizeFactoryBrief prepares an AI judgment prompt for clarification and review follow-up', function (assert) { - let brief = normalizeFactoryBrief( - { - data: { - attributes: { - cardInfo: { - name: 'Idea Board', - summary: 'Maybe a tool for notes.', - }, - content: - 'Build something for notes and maybe tasks or other stuff. Keep it flexible and figure out the details later.', - tags: ['notes'], - }, - }, - }, - 'https://briefs.example.test/software-factory/Wiki/idea-board', - ); - - assert.true(brief.aiJudgmentPrompt.includes('Idea Board')); - assert.true(brief.aiJudgmentPrompt.includes('clarification tickets')); - assert.true(brief.aiJudgmentPrompt.includes('review ticket')); }); test('normalizeFactoryBrief falls back when card fields are missing', function (assert) { @@ -74,9 +48,6 @@ module('factory-brief', function () { 'Capture tasks on a simple board.', ); assert.deepEqual(brief.tags, []); - assert.true( - brief.aiJudgmentPrompt.includes('Capture tasks on a simple board.'), - ); }); test('normalizeFactoryBrief rejects malformed payloads', function (assert) { @@ -94,7 +65,7 @@ module('factory-brief', function () { }); test('loadFactoryBrief passes an explicit authorization header when fetching and normalizing a brief', async function (assert) { - assert.expect(5); + assert.expect(4); let server = createServer((request, response) => { assert.strictEqual(request.url, '/software-factory/Wiki/sticky-note'); @@ -122,7 +93,6 @@ module('factory-brief', function () { ); assert.strictEqual(brief.title, 'Sticky Note'); - assert.true(brief.aiJudgmentPrompt.includes('thin MVP')); } finally { await new Promise((resolvePromise, reject) => server.close((error) => (error ? reject(error) : resolvePromise())), diff --git a/packages/software-factory/tests/factory-entrypoint.integration.test.ts b/packages/software-factory/tests/factory-entrypoint.integration.test.ts index d0832d3392..2ff168e6d6 100644 --- a/packages/software-factory/tests/factory-entrypoint.integration.test.ts +++ b/packages/software-factory/tests/factory-entrypoint.integration.test.ts @@ -17,7 +17,8 @@ interface FactoryEntrypointIntegrationSummary { brief: { url: string; title: string; - aiJudgmentPrompt: string; + contentSummary: string; + tags: string[]; }; targetRealm: { path: string; @@ -90,8 +91,15 @@ module('factory-entrypoint integration', function () { assert.strictEqual(summary.mode, 'resume'); assert.strictEqual(summary.brief.url, briefUrl); assert.strictEqual(summary.brief.title, 'Sticky Note'); - assert.true(summary.brief.aiJudgmentPrompt.includes('thin MVP')); - assert.true(summary.brief.aiJudgmentPrompt.includes('review ticket')); + assert.strictEqual( + summary.brief.contentSummary, + 'Colorful, short-form note designed for spatial arrangement on boards and artboards.', + ); + assert.deepEqual(summary.brief.tags, [ + 'documents-content', + 'sticky', + 'note', + ]); assert.strictEqual(summary.targetRealm.path, targetRealmPath); assert.true(summary.targetRealm.exists); assert.deepEqual(summary.result, { diff --git a/packages/software-factory/tests/factory-entrypoint.test.ts b/packages/software-factory/tests/factory-entrypoint.test.ts index f3326c6b8f..6fab8919d3 100644 --- a/packages/software-factory/tests/factory-entrypoint.test.ts +++ b/packages/software-factory/tests/factory-entrypoint.test.ts @@ -12,7 +12,6 @@ import { wantsFactoryEntrypointHelp, } from '../src/factory-entrypoint'; import type { FactoryBrief } from '../src/factory-brief'; -import { renderFactoryBriefJudgmentPrompt } from '../src/prompts/brief-judgment'; const briefUrl = 'https://briefs.example.test/software-factory/Wiki/sticky-note'; @@ -25,15 +24,6 @@ const normalizedBrief: FactoryBrief = { contentSummary: 'Colorful, short-form note designed for spatial arrangement on boards and artboards.', tags: ['documents-content', 'sticky', 'note'], - aiJudgmentPrompt: renderFactoryBriefJudgmentPrompt({ - title: 'Sticky Note', - sourceUrl: briefUrl, - content: - 'Structured note content with enough context to describe the first MVP.', - contentSummary: - 'Colorful, short-form note designed for spatial arrangement on boards and artboards.', - tags: ['documents-content', 'sticky', 'note'], - }), }; module('factory-entrypoint', function () { @@ -115,7 +105,11 @@ module('factory-entrypoint', function () { assert.strictEqual(summary.mode, 'bootstrap'); assert.strictEqual(summary.brief.url, briefUrl); assert.strictEqual(summary.brief.title, 'Sticky Note'); - assert.true(summary.brief.aiJudgmentPrompt.includes('thin MVP')); + assert.deepEqual(summary.brief.tags, [ + 'documents-content', + 'sticky', + 'note', + ]); assert.strictEqual(summary.targetRealm.path, targetRealmPath); assert.true(summary.targetRealm.exists); assert.strictEqual(summary.targetRealm.url, targetRealmUrl); @@ -195,7 +189,10 @@ module('factory-entrypoint', function () { assert.strictEqual(summary.brief.title, 'Sticky Note'); assert.strictEqual(summary.brief.sourceUrl, briefUrl); - assert.true(summary.brief.aiJudgmentPrompt.includes('thin MVP')); - assert.true(summary.brief.aiJudgmentPrompt.includes('clarification')); + assert.strictEqual( + summary.brief.contentSummary, + 'Colorful, short-form note designed for spatial arrangement on boards and artboards.', + ); + assert.true(summary.brief.content.includes('structured drafting')); }); }); From 04c0b3ba813e513e2d981f4549ace538a4aa69fd Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Wed, 18 Mar 2026 13:03:18 -0400 Subject: [PATCH 3/8] cleanup dead code --- .../docs/one-shot-factory-go-plan.md | 10 ----- .../src/prompts/brief-judgment.ts | 37 ------------------- 2 files changed, 47 deletions(-) delete mode 100644 packages/software-factory/src/prompts/brief-judgment.ts diff --git a/packages/software-factory/docs/one-shot-factory-go-plan.md b/packages/software-factory/docs/one-shot-factory-go-plan.md index 484df53520..b485c563df 100644 --- a/packages/software-factory/docs/one-shot-factory-go-plan.md +++ b/packages/software-factory/docs/one-shot-factory-go-plan.md @@ -350,16 +350,6 @@ Responsibilities: For version one, this helper can stay deterministic and data-oriented. Later AI stages should combine the structured brief fields with a stable prompt template rather than embedding a fully rendered prompt into `factory:go` output. -### D1. `src/prompts/brief-judgment.ts` - -Dedicated prompt template module for brief judgment. - -Responsibilities: - -- hold the reusable runtime prompt instructions for thin-MVP vs broader-first-pass decisions -- keep prompt wording out of the brief-normalization helper -- let later AI boundaries combine brief-specific context with one shared template instead of repeating an inline string - ### E. `scripts/lib/factory-loop.ts` New helper module for the first execution loop. diff --git a/packages/software-factory/src/prompts/brief-judgment.ts b/packages/software-factory/src/prompts/brief-judgment.ts deleted file mode 100644 index c0483dc298..0000000000 --- a/packages/software-factory/src/prompts/brief-judgment.ts +++ /dev/null @@ -1,37 +0,0 @@ -export interface FactoryBriefJudgmentPromptInput { - title: string; - sourceUrl: string; - contentSummary: string; - content: string; - tags: string[]; -} - -const briefJudgmentInstructions = [ - 'Review this factory brief and decide how narrowly the first implementation pass should be scoped.', - '', - 'Instructions:', - '- Decide whether to default to a thin MVP for the first implementation pass or keep a broader first pass.', - '- If the brief is underspecified, create one or more follow-up clarification tickets that name the missing decisions.', - '- If the brief should proceed but still needs a human checkpoint, create a review ticket and call out the specific areas that deserve attention.', - '- Return the judgment in a way the factory can act on during planning and ticket bootstrap.', -] as const; - -export function renderFactoryBriefJudgmentPrompt( - input: FactoryBriefJudgmentPromptInput, -): string { - let promptSections = [ - briefJudgmentInstructions[0], - '', - `Title: ${input.title}`, - `Source URL: ${input.sourceUrl}`, - `Summary: ${input.contentSummary}`, - `Tags: ${input.tags.length > 0 ? input.tags.join(', ') : '(none)'}`, - '', - 'Body:', - input.content === '' ? '(no body content provided)' : input.content, - '', - ...briefJudgmentInstructions.slice(2), - ]; - - return promptSections.join('\n'); -} From 0d0bf1485af3611e360dc2191cb29d24d4580e11 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Wed, 18 Mar 2026 13:39:22 -0400 Subject: [PATCH 4/8] refactoring auth tests --- .../tests/helpers/realm-auth.ts | 247 ++++++++++++++++++ .../software-factory/tests/realm-auth.test.ts | 62 +++-- 2 files changed, 292 insertions(+), 17 deletions(-) create mode 100644 packages/software-factory/tests/helpers/realm-auth.ts diff --git a/packages/software-factory/tests/helpers/realm-auth.ts b/packages/software-factory/tests/helpers/realm-auth.ts new file mode 100644 index 0000000000..e8992bd228 --- /dev/null +++ b/packages/software-factory/tests/helpers/realm-auth.ts @@ -0,0 +1,247 @@ +import { createHash } from 'node:crypto'; +import { createServer } from 'node:http'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +export interface StubServer { + stop(): Promise; + url: string; +} + +export interface PrivateRealmStubServer { + origin: string; + realmUrl: string; + stop(): Promise; +} + +export interface RealmAuthTestServers { + matrixServer: StubServer; + realmServer: PrivateRealmStubServer; + stop(): Promise; +} + +export async function startServers( + sessionToken = buildRealmSessionJwt(), +): Promise { + let matrixServer = await startMatrixStubServer(); + let realmServer = await startPrivateRealmStubServer({ + sessionToken, + }); + + return { + matrixServer, + realmServer, + async stop() { + await realmServer.stop(); + await matrixServer.stop(); + }, + }; +} + +async function startMatrixStubServer(): Promise { + // This is a live HTTP matrix stub, not a mocked fetch callback, so + // createBoxelRealmFetch exercises the real login + OpenID request flow. + let server = createServer((request, response) => { + if ( + request.method === 'POST' && + request.url === '/_matrix/client/v3/login' + ) { + respondJson(response, { + access_token: 'matrix-access-token', + device_id: 'device-id', + user_id: '@software-factory-browser:localhost', + }); + return; + } + + if ( + request.method === 'POST' && + request.url === + '/_matrix/client/v3/user/%40software-factory-browser%3Alocalhost/openid/request_token' + ) { + respondJson(response, { + access_token: 'openid-token', + expires_in: 300, + matrix_server_name: 'localhost', + token_type: 'Bearer', + }); + return; + } + + if ( + request.method === 'GET' && + request.url === '/_matrix/client/v3/joined_rooms' + ) { + respondJson(response, { + joined_rooms: [], + }); + return; + } + + response.writeHead(404, { 'content-type': 'text/plain' }); + response.end(`Unexpected matrix request: ${request.method} ${request.url}`); + }); + + await listenOnRandomPort(server); + + let address = server.address() as AddressInfo; + + return { + url: `http://127.0.0.1:${address.port}/`, + async stop() { + await stopServer(server); + }, + }; +} + +export function browserPassword(username: string): string { + return createHash('sha256') + .update(username.replace(/^@/, '').replace(/:.*$/, '')) + .update("shhh! it's a secret") + .digest('hex'); +} + +async function startPrivateRealmStubServer({ + sessionToken, +}: { + sessionToken: string; +}): Promise { + let origin = ''; + let realmUrl = ''; + + let server = createServer(async (request, response) => { + if (request.method === 'POST' && request.url === '/private/_session') { + let body = await readRequestBody(request); + let parsedBody = JSON.parse(body) as { access_token?: string }; + if (parsedBody.access_token !== 'openid-token') { + response.writeHead(401, { 'content-type': 'text/plain' }); + response.end('invalid openid token'); + return; + } + + response.writeHead(201, { + Authorization: sessionToken, + }); + response.end(''); + return; + } + + if ( + request.method === 'GET' && + request.url === '/private/Wiki/brief-card' + ) { + if (request.headers.authorization !== sessionToken) { + response.writeHead(401, { + 'content-type': 'text/plain', + 'x-boxel-realm-url': realmUrl, + }); + response.end('unauthorized'); + return; + } + + respondJson( + response, + { + data: { + type: 'card', + attributes: { + title: 'Private Brief', + content: + 'Private brief content for testing realm auth. It should only be readable with a valid realm session.', + tags: ['private', 'brief'], + cardInfo: { + name: 'Private Brief', + summary: 'Private brief content for testing realm auth.', + }, + }, + }, + }, + { + 'content-type': 'application/vnd.card+source', + }, + ); + return; + } + + response.writeHead(404, { 'content-type': 'text/plain' }); + response.end(`Unexpected realm request: ${request.method} ${request.url}`); + }); + + await listenOnRandomPort(server); + + let address = server.address() as AddressInfo; + origin = `http://127.0.0.1:${address.port}/`; + realmUrl = `${origin}private/`; + + return { + origin, + realmUrl, + async stop() { + await stopServer(server); + }, + }; +} + +export function buildRealmSessionJwt(): string { + return `header.${Buffer.from( + JSON.stringify({ sessionRoom: '' }), + 'utf8', + ).toString('base64')}.signature`; +} + +export function jsonResponse(value: unknown): Response { + return new Response(JSON.stringify(value), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); +} + +async function listenOnRandomPort( + server: ReturnType, +): Promise { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); +} + +async function stopServer( + server: ReturnType, +): Promise { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +} + +function respondJson( + response: ServerResponse, + value: unknown, + headers?: Record, +): void { + response.writeHead(200, { + 'content-type': 'application/json', + ...headers, + }); + response.end(JSON.stringify(value)); +} + +async function readRequestBody(request: IncomingMessage): Promise { + let chunks: string[] = []; + + for await (let chunk of request) { + chunks.push(String(chunk)); + } + + return chunks.join(''); +} diff --git a/packages/software-factory/tests/realm-auth.test.ts b/packages/software-factory/tests/realm-auth.test.ts index a2d86f1c37..87cc257c81 100644 --- a/packages/software-factory/tests/realm-auth.test.ts +++ b/packages/software-factory/tests/realm-auth.test.ts @@ -4,6 +4,13 @@ import { createBoxelRealmFetch, type ActiveBoxelProfile, } from '../src/realm-auth'; +import { FactoryBriefError, loadFactoryBrief } from '../src/factory-brief'; +import { + browserPassword, + buildRealmSessionJwt, + jsonResponse, + startServers, +} from './helpers/realm-auth'; const matrixServerUrl = 'http://matrix.example.test/'; const realmServerUrl = 'http://realm-server.example.test/'; @@ -47,7 +54,7 @@ module('realm-auth', function () { assert.true(fetchWasCalled); }); - test('createBoxelRealmFetch reauthenticates with runtime-common auth middleware for matching realm-server urls', async function (assert) { + test('createBoxelRealmFetch retries a matching realm request with a refreshed session after a 401', async function (assert) { let calls: Array<{ url: string; authorization: string | null }> = []; let originalFetch = globalThis.fetch; @@ -157,7 +164,7 @@ module('realm-auth', function () { ]); }); - test('createBoxelRealmFetch skips runtime-common auth wiring for non-matching origins', async function (assert) { + test('createBoxelRealmFetch leaves fetch unchanged for non-matching origins', async function (assert) { let fetchWasCalled = false; let fetchImpl = createBoxelRealmFetch( @@ -179,20 +186,41 @@ module('realm-auth', function () { await fetchImpl('http://127.0.0.1:4011/private/Wiki/brief-card'); assert.true(fetchWasCalled); }); -}); -function buildRealmSessionJwt(): string { - return `header.${Buffer.from( - JSON.stringify({ sessionRoom: '' }), - 'utf8', - ).toString('base64')}.signature`; -} - -function jsonResponse(value: unknown): Response { - return new Response(JSON.stringify(value), { - status: 200, - headers: { - 'content-type': 'application/json', - }, + test('createBoxelRealmFetch can fetch a brief from a private realm', async function (assert) { + let servers = await startServers(); + let briefUrl = `${servers.realmServer.realmUrl}Wiki/brief-card`; + + try { + await assert.rejects( + loadFactoryBrief(briefUrl), + (error: unknown) => + error instanceof FactoryBriefError && + /HTTP 401 Unauthorized/.test(error.message), + ); + + let authedFetch = createBoxelRealmFetch(briefUrl, { + profile: { + profileId: null, + username: 'software-factory-browser', + matrixUrl: servers.matrixServer.url, + realmServerUrl: servers.realmServer.origin, + password: browserPassword('software-factory-browser'), + }, + }); + + let brief = await loadFactoryBrief(briefUrl, { + fetch: authedFetch, + }); + + assert.strictEqual(brief.title, 'Private Brief'); + assert.strictEqual( + brief.contentSummary, + 'Private brief content for testing realm auth.', + ); + assert.deepEqual(brief.tags, ['private', 'brief']); + } finally { + await servers.stop(); + } }); -} +}); From ed49f7482df3091bee871208b62e63e122e097cd Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Wed, 18 Mar 2026 13:53:00 -0400 Subject: [PATCH 5/8] updated from code review feedback --- packages/software-factory/README.md | 4 +- .../src/cli/factory-entrypoint.ts | 5 +- .../software-factory/src/factory-brief.ts | 37 ++--- .../src/factory-entrypoint.ts | 16 +- packages/software-factory/src/realm-auth.ts | 145 +++++++++++++++--- .../tests/factory-brief.test.ts | 25 +++ .../tests/helpers/realm-auth.ts | 26 ++-- .../software-factory/tests/realm-auth.test.ts | 64 ++++++++ 8 files changed, 252 insertions(+), 70 deletions(-) diff --git a/packages/software-factory/README.md b/packages/software-factory/README.md index 12e9e2310b..a9ed92e157 100644 --- a/packages/software-factory/README.md +++ b/packages/software-factory/README.md @@ -71,7 +71,7 @@ Usage: pnpm factory:go -- \ --brief-url http://localhost:4201/software-factory/Wiki/sticky-note \ --target-realm-path /path/to/target-realm \ - [--auth-token "Bearer "] \ + [--auth-token ""] \ [--target-realm-url http://localhost:4201/hassan/personal/] \ [--mode implement] ``` @@ -113,7 +113,7 @@ pnpm factory:go -- \ ``` - When the brief is in a public realm, you do not need this flag. -- When the brief is in a private realm, pass `--auth-token` with a realm token that can read that brief. +- When the brief is in a private realm, pass the exact `Authorization` header value returned by `pnpm boxel:session`. ## Layout diff --git a/packages/software-factory/src/cli/factory-entrypoint.ts b/packages/software-factory/src/cli/factory-entrypoint.ts index 4cee7fc5d1..b3c51c490c 100644 --- a/packages/software-factory/src/cli/factory-entrypoint.ts +++ b/packages/software-factory/src/cli/factory-entrypoint.ts @@ -11,13 +11,12 @@ async function main(): Promise { try { if (wantsFactoryEntrypointHelp(process.argv.slice(2))) { console.log(getFactoryEntrypointUsage()); - process.exit(0); + return; } let options = parseFactoryEntrypointArgs(process.argv.slice(2)); let summary = await runFactoryEntrypoint(options); console.log(JSON.stringify(summary, null, 2)); - process.exit(0); } catch (error) { if (error instanceof FactoryEntrypointUsageError) { console.error(error.message); @@ -29,7 +28,7 @@ async function main(): Promise { console.error(error); } - process.exit(1); + process.exitCode = 1; } } diff --git a/packages/software-factory/src/factory-brief.ts b/packages/software-factory/src/factory-brief.ts index cf93ff9b9f..c6367b4c71 100644 --- a/packages/software-factory/src/factory-brief.ts +++ b/packages/software-factory/src/factory-brief.ts @@ -15,24 +15,6 @@ export interface FactoryBrief { tags: string[]; } -export interface FactoryBriefFetchRequestInit { - headers?: Record; -} - -export interface FactoryBriefFetchResponse { - ok: boolean; - status: number; - statusText: string; - json(): Promise; -} - -export interface FactoryBriefFetch { - ( - input: string | URL, - init?: FactoryBriefFetchRequestInit, - ): Promise; -} - interface BoxelBriefCardInfo { name?: string | null; summary?: string | null; @@ -42,12 +24,14 @@ interface FactoryBriefCardAttributes { title?: string | null; name?: string | null; content?: string | null; + summary?: string | null; + description?: string | null; tags?: Array | null; cardInfo?: BoxelBriefCardInfo | null; } interface FactoryBriefLoadOptions { - fetch?: FactoryBriefFetch; + fetch?: typeof globalThis.fetch; authorization?: string; } @@ -117,8 +101,17 @@ export function normalizeFactoryBrief( valueAsTrimmedString(attributes.name), ]); let title = explicitTitle ?? inferTitleFromUrl(sourceUrl); - let summary = valueAsTrimmedString(cardInfo.summary); - let content = valueAsTrimmedString(attributes.content) ?? ''; + let summary = firstNonEmptyString([ + valueAsTrimmedString(cardInfo.summary), + valueAsTrimmedString(attributes.summary), + valueAsTrimmedString(attributes.description), + ]); + let content = + firstNonEmptyString([ + valueAsTrimmedString(attributes.content), + valueAsTrimmedString(attributes.description), + valueAsTrimmedString(attributes.summary), + ]) ?? ''; let tags = normalizeTags(attributes.tags); let contentSummary = buildContentSummary(summary, content, title); @@ -264,6 +257,8 @@ function parseFactoryBriefCardAttributes( title: parseOptionalString(attributes.title), name: parseOptionalString(attributes.name), content: parseOptionalString(attributes.content), + summary: parseOptionalString(attributes.summary), + description: parseOptionalString(attributes.description), tags: parseOptionalStringArray(attributes.tags), cardInfo: parseBriefCardInfo(attributes.cardInfo), }; diff --git a/packages/software-factory/src/factory-entrypoint.ts b/packages/software-factory/src/factory-entrypoint.ts index 0cd67971e4..74e0b7cba4 100644 --- a/packages/software-factory/src/factory-entrypoint.ts +++ b/packages/software-factory/src/factory-entrypoint.ts @@ -2,11 +2,7 @@ import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; import { parseArgs as parseNodeArgs } from 'node:util'; -import { - loadFactoryBrief, - type FactoryBrief, - type FactoryBriefFetch, -} from './factory-brief'; +import { loadFactoryBrief, type FactoryBrief } from './factory-brief'; import { createBoxelRealmFetch } from './realm-auth'; const allowedModes = ['bootstrap', 'implement', 'resume'] as const; @@ -48,12 +44,12 @@ export interface FactoryEntrypointSummary { } export interface RunFactoryEntrypointDependencies { - fetch?: FactoryBriefFetch; + fetch?: typeof globalThis.fetch; createBriefFetch?: ( briefUrl: string, authToken: string | null, - fetch?: FactoryBriefFetch, - ) => FactoryBriefFetch; + fetch?: typeof globalThis.fetch, + ) => typeof globalThis.fetch; } export class FactoryEntrypointUsageError extends Error { @@ -168,8 +164,8 @@ export async function runFactoryEntrypoint( function createFactoryBriefFetch( briefUrl: string, authToken: string | null, - fetch?: FactoryBriefFetch, -): FactoryBriefFetch { + fetch?: typeof globalThis.fetch, +): typeof globalThis.fetch { return createBoxelRealmFetch(briefUrl, { authorization: authToken ?? undefined, fetch, diff --git a/packages/software-factory/src/realm-auth.ts b/packages/software-factory/src/realm-auth.ts index db6e82b381..6d2d73f357 100644 --- a/packages/software-factory/src/realm-auth.ts +++ b/packages/software-factory/src/realm-auth.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { existsSync, readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -7,8 +8,6 @@ import { fetcher } from '@cardstack/runtime-common/fetcher'; import { MatrixClient } from '@cardstack/runtime-common/matrix-client'; import { RealmAuthDataSource } from '@cardstack/runtime-common/realm-auth-data-source'; -const profilesFile = join(homedir(), '.boxel-cli', 'profiles.json'); - interface BoxelStoredProfile { matrixUrl: string; realmServerUrl: string; @@ -55,7 +54,7 @@ export function createBoxelRealmFetch( let profile = options && 'profile' in options ? (options.profile ?? undefined) - : getOptionalActiveProfile(); + : getOptionalActiveProfile(resourceUrl); if (!profile || !sharesOrigin(resourceUrl, profile.realmServerUrl)) { return fetchImpl; @@ -74,7 +73,9 @@ export function createBoxelRealmFetch( return fetcher(fetchImpl, [authorizationMiddleware(realmAuthDataSource)]); } -function getOptionalActiveProfile(): ActiveBoxelProfile | undefined { +function getOptionalActiveProfile( + resourceUrl: string, +): ActiveBoxelProfile | undefined { let config = parseProfilesConfig(); if (config.activeProfile && config.profiles[config.activeProfile]) { @@ -83,36 +84,36 @@ function getOptionalActiveProfile(): ActiveBoxelProfile | undefined { return { profileId: config.activeProfile, username: config.activeProfile.replace(/^@/, '').replace(/:.*$/, ''), - matrixUrl: profile.matrixUrl, - realmServerUrl: ensureTrailingSlash(profile.realmServerUrl), + matrixUrl: normalizeProfileUrl(profile.matrixUrl, 'matrixUrl'), + realmServerUrl: normalizeProfileUrl( + profile.realmServerUrl, + 'realmServerUrl', + ), password: profile.password, }; } - let matrixUrl = normalizeOptionalString(process.env.MATRIX_URL); - let username = normalizeOptionalString(process.env.MATRIX_USERNAME); - let password = normalizeOptionalString(process.env.MATRIX_PASSWORD); - let realmServerUrl = normalizeOptionalString(process.env.REALM_SERVER_URL); - - if (!matrixUrl || !username || !password || !realmServerUrl) { - return undefined; - } - - return { - profileId: null, - username, - matrixUrl, - realmServerUrl: ensureTrailingSlash(realmServerUrl), - password, - }; + return buildEnvProfile(resourceUrl); } function parseProfilesConfig(): BoxelProfilesConfig { + let profilesFile = getProfilesFile(); + if (!existsSync(profilesFile)) { return { profiles: {}, activeProfile: null }; } - return JSON.parse(readFileSync(profilesFile, 'utf8')) as BoxelProfilesConfig; + try { + return JSON.parse( + readFileSync(profilesFile, 'utf8'), + ) as BoxelProfilesConfig; + } catch (error) { + throw new Error( + `Failed to parse Boxel profiles config at ${profilesFile}: ${ + error instanceof Error ? error.message : String(error) + }. Fix or remove the file, or provide auth via environment variables.`, + ); + } } function withAuthorization( @@ -147,6 +148,57 @@ function ensureTrailingSlash(url: string): string { return url.endsWith('/') ? url : `${url}/`; } +function getProfilesFile(): string { + return join(homedir(), '.boxel-cli', 'profiles.json'); +} + +function normalizeProfileUrl(value: string, label: string): string { + try { + return ensureTrailingSlash(new URL(value).href); + } catch (error) { + throw new Error( + `Invalid ${label} in Boxel auth configuration: "${value}". Expected an absolute URL.`, + ); + } +} + +function buildEnvProfile(resourceUrl: string): ActiveBoxelProfile | undefined { + let matrixUrl = normalizeOptionalString(process.env.MATRIX_URL); + let username = normalizeOptionalString(process.env.MATRIX_USERNAME); + let password = normalizeOptionalString(process.env.MATRIX_PASSWORD); + let realmServerUrl = normalizeOptionalString(process.env.REALM_SERVER_URL); + let realmSecretSeed = normalizeOptionalString(process.env.REALM_SECRET_SEED); + + if (!matrixUrl) { + return undefined; + } + + let normalizedMatrixUrl = normalizeProfileUrl(matrixUrl, 'MATRIX_URL'); + let normalizedRealmServerUrl = realmServerUrl + ? normalizeProfileUrl(realmServerUrl, 'REALM_SERVER_URL') + : normalizeProfileUrl(new URL('/', resourceUrl).href, 'resourceUrl origin'); + + if (!username && realmSecretSeed) { + username = deriveRealmUsernameFromResourceUrl(resourceUrl); + } + + if (!password && username && realmSecretSeed) { + password = derivePasswordFromSeed(username, realmSecretSeed); + } + + if (!username || !password) { + return undefined; + } + + return { + profileId: null, + username, + matrixUrl: normalizedMatrixUrl, + realmServerUrl: normalizedRealmServerUrl, + password, + }; +} + function normalizeOptionalString( value: string | undefined, ): string | undefined { @@ -160,5 +212,50 @@ function normalizeOptionalString( } function sharesOrigin(left: string, right: string): boolean { - return new URL(left).origin === new URL(right).origin; + try { + return new URL(left).origin === new URL(right).origin; + } catch (error) { + throw new Error( + `Invalid URL while setting up realm auth for ${left}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +} + +function deriveRealmUsernameFromResourceUrl(resourceUrl: string): string { + let url: URL; + + try { + url = new URL(resourceUrl); + } catch { + throw new Error( + `Cannot derive MATRIX_USERNAME from resource URL "${resourceUrl}". Set MATRIX_USERNAME explicitly or use a valid brief URL.`, + ); + } + + let segments = url.pathname.split('/').filter(Boolean); + + if (segments.length === 0) { + throw new Error( + `Cannot derive MATRIX_USERNAME from resource URL "${resourceUrl}". Set MATRIX_USERNAME explicitly.`, + ); + } + + if (segments[0] === 'published' && segments[1]) { + return `realm/published_${segments[1]}`; + } + + if (segments.length >= 4) { + return `realm/${segments[0]}_${segments[1]}`; + } + + return `${segments[0]}_realm`; +} + +function derivePasswordFromSeed(username: string, seed: string): string { + return createHash('sha256') + .update(username.replace(/^@/, '').replace(/:.*$/, '')) + .update(seed) + .digest('hex'); } diff --git a/packages/software-factory/tests/factory-brief.test.ts b/packages/software-factory/tests/factory-brief.test.ts index 82dbc5f38c..1f32939eb7 100644 --- a/packages/software-factory/tests/factory-brief.test.ts +++ b/packages/software-factory/tests/factory-brief.test.ts @@ -12,6 +12,15 @@ import { const stickyNoteFixture = JSON.parse( readFileSync(resolve(__dirname, '../realm/Wiki/sticky-note.json'), 'utf8'), ) as unknown; +const darkfactoryTicketFixture = JSON.parse( + readFileSync( + resolve( + __dirname, + '../test-fixtures/darkfactory-adopter/Ticket/ticket-001.json', + ), + 'utf8', + ), +) as unknown; module('factory-brief', function () { test('normalizeFactoryBrief extracts a stable shape from the sticky-note wiki card', function (assert) { @@ -50,6 +59,22 @@ module('factory-brief', function () { assert.deepEqual(brief.tags, []); }); + test('normalizeFactoryBrief falls back to summary and description text when content is absent', function (assert) { + let sourceUrl = + 'https://briefs.example.test/darkfactory-adopter/Ticket/ticket-001'; + let brief = normalizeFactoryBrief(darkfactoryTicketFixture, sourceUrl); + + assert.strictEqual(brief.title, 'Ticket 001'); + assert.strictEqual( + brief.content, + 'Render tracker cards from an adopter realm using the public software-factory module URL.', + ); + assert.strictEqual( + brief.contentSummary, + 'Verify public DarkFactory adoption', + ); + }); + test('normalizeFactoryBrief rejects malformed payloads', function (assert) { assert.throws( () => diff --git a/packages/software-factory/tests/helpers/realm-auth.ts b/packages/software-factory/tests/helpers/realm-auth.ts index e8992bd228..f5fe1e121c 100644 --- a/packages/software-factory/tests/helpers/realm-auth.ts +++ b/packages/software-factory/tests/helpers/realm-auth.ts @@ -21,11 +21,16 @@ export interface RealmAuthTestServers { } export async function startServers( - sessionToken = buildRealmSessionJwt(), + options: { + sessionToken?: string; + username?: string; + } = {}, ): Promise { - let matrixServer = await startMatrixStubServer(); + let matrixServer = await startMatrixStubServer( + options.username ?? 'software-factory-browser', + ); let realmServer = await startPrivateRealmStubServer({ - sessionToken, + sessionToken: options.sessionToken ?? buildRealmSessionJwt(), }); return { @@ -38,7 +43,12 @@ export async function startServers( }; } -async function startMatrixStubServer(): Promise { +async function startMatrixStubServer(username: string): Promise { + let userId = `@${username}:localhost`; + let openIdPath = `/_matrix/client/v3/user/${encodeURIComponent( + userId, + )}/openid/request_token`; + // This is a live HTTP matrix stub, not a mocked fetch callback, so // createBoxelRealmFetch exercises the real login + OpenID request flow. let server = createServer((request, response) => { @@ -49,16 +59,12 @@ async function startMatrixStubServer(): Promise { respondJson(response, { access_token: 'matrix-access-token', device_id: 'device-id', - user_id: '@software-factory-browser:localhost', + user_id: userId, }); return; } - if ( - request.method === 'POST' && - request.url === - '/_matrix/client/v3/user/%40software-factory-browser%3Alocalhost/openid/request_token' - ) { + if (request.method === 'POST' && request.url === openIdPath) { respondJson(response, { access_token: 'openid-token', expires_in: 300, diff --git a/packages/software-factory/tests/realm-auth.test.ts b/packages/software-factory/tests/realm-auth.test.ts index 87cc257c81..bb646f1463 100644 --- a/packages/software-factory/tests/realm-auth.test.ts +++ b/packages/software-factory/tests/realm-auth.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { module, test } from 'qunit'; import { @@ -223,4 +226,65 @@ module('realm-auth', function () { await servers.stop(); } }); + + test('createBoxelRealmFetch derives env credentials from REALM_SECRET_SEED when MATRIX_PASSWORD is absent', async function (assert) { + let tempHome = mkdtempSync(join(tmpdir(), 'software-factory-realm-auth-')); + let originalHome = process.env.HOME; + let originalMatrixUrl = process.env.MATRIX_URL; + let originalMatrixUsername = process.env.MATRIX_USERNAME; + let originalMatrixPassword = process.env.MATRIX_PASSWORD; + let originalRealmServerUrl = process.env.REALM_SERVER_URL; + let originalRealmSecretSeed = process.env.REALM_SECRET_SEED; + let servers = await startServers({ username: 'private_realm' }); + let briefUrl = `${servers.realmServer.realmUrl}Wiki/brief-card`; + + try { + process.env.HOME = tempHome; + process.env.MATRIX_URL = servers.matrixServer.url; + delete process.env.MATRIX_USERNAME; + delete process.env.MATRIX_PASSWORD; + delete process.env.REALM_SERVER_URL; + process.env.REALM_SECRET_SEED = "shhh! it's a secret"; + + let brief = await loadFactoryBrief(briefUrl, { + fetch: createBoxelRealmFetch(briefUrl), + }); + + assert.strictEqual(brief.title, 'Private Brief'); + } finally { + process.env.HOME = originalHome; + process.env.MATRIX_URL = originalMatrixUrl; + process.env.MATRIX_USERNAME = originalMatrixUsername; + process.env.MATRIX_PASSWORD = originalMatrixPassword; + process.env.REALM_SERVER_URL = originalRealmServerUrl; + process.env.REALM_SECRET_SEED = originalRealmSecretSeed; + await servers.stop(); + rmSync(tempHome, { recursive: true, force: true }); + } + }); + + test('createBoxelRealmFetch throws a clear error when profiles.json is invalid', function (assert) { + let tempHome = mkdtempSync(join(tmpdir(), 'software-factory-realm-auth-')); + let originalHome = process.env.HOME; + let profilesDir = join(tempHome, '.boxel-cli'); + mkdirSync(profilesDir, { recursive: true }); + writeFileSync(join(profilesDir, 'profiles.json'), '{not-json'); + + try { + process.env.HOME = tempHome; + + assert.throws( + () => + createBoxelRealmFetch( + 'http://127.0.0.1:4011/private/Wiki/brief-card', + ), + (error: unknown) => + error instanceof Error && + error.message.includes('Failed to parse Boxel profiles config at'), + ); + } finally { + process.env.HOME = originalHome; + rmSync(tempHome, { recursive: true, force: true }); + } + }); }); From 55d1ceccfad7a079f71e12cc154ec92bcea321f9 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Wed, 18 Mar 2026 13:56:48 -0400 Subject: [PATCH 6/8] fix lint --- .../tests/factory-entrypoint.test.ts | 15 +++++++++------ packages/software-factory/tsconfig.json | 9 +-------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/packages/software-factory/tests/factory-entrypoint.test.ts b/packages/software-factory/tests/factory-entrypoint.test.ts index 6fab8919d3..3f9a65e2aa 100644 --- a/packages/software-factory/tests/factory-entrypoint.test.ts +++ b/packages/software-factory/tests/factory-entrypoint.test.ts @@ -164,11 +164,8 @@ module('factory-entrypoint', function () { 'Bearer brief-token', ); - return { - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ + return new Response( + JSON.stringify({ data: { attributes: { content: @@ -182,7 +179,13 @@ module('factory-entrypoint', function () { }, }, }), - }; + { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }, + ); }, }, ); diff --git a/packages/software-factory/tsconfig.json b/packages/software-factory/tsconfig.json index 4046338a2a..3ec1c89321 100644 --- a/packages/software-factory/tsconfig.json +++ b/packages/software-factory/tsconfig.json @@ -25,14 +25,7 @@ "skipLibCheck": true, "strict": true, "paths": { - "https://cardstack.com/base/*": ["../base/*"], - "@cardstack/boxel-host/commands/*": ["../host/app/commands/*"], - "@cardstack/boxel-ui": ["../boxel-ui/addon"], - "@cardstack/boxel-ui/*": ["../boxel-ui/addon/*"], - "@cardstack/boxel-ui/helpers": ["../boxel-ui/addon/src/helpers"], - "@cardstack/boxel-ui/helpers/*": ["../boxel-ui/addon/src/helpers/*"], - "@cardstack/boxel-icons": ["../boxel-icons/src"], - "@cardstack/boxel-icons/*": ["../boxel-icons/src/icons/*"] + "https://cardstack.com/base/*": ["../base/*"] }, "types": ["@cardstack/local-types", "node"] }, From 83161fb726efdb71d50c58f2e273b618d57b6ff1 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Wed, 18 Mar 2026 13:58:42 -0400 Subject: [PATCH 7/8] fix upstream lint failure --- .../server-endpoints/delete-realm-test.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/realm-server/tests/server-endpoints/delete-realm-test.ts b/packages/realm-server/tests/server-endpoints/delete-realm-test.ts index fb8d1cb76b..7775756d43 100644 --- a/packages/realm-server/tests/server-endpoints/delete-realm-test.ts +++ b/packages/realm-server/tests/server-endpoints/delete-realm-test.ts @@ -20,7 +20,7 @@ module(`server-endpoints/${basename(__filename)}`, function (hooks) { async function createRealmFor(ownerUserId: string) { let endpoint = `delete-me-${uuidv4()}`; - let response = await context.request2 + let response = await context.request .post('/_create-realm') .set('Accept', 'application/vnd.api+json') .set('Content-Type', 'application/json') @@ -110,7 +110,7 @@ module(`server-endpoints/${basename(__filename)}`, function (hooks) { 'mango@example.com', ); - let publishResponse = await context.request2 + let publishResponse = await context.request .post('/_publish-realm') .set('Accept', 'application/vnd.api+json') .set('Content-Type', 'application/json') @@ -277,7 +277,7 @@ module(`server-endpoints/${basename(__filename)}`, function (hooks) { insert('claimed_domains_for_sites', nameExpressions, valueExpressions), ); - let deleteResponse = await context.request2 + let deleteResponse = await context.request .delete('/_delete-realm') .set('Accept', 'application/vnd.api+json') .set('Content-Type', 'application/json') @@ -607,13 +607,13 @@ module(`server-endpoints/${basename(__filename)}`, function (hooks) { ); assert.notOk( - context.testRealmServer2.testingOnlyRealms.find( + context.testRealmServer.testingOnlyRealms.find( (realm) => realm.url === realmURL, ), 'source realm is unmounted', ); assert.notOk( - context.testRealmServer2.testingOnlyRealms.find( + context.testRealmServer.testingOnlyRealms.find( (realm) => realm.url === publishedRealmURL, ), 'published realm is unmounted', @@ -633,7 +633,7 @@ module(`server-endpoints/${basename(__filename)}`, function (hooks) { 'mango@example.com', ); - let publishResponse = await context.request2 + let publishResponse = await context.request .post('/_publish-realm') .set('Accept', 'application/vnd.api+json') .set('Content-Type', 'application/json') @@ -664,7 +664,7 @@ module(`server-endpoints/${basename(__filename)}`, function (hooks) { 'published realm directory exists', ); - let mountedPublishedRealm = context.testRealmServer2.testingOnlyRealms.find( + let mountedPublishedRealm = context.testRealmServer.testingOnlyRealms.find( (realm) => realm.url === publishedRealmURL, ); if (!mountedPublishedRealm) { @@ -673,10 +673,10 @@ module(`server-endpoints/${basename(__filename)}`, function (hooks) { context.virtualNetwork.unmount(mountedPublishedRealm.handle); let mountedRealms = ( - context.testRealmServer2 as unknown as { realms: { url: string }[] } + context.testRealmServer as unknown as { realms: { url: string }[] } ).realms; let publishedRealmIndex = mountedRealms.findIndex( - (realm) => realm.url === publishedRealmURL, + (realm: { url: string }) => realm.url === publishedRealmURL, ); assert.notStrictEqual( publishedRealmIndex, @@ -685,7 +685,7 @@ module(`server-endpoints/${basename(__filename)}`, function (hooks) { ); mountedRealms.splice(publishedRealmIndex, 1); - let deleteResponse = await context.request2 + let deleteResponse = await context.request .delete('/_delete-realm') .set('Accept', 'application/vnd.api+json') .set('Content-Type', 'application/json') @@ -720,7 +720,7 @@ module(`server-endpoints/${basename(__filename)}`, function (hooks) { 'published realm records are removed', ); assert.notOk( - context.testRealmServer2.testingOnlyRealms.find( + context.testRealmServer.testingOnlyRealms.find( (realm) => realm.url === realmURL, ), 'source realm is unmounted', @@ -736,7 +736,7 @@ module(`server-endpoints/${basename(__filename)}`, function (hooks) { [ownerUserId]: ['read', 'write', 'realm-owner'], }); - let response = await context.request2 + let response = await context.request .delete('/_delete-realm') .set('Accept', 'application/vnd.api+json') .set('Content-Type', 'application/json') @@ -768,7 +768,7 @@ module(`server-endpoints/${basename(__filename)}`, function (hooks) { test('DELETE /_delete-realm rejects an invalid realm URL', async function (assert) { let ownerUserId = `@mango-${uuidv4()}:localhost`; - let response = await context.request2 + let response = await context.request .delete('/_delete-realm') .set('Accept', 'application/vnd.api+json') .set('Content-Type', 'application/json') From 9ad5e6d460ff3ef1a449c6c8ea6b277e0b5e0732 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Wed, 18 Mar 2026 14:27:35 -0400 Subject: [PATCH 8/8] more clarification around auth re: username/password --- packages/software-factory/README.md | 55 ++++++++++++--- .../src/factory-entrypoint.ts | 11 ++- .../tests/factory-entrypoint.test.ts | 9 +++ .../tests/helpers/realm-auth.ts | 42 ++++++++++-- .../software-factory/tests/realm-auth.test.ts | 67 +++++++++++++++++++ 5 files changed, 168 insertions(+), 16 deletions(-) diff --git a/packages/software-factory/README.md b/packages/software-factory/README.md index a9ed92e157..9871b517e3 100644 --- a/packages/software-factory/README.md +++ b/packages/software-factory/README.md @@ -83,7 +83,7 @@ Parameters: - The command fetches card source JSON from this URL and includes normalized brief metadata in the summary. - `--auth-token` - Optional. Explicit `Authorization` header value to use when fetching the brief. - - When omitted, `factory:go` will try to resolve realm auth from the active Boxel profile for matching realm-server URLs. + - This is an override. You usually do not need it unless you already have a token and want to force that exact header value. - `--target-realm-path` - Required. Local filesystem path to the Boxel realm where the factory should write output. - `--target-realm-url` @@ -93,18 +93,58 @@ Parameters: - `--help` - Optional. Prints the command usage and exits. -Getting an auth token: +Auth for fetching private briefs: -- If the brief lives on a private realm and you want to pass the token explicitly, first ask the package session helper for the realm token: +- If the brief is in a public realm, you do not need any auth setup. +- If the brief is in a private realm, `factory:go` can usually authenticate without `--auth-token`. It will try these sources in order: + - the active Boxel profile in `~/.boxel-cli/profiles.json` + - `MATRIX_URL`, `MATRIX_USERNAME`, `MATRIX_PASSWORD`, and `REALM_SERVER_URL` + - `MATRIX_URL`, `REALM_SERVER_URL`, and `REALM_SECRET_SEED` +- When using `REALM_SECRET_SEED`, `factory:go` can derive the realm username from the brief URL when `MATRIX_USERNAME` is not set. + +Private brief with explicit Matrix username/password env: + +```bash +export MATRIX_URL=http://localhost:8008/ +export MATRIX_USERNAME=factory +read -s MATRIX_PASSWORD'?Matrix password: ' +export MATRIX_PASSWORD +export REALM_SERVER_URL=http://localhost:4201/ + +pnpm factory:go -- \ + --brief-url http://localhost:4201/software-factory/Wiki/sticky-note \ + --target-realm-path /path/to/target-realm +``` + +Private brief with `REALM_SECRET_SEED` instead of username/password: ```bash -pnpm boxel:session -- --realm http://localhost:4201/software-factory/ +export MATRIX_URL=http://localhost:8008/ +export REALM_SERVER_URL=http://localhost:4201/ +read -s REALM_SECRET_SEED'?Realm secret seed: ' +export REALM_SECRET_SEED + +pnpm factory:go -- \ + --brief-url http://localhost:4201/software-factory/Wiki/sticky-note \ + --target-realm-path /path/to/target-realm ``` -- The command prints JSON with a `boxelSession` object keyed by realm URL. Extract the token for the brief's realm and pass it through `--auth-token`: +Getting an auth token explicitly: + +- Only use this when you specifically want to pass `--auth-token`. `factory:go` itself does not require this if one of the auth sources above is already configured. +- `pnpm boxel:session` can authenticate from the active Boxel profile or from `MATRIX_URL`, `MATRIX_USERNAME`, `MATRIX_PASSWORD`, and `REALM_SERVER_URL`. +- `pnpm boxel:session` does not require `--realm`. Passing `--realm` only narrows the returned `boxelSession` object to specific realms. + +Example: ```bash -AUTH_TOKEN="$(pnpm --silent boxel:session -- --realm http://localhost:4201/software-factory/ | jq -r '.boxelSession[\"http://localhost:4201/software-factory/\"]')" +export MATRIX_URL=http://localhost:8008/ +export MATRIX_USERNAME=factory +read -s MATRIX_PASSWORD'?Matrix password: ' +export MATRIX_PASSWORD +export REALM_SERVER_URL=http://localhost:4201/ + +AUTH_TOKEN="$(pnpm --silent boxel:session | jq -r '.boxelSession[\"http://localhost:4201/software-factory/\"]')" pnpm factory:go -- \ --brief-url http://localhost:4201/software-factory/Wiki/sticky-note \ @@ -112,8 +152,7 @@ pnpm factory:go -- \ --target-realm-path /path/to/target-realm ``` -- When the brief is in a public realm, you do not need this flag. -- When the brief is in a private realm, pass the exact `Authorization` header value returned by `pnpm boxel:session`. +- When you do use `--auth-token`, pass the exact `Authorization` header value returned by `pnpm boxel:session`. ## Layout diff --git a/packages/software-factory/src/factory-entrypoint.ts b/packages/software-factory/src/factory-entrypoint.ts index 74e0b7cba4..47f71d590d 100644 --- a/packages/software-factory/src/factory-entrypoint.ts +++ b/packages/software-factory/src/factory-entrypoint.ts @@ -69,10 +69,19 @@ export function getFactoryEntrypointUsage(): string { ' --target-realm-path Local filesystem path to the target realm', '', 'Options:', - ' --auth-token Optional Authorization header override for fetching the brief', + ' --auth-token Optional explicit Authorization header override', ' --target-realm-url Absolute URL for the target realm when known', ' --mode One of: bootstrap, implement, resume', ' --help Show this usage information', + '', + 'Auth:', + ' For public briefs, no auth setup is needed.', + ' For private briefs, factory:go can authenticate without --auth-token via:', + ' 1. the active Boxel profile, or', + ' 2. MATRIX_URL + MATRIX_USERNAME + MATRIX_PASSWORD + REALM_SERVER_URL, or', + ' 3. MATRIX_URL + REALM_SERVER_URL + REALM_SECRET_SEED', + ' Use --auth-token only when you already have an Authorization header value', + ' and want to override the automatic auth resolution.', ].join('\n'); } diff --git a/packages/software-factory/tests/factory-entrypoint.test.ts b/packages/software-factory/tests/factory-entrypoint.test.ts index 3f9a65e2aa..9c4611ada2 100644 --- a/packages/software-factory/tests/factory-entrypoint.test.ts +++ b/packages/software-factory/tests/factory-entrypoint.test.ts @@ -144,6 +144,15 @@ module('factory-entrypoint', function () { assert.true(/--target-realm-url /.test(usage)); assert.true(/--mode /.test(usage)); assert.true(/--help/.test(usage)); + assert.true(/For public briefs, no auth setup is needed./.test(usage)); + assert.true( + /MATRIX_URL \+ MATRIX_USERNAME \+ MATRIX_PASSWORD \+ REALM_SERVER_URL/.test( + usage, + ), + ); + assert.true( + /MATRIX_URL \+ REALM_SERVER_URL \+ REALM_SECRET_SEED/.test(usage), + ); }); test('runFactoryEntrypoint loads and includes normalized brief data', async function (assert) { diff --git a/packages/software-factory/tests/helpers/realm-auth.ts b/packages/software-factory/tests/helpers/realm-auth.ts index f5fe1e121c..02377ade3d 100644 --- a/packages/software-factory/tests/helpers/realm-auth.ts +++ b/packages/software-factory/tests/helpers/realm-auth.ts @@ -22,12 +22,15 @@ export interface RealmAuthTestServers { export async function startServers( options: { + password?: string; sessionToken?: string; username?: string; } = {}, ): Promise { + let username = options.username ?? 'software-factory-browser'; let matrixServer = await startMatrixStubServer( - options.username ?? 'software-factory-browser', + username, + options.password ?? browserPassword(username), ); let realmServer = await startPrivateRealmStubServer({ sessionToken: options.sessionToken ?? buildRealmSessionJwt(), @@ -43,7 +46,10 @@ export async function startServers( }; } -async function startMatrixStubServer(username: string): Promise { +async function startMatrixStubServer( + username: string, + password: string, +): Promise { let userId = `@${username}:localhost`; let openIdPath = `/_matrix/client/v3/user/${encodeURIComponent( userId, @@ -56,11 +62,33 @@ async function startMatrixStubServer(username: string): Promise { request.method === 'POST' && request.url === '/_matrix/client/v3/login' ) { - respondJson(response, { - access_token: 'matrix-access-token', - device_id: 'device-id', - user_id: userId, - }); + void (async () => { + let body = await readRequestBody(request); + let parsedBody = JSON.parse(body) as { + identifier?: { user?: string }; + password?: string; + }; + + if ( + parsedBody.identifier?.user !== username || + parsedBody.password !== password + ) { + response.writeHead(401, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + errcode: 'M_FORBIDDEN', + error: 'Invalid matrix credentials', + }), + ); + return; + } + + respondJson(response, { + access_token: 'matrix-access-token', + device_id: 'device-id', + user_id: userId, + }); + })(); return; } diff --git a/packages/software-factory/tests/realm-auth.test.ts b/packages/software-factory/tests/realm-auth.test.ts index bb646f1463..2782f1a320 100644 --- a/packages/software-factory/tests/realm-auth.test.ts +++ b/packages/software-factory/tests/realm-auth.test.ts @@ -8,6 +8,7 @@ import { type ActiveBoxelProfile, } from '../src/realm-auth'; import { FactoryBriefError, loadFactoryBrief } from '../src/factory-brief'; +import { matrixLogin } from '../scripts/lib/boxel'; import { browserPassword, buildRealmSessionJwt, @@ -227,6 +228,72 @@ module('realm-auth', function () { } }); + test('createBoxelRealmFetch uses MATRIX_USERNAME and MATRIX_PASSWORD env auth for a private brief', async function (assert) { + let tempHome = mkdtempSync(join(tmpdir(), 'software-factory-realm-auth-')); + let originalHome = process.env.HOME; + let originalMatrixUrl = process.env.MATRIX_URL; + let originalMatrixUsername = process.env.MATRIX_USERNAME; + let originalMatrixPassword = process.env.MATRIX_PASSWORD; + let originalRealmServerUrl = process.env.REALM_SERVER_URL; + let originalRealmSecretSeed = process.env.REALM_SECRET_SEED; + let username = 'software-factory-browser'; + let password = browserPassword(username); + let servers = await startServers({ username, password }); + let briefUrl = `${servers.realmServer.realmUrl}Wiki/brief-card`; + + try { + process.env.HOME = tempHome; + process.env.MATRIX_URL = servers.matrixServer.url; + process.env.MATRIX_USERNAME = username; + process.env.MATRIX_PASSWORD = password; + delete process.env.REALM_SERVER_URL; + delete process.env.REALM_SECRET_SEED; + + let brief = await loadFactoryBrief(briefUrl, { + fetch: createBoxelRealmFetch(briefUrl), + }); + + assert.strictEqual(brief.title, 'Private Brief'); + assert.strictEqual( + brief.contentSummary, + 'Private brief content for testing realm auth.', + ); + } finally { + process.env.HOME = originalHome; + process.env.MATRIX_URL = originalMatrixUrl; + process.env.MATRIX_USERNAME = originalMatrixUsername; + process.env.MATRIX_PASSWORD = originalMatrixPassword; + process.env.REALM_SERVER_URL = originalRealmServerUrl; + process.env.REALM_SECRET_SEED = originalRealmSecretSeed; + await servers.stop(); + rmSync(tempHome, { recursive: true, force: true }); + } + }); + + test('matrixLogin rejects when MATRIX_USERNAME and MATRIX_PASSWORD are invalid', async function (assert) { + let username = 'software-factory-browser'; + let servers = await startServers({ username }); + + try { + await assert.rejects( + matrixLogin({ + profileId: null, + username, + matrixUrl: servers.matrixServer.url, + realmServerUrl: servers.realmServer.origin, + password: 'wrong-password', + }), + (error: unknown) => + error instanceof Error && + error.message.includes( + `Matrix login failed: 401 {"errcode":"M_FORBIDDEN","error":"Invalid matrix credentials"}`, + ), + ); + } finally { + await servers.stop(); + } + }); + test('createBoxelRealmFetch derives env credentials from REALM_SECRET_SEED when MATRIX_PASSWORD is absent', async function (assert) { let tempHome = mkdtempSync(join(tmpdir(), 'software-factory-realm-auth-')); let originalHome = process.env.HOME;