Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ Milestone detail: [`docs/idea/14-roadmap.md`](docs/idea/14-roadmap.md).
| test (one file) | `bun test packages/core/src/errors.test.ts` |
| test (one name) | `bun test -t 'formats the fix line'` |
| import boundaries | `bun run boundaries` |
| unsafe error rendering | `bun run error-render` — a step of the gate's `errors` check, standalone. Refuses an `unknown` reaching a `cause:`/`fix:` through `${x}`, `JSON.stringify(x)` or `String(x)`; all three throw on real app values, and the bug shipped three times before it was mechanised |
| regenerate manifest | `bun run manifest` |
| list workspaces | `bun run workspaces:list` |
| new framework package | `bun run scripts/new-package.ts <name> --tier <n>` |
Expand Down
1 change: 1 addition & 0 deletions bun.lock

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

13 changes: 13 additions & 0 deletions examples/dummy/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,16 @@ Feature slice: `apps/web/app/<feature>/{entity,repo,service,actions,mutator,live
remaining instances, not a pattern to copy.
- Uploads are `grantUpload` wrapped in an app `action` — the app owns the policy, the framework
owns the key and the signature. Nothing here builds an object key by hand.
- `app/auth/login.ts` is the whole of "log in with GitHub" — `defineAuth` + `oauthLogin`, and the
three decisions an app owns: `providers`, `link` and where a signed-in member lands. Its two
route descriptors are **declared and driven by `login.test.ts`, but not served**, and that is a
framework gap rather than a shortcut here: an app's HTTP surface is composed in
`packages/cli/src/serve.ts` out of actions, queries, assets, storage, islands and page routes,
and there is no seam by which an app contributes a raw `Route` — `configureAuthenticator()` is
the only app-installed hook of that shape. So `start`/`callback` stay exported and unmounted
until that seam exists. Two things then remain here: mounting them, and the `x_users` /
`x_sessions` / `x_accounts` tables `BuiltinAdapter` reads, which no migration in
`packages/db/migrations` creates — `AUTH_TABLES` is DDL the framework exports and `x db gen`
generates only from this app's own entities, so neither half is a file to hand-write. Until
both land nobody can hold a Postly session, which is also why `configureAuthenticator()` is
still uncalled and `ctx.auth` still undeclared.
1 change: 1 addition & 0 deletions examples/dummy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ apps/web/app/<feature>/{entity,repo,service,actions,mutator,live,jobs,policy,ui}
| **Money** | [`packages/core/src/billing.ts`](packages/core/src/billing.ts) | integer minor units, USD + EUR, arithmetic never leaves minor units, `Intl` only at the edge |
| **Offline** | [`apps/web/app/posts/mutator.ts`](apps/web/app/posts/mutator.ts) | `likePost` queues offline and reconciles; feed reads from the persisted store; [`site/offline/page.tsx`](apps/web/site/offline/page.tsx) is the required fallback |
| **Realtime** | [`apps/web/app/feed/page.tsx`](apps/web/app/feed/page.tsx) | tier 3 — `useLiveFeed()` (bound once with `liveHookFor`) is a Solid signal, patched per row |
| **Auth** | [`apps/web/app/auth/login.ts`](apps/web/app/auth/login.ts) | "log in with GitHub" is `defineAuth` + `oauthLogin`, ~12 lines; the round trip — 302 with an S256 challenge, a forged `state` refused, a session `authenticate()` resolves — is asserted in [`login.test.ts`](apps/web/app/auth/login.test.ts) against a stubbed provider, because no client id exists in CI. **Not yet reachable in a browser:** see the gotcha in [`CLAUDE.md`](CLAUDE.md) |
| **AI-first** | [`packages/mcp/src/tools.ts`](packages/mcp/src/tools.ts) | every exposed action is an MCP tool with the *same* policy; admin ships its own MCP surface |
| **Admin** | [`apps/admin/src/index.ts`](apps/admin/src/index.ts) | the whole dashboard, 20 lines of `defineAdmin` |
| **Prompts** | [`apps/web/app/posts/prompts`](apps/web/app/posts/prompts) | versioned `.md` artifact + typed slots + a scored eval |
Expand Down
153 changes: 153 additions & 0 deletions examples/dummy/apps/web/app/auth/login.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
* The round trip "log in with GitHub" is, driven through Postly's OWN declaration: its providers,
* its link policy, its landing path. Three shipped error codes told the caller to restart at
* `GET /auth/oauth/<provider>` while nothing anywhere ran the flow — so this asserts the path the
* refusal names against the path the declaration mounts, never against a string this file repeats.
*/

import { beforeEach, describe, expect, test } from 'bun:test';
import type { OAuthFetch } from '@ultimat3/auth';
import { authenticate, MemoryAdapter, readSessionCookie } from '@ultimat3/auth';
import { frozenClock } from '@ultimat3/core';
import { AFTER_SIGN_IN, postlyAuth, postlyLogin } from './login';

const NOW = new Date('2026-08-15T12:00:00.000Z');
/** 32 bytes, the length `handshakeSecret` demands — never Postly's real `SESSION_SECRET`. */
const SECRET = 'postly-test-handshake-secret-000';
const ORIGIN = 'https://postly.test';
const CREDENTIALS = { clientId: 'postly-client-id', clientSecret: 'postly-client-secret' };

/**
* There is no GitHub client id in CI and there never will be, so the provider is the one seam the
* framework already hands a caller: the three calls a real login makes, answered in process. Every
* other step — PKCE, the sealed handshake, the state check, the session — is the real code.
*/
const githubFetch: OAuthFetch = (input) => {
if (input === 'https://github.com/login/oauth/access_token') {
return Promise.resolve(Response.json({ access_token: 'gho_postly', token_type: 'bearer' }));
}
if (input === 'https://api.github.com/user') {
return Promise.resolve(Response.json({ id: 4207, login: 'ada', name: 'Ada Lovelace' }));
}
if (input === 'https://api.github.com/user/emails') {
return Promise.resolve(
Response.json([{ email: 'ada@postly.test', primary: true, verified: true }]),
);
}
return Promise.resolve(new Response('a call this login does not make', { status: 500 }));
};

let adapter: MemoryAdapter;
let auth: ReturnType<typeof postlyAuth>;
let login: ReturnType<typeof postlyLogin>;

beforeEach(() => {
adapter = new MemoryAdapter();
auth = postlyAuth({ adapter, clock: frozenClock(NOW) });
login = postlyLogin(auth, {
credentials: CREDENTIALS,
fetch: githubFetch,
secret: SECRET,
baseUrl: ORIGIN,
});
});

/** `/auth/oauth/:provider` → the URL a browser is actually sent to. The mount, filled in. */
const mounted = (pattern: string, provider: string): string =>
pattern.replace(':provider', provider);

/** `name=value` — what a browser sends back, without the attributes it keeps to itself. */
const cookiePair = (setCookie: string): string => setCookie.slice(0, setCookie.indexOf(';'));

const bodyOf = async (response: Response): Promise<Record<string, unknown>> => {
const parsed: unknown = await response.json();
expect(parsed).toBeObject();
return parsed as Record<string, unknown>;
};

const startRequest = (provider: string): Request =>
new Request(`${ORIGIN}${mounted(login.start.path, provider)}`);

describe('log in with GitHub', () => {
test('the start leg leaves for github with a state and an S256 challenge', async () => {
const response = await login.start.handle(startRequest('github'));

expect(response.status).toBe(302);
const authorize = new URL(response.headers.get('location') ?? '');
expect(`${authorize.origin}${authorize.pathname}`).toBe(
'https://github.com/login/oauth/authorize',
);
expect(authorize.searchParams.get('state')).not.toBe('');
expect(authorize.searchParams.get('code_challenge_method')).toBe('S256');
expect(authorize.searchParams.get('code_challenge')).not.toBeNull();
// The address GitHub sends the browser back to is Postly's own callback mount, not a literal.
expect(authorize.searchParams.get('redirect_uri')).toBe(
`${ORIGIN}${mounted(login.callback.path, 'github')}`,
);
// Sealed across the two requests, and readable by nothing in the page.
const sealed = response.headers.getSetCookie();
expect(sealed).toHaveLength(1);
expect(sealed[0]).toContain('HttpOnly');
});

test('a forged state is refused, and its fix names a path this app mounts', async () => {
const start = await login.start.handle(startRequest('github'));

const done = await login.callback.handle(
new Request(`${ORIGIN}${mounted(login.callback.path, 'github')}?code=c&state=forged`, {
headers: { cookie: cookiePair(start.headers.getSetCookie()[0] ?? '') },
}),
);

expect(done.status).toBe(400);
const body = await bodyOf(done);
expect(body['code']).toBe('X_OAUTH_STATE_INVALID');

// Axiom 4, checked as a round trip: the path the fix line tells the caller to restart at has
// to be one this app's own start descriptor claims. A literal here would pass while the mount
// moved out from under it, which is exactly how three fix lines outlived their route.
const named = /GET (\/\S+)/.exec(String(body['fix']))?.[1] ?? '';
expect(named).toBe(mounted(login.start.path, 'github'));

// The code the handshake authorised is spent whether or not the callback succeeded.
expect(done.headers.getSetCookie().some((c) => c.includes('=;'))).toBe(true);
});

test('a completed login lands on Postly and mints a session Postly can authenticate', async () => {
const start = await login.start.handle(startRequest('github'));
const state = new URL(start.headers.get('location') ?? '').searchParams.get('state') ?? '';

const done = await login.callback.handle(
new Request(
`${ORIGIN}${mounted(login.callback.path, 'github')}?code=the-code&state=${state}`,
{ headers: { cookie: cookiePair(start.headers.getSetCookie()[0] ?? '') } },
),
);

expect(done.status).toBe(303);
expect(done.headers.get('location')).toBe(AFTER_SIGN_IN);

// The end of the round trip: the cookie the callback set is one the app's own `authenticate`
// resolves to an actor. Without this the flow could "succeed" and still sign nobody in.
const session = new Request(ORIGIN, {
headers: { cookie: done.headers.getSetCookie().map(cookiePair).join('; ') },
});
const token = readSessionCookie(session, auth.sessions.policy);
expect(token).not.toBeNull();

const actor = await authenticate(auth, token);
const user = await adapter.findUserByEmail('ada@postly.test');
expect(actor.id).toBe(user?.id ?? '');
// `link: 'verified-email'` is only safe because the provider's assertion is recorded.
expect(user?.emailVerifiedAt).toEqual(NOW);
expect(await adapter.findAccount('github', '4207')).not.toBeNull();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

test('a provider Postly never enabled never reaches a provider', async () => {
const response = await login.start.handle(startRequest('google'));

expect(response.status).toBe(404);
expect((await bodyOf(response))['code']).toBe('X_OAUTH_PROVIDER_UNKNOWN');
expect(response.headers.get('location')).toBeNull();
});
});
53 changes: 53 additions & 0 deletions examples/dummy/apps/web/app/auth/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* "Log in with GitHub", as Postly declares it: one `defineAuth` and one `oauthLogin`, and that is
* the whole of it. PKCE, the sealed handshake, the state check, the account link and the session
* cookie are the framework's — this file holds only the three decisions an app owns: which
* providers, when two identities are one person, and where a signed-in member lands.
*/

import type { Auth, AuthAdapter, OAuthLoginOptions, OAuthLoginRoutes } from '@ultimat3/auth';
import { BuiltinAdapter, defineAuth, oauthLogin } from '@ultimat3/auth';
import type { Clock } from '@ultimat3/core';

/**
* Where a signed-in member lands. A fixed path and never a `?next=` off the callback: the one
* endpoint whose job is to hand out a session is the classic open redirect.
*/
export const AFTER_SIGN_IN = '/feed';

export interface PostlyAuthSeams {
/**
* Defaults to Postgres, the shape `BuiltinAdapter(client = db())` already uses. Only a test
* passes anything else, so the production wiring is what the declaration says rather than
* something assembled a second time somewhere a test never reaches.
*/
readonly adapter?: AuthAdapter | undefined;
readonly clock?: Clock | undefined;
}

export function postlyAuth(seams: PostlyAuthSeams = {}): Auth {
return defineAuth({
adapter: seams.adapter ?? new BuiltinAdapter(),
// GitHub alone. Every provider listed here is a button someone has to keep working, and a
// provider missing from this list is a 404 rather than a half-configured redirect.
providers: ['github'],
// The default, spelled out because it is Postly's to keep: a provider identity joins an
// existing member only when the provider AND that member both proved the address.
link: 'verified-email',
...(seams.clock === undefined ? {} : { clock: seams.clock }),
});
}

/**
* The two legs of the login as route descriptors — `start.path` is `/auth/oauth/:provider` and
* `callback.path` is `/auth/oauth/:provider/callback`, the one declaration every `X_OAUTH_*` fix
* line quotes. `options` carries the seams the framework already injects (credentials, the token
* endpoint's `fetch`, the handshake secret), so a test drives THIS login and not a copy of it.
*/
export const postlyLogin = (auth: Auth, options: OAuthLoginOptions = {}): OAuthLoginRoutes =>
oauthLogin(auth, { successPath: AFTER_SIGN_IN, ...options });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

/** Postly's own, built once: the app has one boot and therefore one identity resolver. */
export const auth = postlyAuth();

export const { start, callback } = postlyLogin(auth);
1 change: 1 addition & 0 deletions examples/dummy/apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@postly/ui": "0.0.1",
"@ultimat3/action": "1.2.0",
"@ultimat3/ai": "1.2.0",
"@ultimat3/auth": "1.2.0",
"@ultimat3/core": "1.2.0",
"@ultimat3/flags": "1.2.0",
"@ultimat3/jobs": "1.2.0",
Expand Down
12 changes: 11 additions & 1 deletion examples/dummy/imports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,21 @@
* a module that fails either half is a module the toolchain cannot see.
*/

import { describe, expect, test } from 'bun:test';
import { afterAll, describe, expect, test } from 'bun:test';
import { isolateDeclaredTags } from '@ultimat3/cache';
import { Glob } from 'bun';

const APP_ROOT = new URL('.', import.meta.url).pathname.replace(/\/$/, '');

/**
* Importing every module of the app declares the app's cache tags — `packages/db/src/tags.ts` calls
* `declareTags` at module scope — and `entity()` runs once per module, so the declaration cannot
* happen twice. Left standing, this file's side effect decides what every later file in the same
* `bun test` process validates tags against, which is state no reader of those files can see.
*/
const restoreTags = isolateDeclaredTags();
afterAll(restoreTags);

/**
* Test files are excluded: importing one from inside another registers its cases twice. They are
* covered anyway — `bun test` imports every one of them.
Expand Down
1 change: 1 addition & 0 deletions examples/dummy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"manifest": "x manifest --json"
},
"devDependencies": {
"@ultimat3/cache": "1.2.0",
"@ultimat3/entity": "1.2.0",
"@ultimat3/testing": "1.2.0",
"typescript": "5.9.2"
Expand Down
22 changes: 21 additions & 1 deletion framework.manifest.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"test:watch": "bun test --watch --path-ignore-patterns='**/dist/**' --path-ignore-patterns='**/examples/**' --path-ignore-patterns='**/dummy/**'",
"verify": "bun run scripts/verify.ts",
"boundaries": "bun run scripts/boundaries.ts",
"error-render": "bun run scripts/error-render.ts",
"manifest": "bun run scripts/manifest.ts",
"workspaces:list": "bun run scripts/list-workspaces.ts",
"x": "bun run packages/cli/src/bin.ts"
Expand Down
Loading
Loading