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
17 changes: 17 additions & 0 deletions .changeset/24222-get-session-readers-declared.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@objectstack/spec": minor
"@objectstack/plugin-auth": patch
"@objectstack/rest": patch
"@objectstack/runtime": patch
"@objectstack/cloud-connection": patch
"@objectstack/plugin-webhooks": patch
"@objectstack/service-storage": patch
---

feat(spec): the remaining `getSession` readers read `AuthSessionApi`'s declared result, and none reads a top-level `activeOrganizationId`

Clause-②: yes (widening)

- **`@objectstack/spec/contracts` — `AuthSessionApi.getSession`'s result gains three optional `user` members**, each declared for a named reader: `isPlatformAdmin` (`boolean`, the ADR-0068 D2 rung plugin-auth's `customSession` derives) and `role` (`string | null`, better-auth's `admin` plugin scalar), both read by `judgePlatformAdmin` in `@objectstack/plugin-auth`; and `name` (`string`), read by the runtime dispatcher's AI-route `req.user` resolver as the display name. Every member is optional, so every implementer and caller typechecks unchanged.
- **The readers read through the declared type instead of `any` or a cast** (types only, runtime behaviour unchanged): `judgePlatformAdmin` and the four platform-admin mounts in `@objectstack/plugin-auth` (better-auth's own session type now has to fit the declaration at those reads), the REST server's two session reads (`@objectstack/rest`), the dispatcher's posture read, `resolveSessionData` and both `HttpDispatcher` reads (`@objectstack/runtime`), the cloud-connection and install-local session reads (`@objectstack/cloud-connection`), the webhook redeliver door (`@objectstack/plugin-webhooks`), and the storage session bridge (`@objectstack/service-storage`).
- **Fix — the top-level `activeOrganizationId` fallback is removed** from the storage upload stamp (`@objectstack/service-storage`) and from install-local's `resolveActiveOrgId` (`@objectstack/cloud-connection`). The active organization is read from `session.activeOrganizationId` alone, where the contract declares it. No in-process producer answers a top-level `activeOrganizationId` (better-auth's read is `{ user, session }`), so for the shipped auth plugin nothing changes. An auth service that answered only the flattened shape now gets no organization: the upload is not stamped, and install-local reports no active organization. To fix it, answer the active organization on `session.activeOrganizationId`, as `AuthSessionApi` declares it.
12 changes: 8 additions & 4 deletions packages/cloud-connection/src/cloud-connection-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ import {
classifyAdmissionTenancyPosture,
type TenancyPostureSource,
} from '@objectstack/core';
import type { IAuthService, IObjectQLEngine } from '@objectstack/spec/contracts';
import type { AuthSessionApi, IAuthService, IObjectQLEngine } from '@objectstack/spec/contracts';
import { inProcessSessionReadInput } from '@objectstack/types';
import { ConnectionCredentialStore } from './connection-credential-store.js';
import { CLOUD_CONNECTION_UI_BUNDLE } from './cloud-connection-ui.js';
Expand Down Expand Up @@ -167,6 +167,9 @@ interface EnvironmentKernel {
getService?<T = unknown>(name: string): T;
}

/** What the auth service's in-process session read answers, as `AuthSessionApi` declares it. */
type SessionRead = Awaited<ReturnType<NonNullable<AuthSessionApi['getSession']>>>;

/**
* A signed-in caller of this surface, and the ADR-0069 posture its session
* carries (`authGate`, present only for a session the policy holds).
Expand All @@ -182,7 +185,7 @@ interface ResolvedSession {
* kernel, whose grants are the ones that apply.
*/
readFrom: {
sessionData: unknown;
sessionData: SessionRead | null;
authService: IAuthService | undefined;
kernel?: EnvironmentKernel;
};
Expand Down Expand Up @@ -313,8 +316,9 @@ export class CloudConnectionPlugin implements Plugin {
const api = typeof authSvc?.getApi === 'function' ? await authSvc.getApi() : authSvc?.api ?? authSvc;
// [#22258] The in-process session-read rule: a request carrying a
// session cookie reads without renewal, because this route's
// response never carries a renewed cookie.
const session = await api?.getSession?.(inProcessSessionReadInput(rawReq.headers));
// response never carries a renewed cookie. [#24222] Typed as
// `AuthSessionApi` declares the answer (`api` itself is `any`).
const session: SessionRead | null = await api?.getSession?.(inProcessSessionReadInput(rawReq.headers));
const userId = session?.user?.id ? String(session.user.id) : undefined;
if (!userId) return null;
// ADR-0069: the ONE consult of the authentication-policy gate for
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,11 @@ afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMo

/**
* Boot the real plugin. `posture` is the `tenancy` service's posture in force;
* `activeOrg` what the session's `activeOrganizationId` says (absent: none).
* `activeOrg` what the session's `activeOrganizationId` says (absent: none);
* `topLevelOrg` an `activeOrganizationId` beside `user` and `session` instead,
* a shape `AuthSessionApi` does not declare.
*/
async function boot(opts: { posture: 'single' | 'isolated'; activeOrg?: string }) {
async function boot(opts: { posture: 'single' | 'isolated'; activeOrg?: string; topLevelOrg?: string }) {
const routes = new Map<string, Handler>();
const rawApp = {
get: (p: string, h: Handler) => routes.set(`GET ${p}`, h),
Expand Down Expand Up @@ -135,6 +137,7 @@ async function boot(opts: { posture: 'single' | 'isolated'; activeOrg?: string }
getSession: async () => ({
user: { id: INSTALLER_USER_ID },
session: opts.activeOrg ? { activeOrganizationId: opts.activeOrg } : {},
...(opts.topLevelOrg ? { activeOrganizationId: opts.topLevelOrg } : {}),
}),
},
},
Expand Down Expand Up @@ -212,6 +215,22 @@ describe('walled, no active organization — refused at every sample-data door,
});
});

describe('walled, an `activeOrganizationId` only at the top level — not read', () => {
it('reseed: 403 PERMISSION_DENIED as for no active organization, and no seed run', async () => {
// [#24222] `resolveActiveOrgId` reads `session.activeOrganizationId`, where
// `AuthSessionApi` declares it. The top-level operand it dropped tolerated
// a shape no in-process producer answers (better-auth: `{ user, session }`).
const m = await boot({ posture: 'isolated', topLevelOrg: 'org_b' });
await m.install();
const res = await m.reseed();

expect(res.status).toBe(403);
expect(res.payload.error.code).toBe('PERMISSION_DENIED');
expect(res.payload.error.message).toContain('this session has no active organization');
expect(loadCalls).toHaveLength(0);
});
});

describe('walled, an active organization — all three doors act in it, as before', () => {
it('install seeds inline into the active organization', async () => {
const m = await boot({ posture: 'isolated', activeOrg: 'org_b' });
Expand Down
15 changes: 11 additions & 4 deletions packages/cloud-connection/src/marketplace-install-local-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ import { ConnectionCredentialStore } from './connection-credential-store.js';
import { readJsonRequestBody } from './request-json-body.js';
import { MARKETPLACE_INSTALLED_UI_BUNDLE } from './marketplace-ui.js';
import { matchSeedRows, purgeSeedRows, type PurgeSeedDataset } from './marketplace-install-local-purge.js';
import type { IAuthService, IHttpServer, IMetadataService, IObjectQLEngine } from '@objectstack/spec/contracts';
import type { AuthSessionApi, IAuthService, IHttpServer, IMetadataService, IObjectQLEngine } from '@objectstack/spec/contracts';
import type {
DeletePackageRequest,
UninstallCleanupOutcome,
Expand All @@ -202,6 +202,9 @@ import type {
import { unmanagedInstallPostureRefusal, isWritablePackage, clientFacingFailureText, captureUninstallDeclared } from '@objectstack/metadata-protocol';
import { resolveThrownHttpError } from '@objectstack/types';

/** What the auth service's in-process session read answers, as `AuthSessionApi` declares it. */
type SessionRead = Awaited<ReturnType<NonNullable<AuthSessionApi['getSession']>>>;

const ROUTE_BASE = '/api/v1/marketplace/install-local';

/**
Expand Down Expand Up @@ -3360,8 +3363,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
if (!api?.getSession) return null;
// [#22258] A request carrying a session cookie reads without
// renewal: this route's response never carries a renewed cookie.
const session = await api.getSession(inProcessSessionReadInput(c.req.raw.headers));
const direct = session?.session?.activeOrganizationId ?? session?.activeOrganizationId ?? null;
// [#24222] Typed as `AuthSessionApi` declares the answer, and read
// where it declares the active organization. No top-level
// `activeOrganizationId` operand: no in-process producer answers one.
const session: SessionRead | null = await api.getSession(inProcessSessionReadInput(c.req.raw.headers));
const direct = session?.session?.activeOrganizationId ?? null;
if (direct) return String(direct);
} catch { /* ignore */ }
return null;
Expand Down Expand Up @@ -3521,7 +3527,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
// The better-auth session bridge — resolved lazily and defensively,
// exactly as the previous implementation did, then handed to the
// shared resolver instead of being trusted on its own.
const getSession = async (h: any) => {
// [#24222] Answers as `AuthSessionApi` declares the read, not `any`.
const getSession = async (h: any): Promise<SessionRead | null> => {
try {
const authService: any = ctx.getService('auth');
let api: any = authService?.api;
Expand Down
23 changes: 17 additions & 6 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { SysOrganizationDetailPage, SysUserDetailPage } from '@objectstack/platf
import { PLATFORM_OWNER_EMAIL_ENV, resolvePlatformOwnerEmail, resolveTenancyPosture } from '@objectstack/types';
import { inProcessSessionReadInput } from '@objectstack/types';
import { postureEnforcesWall, type OrgScopingEntitlement } from '@objectstack/spec/security';
import type { IDataEngine, IEmailService, II18nService, IObjectQLEngine, ISmsService } from '@objectstack/spec/contracts';
import type { AuthSessionApi, IDataEngine, IEmailService, II18nService, IObjectQLEngine, ISmsService } from '@objectstack/spec/contracts';
import {
AuthManager,
resolveOidcProviderEnabled,
Expand Down Expand Up @@ -147,6 +147,13 @@ interface SettingsReadSurface {
/** [#23367] The auth setting that records when the API-key lifecycle policy took effect. */
const API_KEY_POLICY_STARTED_AT_KEY = 'api_key_policy_started_at';

/**
* [#24222] What the in-process session read answers, as `AuthSessionApi` declares
* it. better-auth's own handle answers `null` for no session, hence the
* `| null` at each read.
*/
type SessionRead = Awaited<ReturnType<NonNullable<AuthSessionApi['getSession']>>>;

/**
* The `protocol` slot's metadata reader, as the user-import route uses it.
*
Expand Down Expand Up @@ -2773,7 +2780,8 @@ export class AuthPlugin implements Plugin {
// Platform-admin gate (ADR-0068 D2) — one shared judge for every
// ObjectStack `/admin/*` mount; see platform-admin-gate.ts.
const authApi = await this.authManager!.getApi();
const session = await authApi.getSession(inProcessSessionReadInput(c.req.raw.headers));
// [#24222] Read as `AuthSessionApi` declares the answer (see `SessionRead`).
const session: SessionRead | null = await authApi.getSession(inProcessSessionReadInput(c.req.raw.headers));
const verdict = judgePlatformAdmin(session);
if (!verdict.ok) return c.json(verdict.refusal.body, verdict.refusal.status);

Expand Down Expand Up @@ -2836,7 +2844,8 @@ export class AuthPlugin implements Plugin {
// spelling instead of accreting per-mount copies.
const gateAdmin = async (c: any): Promise<PlatformAdminActor | Response> => {
const authApi = await this.authManager!.getApi();
const session = await (authApi as any).getSession(inProcessSessionReadInput(c.req.raw.headers));
// [#24222] Read as `AuthSessionApi` declares the answer (see `SessionRead`), not through `any`.
const session: SessionRead | null = await authApi.getSession(inProcessSessionReadInput(c.req.raw.headers));
const verdict = judgePlatformAdmin(session);
if (!verdict.ok) return c.json(verdict.refusal.body, verdict.refusal.status);
return verdict.actor;
Expand Down Expand Up @@ -2908,7 +2917,8 @@ export class AuthPlugin implements Plugin {

// Platform-admin gate (ADR-0068 D2) — see platform-admin-gate.ts.
const authApi = await this.authManager!.getApi();
const session = await authApi.getSession(inProcessSessionReadInput(c.req.raw.headers));
// [#24222] Read as `AuthSessionApi` declares the answer (see `SessionRead`).
const session: SessionRead | null = await authApi.getSession(inProcessSessionReadInput(c.req.raw.headers));
const verdict = judgePlatformAdmin(session);
if (!verdict.ok) return c.json(verdict.refusal.body, verdict.refusal.status);

Expand Down Expand Up @@ -3226,8 +3236,9 @@ export class AuthPlugin implements Plugin {
rawApp.post(`${basePath}/admin/has-permission`, async (c: any) => {
try {
const authApi = await this.authManager!.getApi();
const session = await (authApi as any).getSession(inProcessSessionReadInput(c.req.raw.headers));
const user = (session as { user?: { id?: unknown } } | null | undefined)?.user;
// [#24222] Read as `AuthSessionApi` declares the answer (see `SessionRead`), not through `any`.
const session: SessionRead | null = await authApi.getSession(inProcessSessionReadInput(c.req.raw.headers));
const user = session?.user;
if (user?.id && isPlatformAdminUser(user)) {
const { readEvaluatedPermissionQuery, answerPermissionQueryAsAdmin } = await import(
'./admin-has-permission-endpoint.js'
Expand Down
13 changes: 10 additions & 3 deletions packages/plugins/plugin-auth/src/platform-admin-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@
*/

import { evaluateAuthGate } from '@objectstack/core';
import type { AuthSessionApi } from '@objectstack/spec/contracts';

/** What the auth service's in-process session read answers, as `AuthSessionApi` declares it. */
type SessionRead = Awaited<ReturnType<NonNullable<AuthSessionApi['getSession']>>>;

/** The caller a passing gate hands back to the route. */
export interface PlatformAdminActor {
Expand Down Expand Up @@ -163,9 +167,12 @@ export function judgeHeldSession(sessionUser: unknown): PlatformAdminRefusal | n
* the posture refusal rather than `PERMISSION_DENIED`, the same 403 status.
*/
export function judgePlatformAdmin(session: unknown): PlatformAdminVerdict {
const user = (session as { user?: unknown } | null | undefined)?.user as
| Record<string, unknown>
| undefined;
// [#24222] Read as `AuthSessionApi.getSession` declares its answer, so every
// user field this judge reads (`id`, `email`, `authGate`, `isPlatformAdmin`,
// `role`) is one the contract names for it. The parameter stays `unknown`:
// it is a published signature, and narrowing it would break a caller that
// hands over what it holds.
const user = (session as SessionRead | null)?.user;

if (!user?.id) {
return {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The platform-admin judge reads `user.isPlatformAdmin` and `user.role`, which
* `AuthSessionApi.getSession` declares for it (`@objectstack/spec/contracts`).
* A field better-auth or `customSession` stops sending reads `undefined`; this
* pins the direction that `undefined` is judged in: it REFUSES, never admits.
*
* Every user below carries an `id`, so the identity refusal (401) is not the
* answer that hides the authorization one (403).
*/

import { describe, it, expect } from 'vitest';
import { isPlatformAdminUser, judgePlatformAdmin } from './platform-admin-gate.js';

const ID = { id: 'usr_signed_in', email: 'signed-in@example.com' };

const NOT_ADMIN: ReadonlyArray<[string, Record<string, unknown>]> = [
['both fields absent', { ...ID }],
['both fields `undefined`', { ...ID, isPlatformAdmin: undefined, role: undefined }],
['`isPlatformAdmin` absent, `role` null', { ...ID, role: null }],
['`isPlatformAdmin` false, `role` absent', { ...ID, isPlatformAdmin: false }],
// Truthy is not `true`, and a near-miss spelling is not `'admin'`.
['`isPlatformAdmin` the string `"true"`', { ...ID, isPlatformAdmin: 'true' }],
['`role` `"Admin"`', { ...ID, role: 'Admin' }],
];

describe('isPlatformAdminUser: an absent or `undefined` signal is not a platform admin', () => {
for (const [label, user] of NOT_ADMIN) {
it(`${label} → false`, () => {
expect(isPlatformAdminUser(user)).toBe(false);
});
}

it('the two admitting signals still admit (the control)', () => {
expect(isPlatformAdminUser({ ...ID, isPlatformAdmin: true })).toBe(true);
expect(isPlatformAdminUser({ ...ID, role: 'admin' })).toBe(true);
});
});

describe('judgePlatformAdmin: a signed-in session whose signals read `undefined` is refused 403', () => {
for (const [label, user] of NOT_ADMIN) {
it(`${label} → 403 PERMISSION_DENIED`, () => {
const verdict = judgePlatformAdmin({ user, session: { userId: ID.id } });
expect(verdict.ok).toBe(false);
if (verdict.ok) return;
expect(verdict.refusal.status).toBe(403);
expect(verdict.refusal.body.error.code).toBe('PERMISSION_DENIED');
});
}

it('a session with `isPlatformAdmin: true` is admitted (the control)', () => {
const verdict = judgePlatformAdmin({ user: { ...ID, isPlatformAdmin: true }, session: { userId: ID.id } });
expect(verdict).toEqual({ ok: true, actor: { id: ID.id, email: ID.email } });
});
});
10 changes: 9 additions & 1 deletion packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Plugin, PluginContext } from '@objectstack/core';
// redeliver door on the session it already reads (see `serveRedeliver`).
import { evaluateAuthGate } from '@objectstack/core';
import type {
AuthSessionApi,
IDataEngine,
II18nService,
IMetadataService,
Expand All @@ -14,6 +15,9 @@ import type {
import type { EnqueueHttpInput } from '@objectstack/service-messaging';
import { inProcessSessionReadInput } from '@objectstack/types';
import { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js';

/** What the auth service's in-process session read answers, as `AuthSessionApi` declares it. */
type SessionRead = Awaited<ReturnType<NonNullable<AuthSessionApi['getSession']>>>;
import { SysWebhook } from './sys-webhook.object.js';
import { bootstrapDeclaredWebhooks } from './bootstrap-declared-webhooks.js';
import { migrateLegacyWebhookSecrets } from './migrate-webhook-secrets.js';
Expand Down Expand Up @@ -614,8 +618,12 @@ export class WebhookOutboxPlugin implements Plugin {
* (`session.activeOrganizationId`, the tenant threaded into the write).
* Resolving them separately would mean two `getSession` calls that can
* disagree.
*
* [#24222] Typed as `AuthSessionApi` declares the answer, not `any`: the
* route's three reads (`user.id`, `user` for the posture gate,
* `session.activeOrganizationId`) are all declared there.
*/
private async resolveSession(ctx: PluginContext, c: any): Promise<any | undefined> {
private async resolveSession(ctx: PluginContext, c: any): Promise<SessionRead | null> {
try {
const authService: any = this.tryGetService<any>(ctx, ['auth']);
if (!authService) return undefined;
Expand Down
Loading
Loading