Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 28 additions & 26 deletions apps/web/app/board/lib/triage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,42 +44,44 @@ export type BoardRequestDependencies = {
export type AuthenticatedBoardResult =
{ status: 'authorized'; rows: BoardRow[] } | { status: 'unavailable'; rows: [] };

async function getProductionDependencies(): Promise<BoardRequestDependencies> {
return {
async getVerifiedAuthUserId() {
const { getVerifiedAuthUserId } = await import('../../../lib/supabase/server');
return getVerifiedAuthUserId();
},
async getGuideScopeForAuthUser(authUserId) {
// The direct Postgres client rejects missing configuration at import time. Check before
// importing it so an incomplete demo deployment refuses access without leaking board state.
if (!process.env.DATABASE_URL) return null;
const { getGuideScopeForAuthUser } = await import('@huddle/db/scoped.js');
return getGuideScopeForAuthUser(authUserId);
},
async getTriageBoardForGuide(scope, boardDate) {
const { getTriageBoardForGuide } = await import('@huddle/db/scoped.js');
return getTriageBoardForGuide(scope, boardDate);
},
};
}

/**
* Request-time board boundary. No caller may supply a guide id: an authenticated Supabase identity
* must first resolve to exactly one server-owned guide and synthetic studio scope.
*/
export function chicagoBoardDate(now: Date): string {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Chicago',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(now);
const values = Object.fromEntries(
parts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value])
);
return `${values.year}-${values.month}-${values.day}`;
}

export async function getBoardEntriesForAuthenticatedRequest(
dependencies?: BoardRequestDependencies,
boardDate = new Date().toISOString().slice(0, 10)
boardDate?: string
): Promise<AuthenticatedBoardResult> {
const deps = dependencies ?? (await getProductionDependencies());
const authUserId = await deps.getVerifiedAuthUserId();
const resolvedBoardDate = boardDate ?? chicagoBoardDate(new Date());
if (!dependencies) {
const { resolveGuideAccess } = await import('../../../lib/guide-access');
const access = await resolveGuideAccess();
if (!access) return { status: 'unavailable', rows: [] };
const { getTriageBoardForGuide } = await import('@huddle/db/scoped.js');
const rows = await getTriageBoardForGuide(access, resolvedBoardDate);
return toBoardResult(rows);
}
const authUserId = await dependencies.getVerifiedAuthUserId();
if (!authUserId) return { status: 'unavailable', rows: [] };

const scope = await deps.getGuideScopeForAuthUser(authUserId);
const scope = await dependencies.getGuideScopeForAuthUser(authUserId);
if (!scope) return { status: 'unavailable', rows: [] };
return toBoardResult(await dependencies.getTriageBoardForGuide(scope, resolvedBoardDate));
}

const rows = await deps.getTriageBoardForGuide(scope, boardDate);
function toBoardResult(rows: PersistedBoardRow[]): AuthenticatedBoardResult {
return {
status: 'authorized',
rows: rows.map((row) => ({
Expand Down
24 changes: 24 additions & 0 deletions apps/web/lib/guide-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import 'server-only';

import type { GuideAccess } from '@huddle/application';
import { getVerifiedAuthUserId } from './supabase/server';

/**
* The sole browser-independent composition boundary for trusted guide scope.
* No route, form, credential, or client may supply a guide/studio/timezone/data scope.
*/
export async function resolveGuideAccess(): Promise<GuideAccess | null> {
const authUserId = await getVerifiedAuthUserId();
if (!authUserId || !process.env.DATABASE_URL) return null;
const { getGuideScopeForAuthUser } = await import('@huddle/db/scoped.js');
const scope = await getGuideScopeForAuthUser(authUserId);
return scope
? {
authUserId,
guideId: scope.guideId,
studioId: scope.studioId,
role: 'guide',
syntheticOnly: true,
}
: null;
}
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"test": "vitest run"
},
"dependencies": {
"@huddle/application": "^0.1.0",
"@huddle/core": "^0.1.0",
"@huddle/db": "^0.1.0",
"@huddle/signal-engine": "^0.1.0",
Expand Down
6 changes: 3 additions & 3 deletions apps/web/test/board-without-narrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ describe('Board without narrator', () => {
const rows = await getSyntheticFixtureBoardEntriesForTest();

expect(rows.map((row) => row.rank)).toEqual([1, 2]);
expect(rows.map((row) => row.cause)).toEqual(['guessing', 'prerequisite_gap']);
expect(rows.map((row) => row.cause)).toEqual(['prerequisite_gap', 'guessing']);
expect(rows.some((row) => row.cause === 'fine')).toBe(false);
expect(rows.every((row) => row.diagnosis === null && row.opener === null)).toBe(true);
});
Expand All @@ -17,10 +17,10 @@ describe('Board without narrator', () => {
const rows = await getSyntheticFixtureBoardEntriesForTest();

expect(rows.map((row) => row.studentId)).toEqual([
'11111111-1111-1111-1111-111111111111',
'22222222-2222-2222-2222-222222222222',
'11111111-1111-1111-1111-111111111111',
]);
expect(rows.map((row) => row.studentFirstName)).toEqual(['Avery', 'Blake']);
expect(rows.map((row) => row.studentFirstName)).toEqual(['Blake', 'Avery']);
});
});

Expand Down
4 changes: 4 additions & 0 deletions apps/web/test/quick-demo-access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it, vi } from 'vitest';
import {
chicagoBoardDate,
getBoardEntriesForAuthenticatedRequest,
type BoardRequestDependencies,
} from '../app/board/lib/triage';
Expand All @@ -22,6 +23,9 @@ function dependencies(overrides: Partial<BoardRequestDependencies> = {}): BoardR
}

describe('quick-demo authenticated board access', () => {
it('derives the default BoardKey from America/Chicago rather than UTC', () => {
expect(chicagoBoardDate(new Date('2026-03-09T00:30:00.000Z'))).toBe('2026-03-08');
});
it('fails closed before any scope or board query when Supabase has no verified user', async () => {
const deps = dependencies({ getVerifiedAuthUserId: vi.fn(async () => null) });

Expand Down
3 changes: 3 additions & 0 deletions apps/web/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
{
"path": "../../packages/core"
},
{
"path": "../../packages/application"
},
{
"path": "../../packages/db"
},
Expand Down
41 changes: 41 additions & 0 deletions db/migrations/007_shared_foundation.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
-- Shared foundation reservation. Feature owners extend only after these identities exist.
-- 008_import_receipt_session.sql: import_run / learning_session / receipt lineage
-- 009_board_run_head.sql: board_refresh_request / immutable board_run / board_head / run entries
-- 010_evidence_acknowledgment.sql: acknowledgment grants, nonces, replay ledger (must follow 009)
-- Legacy synthetic derived attempts/boards are rebuilt from canonical fixtures by the target
-- importer/compiler. Retained legacy rows must be explicitly legacy/non-displayable and never heads.

ALTER TABLE item
ADD COLUMN IF NOT EXISTS item_type text NOT NULL DEFAULT 'multiple_choice'
CHECK (item_type IN ('multiple_choice', 'numeric', 'short_text')),
ADD COLUMN IF NOT EXISTS timing_profile text NOT NULL DEFAULT 'standard_multiple_choice'
CHECK (timing_profile IN ('standard_multiple_choice', 'word_problem'));
UPDATE item
SET timing_profile = 'word_problem'
WHERE id IN (
'wp:TEKS.4.4H-01',
'wp:TEKS.4.5A-01',
'wp:TEKS.4.8C-01',
'wp:TEKS.4.9B-01'
);

-- Existing date-keyed derived rows lack target receipt/rule/evidence provenance. They remain
-- historical only and cannot be returned as an active board while the target compiler recomputes.
ALTER TABLE triage_entry
ADD COLUMN IF NOT EXISTS legacy_non_displayable boolean NOT NULL DEFAULT true;
ALTER TABLE triage_entry
ALTER COLUMN legacy_non_displayable SET DEFAULT true;
UPDATE triage_entry SET legacy_non_displayable = true WHERE legacy_non_displayable = false;

-- Mastery belongs to the same half-open [start,end) semantics as board compilation.
CREATE OR REPLACE FUNCTION mastery_at(anchor timestamptz)
RETURNS TABLE (student_id uuid, skill_id text, value real, attempt_count bigint, is_known boolean)
LANGUAGE sql STABLE AS $$
SELECT a.student_id, a.skill_id,
(sum(a.is_correct::int * exp(-ln(2) * extract(epoch FROM (anchor - a.submitted_at)) / 86400.0 / 14.0))
/ nullif(sum(exp(-ln(2) * extract(epoch FROM (anchor - a.submitted_at)) / 86400.0 / 14.0)), 0))::real,
count(*), count(*) >= 5
FROM attempt a
WHERE a.submitted_at < anchor
GROUP BY a.student_id, a.skill_id;
$$;
19 changes: 17 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions packages/application/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "@huddle/application",
"version": "0.1.0",
"type": "module",
"main": "./dist/src/index.js",
"types": "./dist/src/index.d.ts",
"exports": {
".": {
"import": "./dist/src/index.js",
"types": "./dist/src/index.d.ts"
}
},
"scripts": {
"build": "tsc -b",
"test": "vitest run"
},
"dependencies": {
"@huddle/core": "^0.1.0"
},
"devDependencies": {
"@types/node": "^20.14.0",
"typescript": "^5.5.0",
"vitest": "^2.0.0"
}
}
20 changes: 20 additions & 0 deletions packages/application/src/access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export interface GuideAccess {
authUserId: string;
guideId: string;
studioId: string;
role: 'guide';
syntheticOnly: true;
}

export interface SchedulerAccess {
workerId: string;
capability: 'board-refresh';
syntheticOnly: true;
}

export interface BoardKey {
boardDate: string;
}

/** Server composition must verify Auth and resolve exactly one synthetic guide scope. */
export type ResolveGuideAccess = () => Promise<GuideAccess | null>;
21 changes: 21 additions & 0 deletions packages/application/src/board-compiler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { GuideAccess, SchedulerAccess } from './access.js';
import type { RefreshFailureCode, RefreshView } from './board-reader.js';

export type RefreshRequestInput =
| { trigger: 'manual'; access: GuideAccess; boardDate: string }
| {
trigger: 'nightly';
scheduler: SchedulerAccess;
applicationScopeId: string;
boardDate: string;
};
export interface BoardCompiler {
request(input: RefreshRequestInput): Promise<RefreshView>;
compile(
scheduler: SchedulerAccess,
refreshRequestId: string
): Promise<
| { kind: 'succeeded'; boardRunId: string; replacedBoardRunId: string | null }
| { kind: 'failed'; preservedBoardRunId: string | null; failureCode: RefreshFailureCode }
>;
}
70 changes: 70 additions & 0 deletions packages/application/src/board-reader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { EvidenceScope, RootCause } from '@huddle/core';
import type { BoardKey, GuideAccess } from './access.js';

export type RefreshFailureCode =
| 'input-unavailable'
| 'compile-failed'
| 'persistence-failed'
| 'deadline-missed'
| 'worker-timeout';
export type RefreshView =
| { state: 'idle' }
| { state: 'queued' | 'running'; requestId: string; requestedAt: string }
| { state: 'succeeded'; requestId: string; completedAt: string; boardRunId: string }
| {
state: 'failed';
requestId: string;
completedAt: string;
failureCode: RefreshFailureCode;
preservedBoardRunId: string | null;
};

export interface NarrationView {
mode: 'generated' | 'deterministic-fallback';
status: 'complete' | 'degraded';
degradedReason:
| null
| 'pending'
| 'model-unavailable'
| 'timeout'
| 'provider-error'
| 'selection-invalid'
| 'grounding-rejected'
| 'stale-result';
catalogVersion: string;
renderVersion: string;
}
export interface BoardEntryView {
triageEntryId: string;
findingFingerprint: string;
student: { id: string; firstName: string };
rank: number;
cause: RootCause;
scope: EvidenceScope;
severity: number;
finalConfidence: number;
diagnosis: string;
opener: string;
narration: NarrationView;
acknowledgedAt: string | null;
additionalCauseCount: number;
}
export type BoardView =
| { kind: 'not-built'; refresh: RefreshView }
| {
kind: 'ready' | 'successful-empty' | 'stale';
boardRunId: string;
requestedBoardDate: string;
boardDate: string;
asOf: string;
timezone: 'America/Chicago';
completedAt: string;
inputReceiptSetFingerprint: string;
entries: BoardEntryView[];
refresh: RefreshView;
narration: { status: 'complete' | 'degraded'; degradedCount: number };
};

export interface BoardReader {
readCurrent(access: GuideAccess, key: BoardKey): Promise<BoardView>;
}
Loading
Loading