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
48 changes: 48 additions & 0 deletions .changeset/session-dual-source-c4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
"@objectstack/spec": major
---

Resolve the `Session` dual source — `./api` keeps the bare names, the `./identity` declaration is removed (#4641)

`Session` and `SessionSchema` were each declared **twice**, once on
`@objectstack/spec/api` and once on `@objectstack/spec/identity`. Which shape a
consumer got depended only on which entry point they imported from — the #4411
trap — and the two did not even agree on field names, so the mistake surfaced as
a runtime `undefined`, not a type error.

**FROM → TO**

| Import | Before | After |
|:--|:--|:--|
| `@objectstack/spec/api` | `Session` / `SessionSchema` | unchanged — this is now the only declaration |
| `@objectstack/spec/identity` | `Session` / `SessionSchema` (a second, different shape) | **removed** |

The surviving `./api` shape is the wire contract:

```ts
{ id: string; expiresAt: string; token?: string; ipAddress?: string; userAgent?: string; userId: string }
```

It is embedded in `SessionResponseSchema`, the body served for
`AuthEndpointPaths.getSession` (`/get-session`, `/me`, `/refresh`).

The removed `./identity` shape was
`{ id, sessionToken, userId, activeOrganizationId?, expires, createdAt, updatedAt, ipAddress?, userAgent?, fingerprint? }`.

**Nothing consumes it.** An import-statement-level scan across framework, `cloud`
and `objectui` found no importer outside its own unit test, and it was wired into
no parent schema. It had also drifted from the record it claimed to describe: the
**enforced** session row is the `sys_session` object in
`@objectstack/platform-objects`, which spells the columns `token` and
`expires_at` (matching `./api`, not `./identity`) and has no `fingerprint` at all.

**If you were importing `Session` from `@objectstack/spec/identity`**, change the
specifier to `@objectstack/spec/api` and rename the fields you read:
`sessionToken` → `token`, `expires` → `expiresAt`. `createdAt` / `updatedAt` /
`activeOrganizationId` / `fingerprint` are not on the wire shape — read the
persisted record through the `sys_session` object, which is what the migration
and the auth plugin actually enforce.

Reference docs follow the declaration: `Session` is now documented on the
`references/api/auth` page (the module that declares it) instead of the
name-collision page `references/api/identity`, which is removed.
20 changes: 18 additions & 2 deletions content/docs/references/api/auth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ and Access Control.
## TypeScript Usage

```typescript
import { AuthProvider, LoginRequestSchema, LoginType, RefreshTokenRequestSchema, RegisterRequestSchema, SessionResponseSchema, SessionUserSchema, UserProfileResponseSchema } from '@objectstack/spec/api';
import type { AuthProvider, LoginRequest, LoginType, RefreshTokenRequest, RegisterRequest, SessionResponse, SessionUser, UserProfileResponse } from '@objectstack/spec/api';
import { AuthProvider, LoginRequestSchema, LoginType, RefreshTokenRequestSchema, RegisterRequestSchema, SessionSchema, SessionResponseSchema, SessionUserSchema, UserProfileResponseSchema } from '@objectstack/spec/api';
import type { AuthProvider, LoginRequest, LoginType, RefreshTokenRequest, RegisterRequest, Session, SessionResponse, SessionUser, UserProfileResponse } from '@objectstack/spec/api';

// Validate data
const result = AuthProvider.parse(data);
Expand Down Expand Up @@ -93,6 +93,22 @@ const result = AuthProvider.parse(data);
| **image** | `string` | optional | |


---

## Session

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **id** | `string` | ✅ | |
| **expiresAt** | `string` | ✅ | |
| **token** | `string` | optional | |
| **ipAddress** | `string` | optional | |
| **userAgent** | `string` | optional | |
| **userId** | `string` | ✅ | |


---

## SessionResponse
Expand Down
35 changes: 0 additions & 35 deletions content/docs/references/api/identity.mdx

This file was deleted.

1 change: 0 additions & 1 deletion content/docs/references/api/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
"core-services",
"events",
"export",
"identity",
"metadata",
"package-api",
"package-registry",
Expand Down
24 changes: 2 additions & 22 deletions content/docs/references/identity/identity.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ defines "how to login".
## TypeScript Usage

```typescript
import { AccountSchema, ApiKeySchema, SessionSchema, UserSchema, VerificationTokenSchema } from '@objectstack/spec/identity';
import type { Account, ApiKey, Session, User, VerificationToken } from '@objectstack/spec/identity';
import { AccountSchema, ApiKeySchema, UserSchema, VerificationTokenSchema } from '@objectstack/spec/identity';
import type { Account, ApiKey, User, VerificationToken } from '@objectstack/spec/identity';

// Validate data
const result = AccountSchema.parse(data);
Expand Down Expand Up @@ -82,26 +82,6 @@ const result = AccountSchema.parse(data);
| **metadata** | `Record<string, any>` | optional | Custom metadata |


---

## Session

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **id** | `string` | ✅ | Unique session identifier |
| **sessionToken** | `string` | ✅ | Session token |
| **userId** | `string` | ✅ | Associated user ID |
| **activeOrganizationId** | `string` | optional | Active organization ID for context switching |
| **expires** | `string` | ✅ | Session expiry timestamp |
| **createdAt** | `string` | ✅ | Session creation timestamp |
| **updatedAt** | `string` | ✅ | Last update timestamp |
| **ipAddress** | `string` | optional | IP address |
| **userAgent** | `string` | optional | User agent string |
| **fingerprint** | `string` | optional | Device fingerprint |


---

## User
Expand Down
2 changes: 1 addition & 1 deletion docs/audits/2026-07-unknown-key-strictness-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,7 @@ it rather than rediscover it.
| `cloud/` | 83 | wire | multi-tenant runtime |
| `ai/` | 75 | mixed | agent/tool/skill definitions authored (partially strict already); model/provider payloads wire |
| `integration/` | 64 | wire | connector payloads — upstream adds fields freely |
| `identity/` | 34 | mixed | position/user shapes authored (`PositionSchema` **strict as of #4001 step 2**, with the ADR-0010 envelope declared); auth payloads wire |
| `identity/` | 33 | mixed | position/user shapes authored (`PositionSchema` **strict as of #4001 step 2**, with the ADR-0010 envelope declared); auth payloads wire. **34 → 33 in #4641**: `identity.zod.ts` lost its `SessionSchema` site — a second, importerless declaration of a name `api/auth.zod.ts` already owned (the #4411 dual-source trap), deleted rather than reclassified |
| `shared/` | 25 | n/a | utilities and building blocks; strictness decided at the consuming schema |
| `qa/` | 6 | n/a | test fixtures |

Expand Down
2 changes: 0 additions & 2 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -4412,8 +4412,6 @@
"SCIMUser (type)",
"SCIMUserSchema (const)",
"SCIM_SCHEMAS (const)",
"Session (type)",
"SessionSchema (const)",
"TokenPayload (interface)",
"User (type)",
"UserSchema (const)",
Expand Down
10 changes: 0 additions & 10 deletions packages/spec/authorable-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -4095,16 +4095,6 @@
"identity/SCIMUser:userName",
"identity/SCIMUser:userType",
"identity/SCIMUser:x509Certificates",
"identity/Session:activeOrganizationId",
"identity/Session:createdAt",
"identity/Session:expires",
"identity/Session:fingerprint",
"identity/Session:id",
"identity/Session:ipAddress",
"identity/Session:sessionToken",
"identity/Session:updatedAt",
"identity/Session:userAgent",
"identity/Session:userId",
"identity/User:createdAt",
"identity/User:email",
"identity/User:emailVerified",
Expand Down
2 changes: 0 additions & 2 deletions packages/spec/dual-source-exports.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@
"RateLimitConfigSchema — [./integration (const)] ≠ [./shared (const)]",
"RetryPolicy — [./automation (type)] ≠ [./system (type)]",
"RetryPolicySchema — [./automation (const)] ≠ [./system (const)]",
"Session — [./api (type)] ≠ [./identity (type)]",
"SessionSchema — [./api (const)] ≠ [./identity (const)]",
"TenantPlan — [./cloud (type)] ≠ [./system (type)]",
"TenantPlanSchema — [./cloud (const)] ≠ [./system (const)]"
]
Expand Down
1 change: 0 additions & 1 deletion packages/spec/json-schema.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -864,7 +864,6 @@
"identity/SCIMPatchRequest",
"identity/SCIMPhoneNumber",
"identity/SCIMUser",
"identity/Session",
"identity/User",
"identity/VerificationToken",
"integration/CircuitBreakerConfig",
Expand Down
120 changes: 32 additions & 88 deletions packages/spec/src/identity/identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,10 @@ import { describe, it, expect } from 'vitest';
import {
UserSchema,
AccountSchema,
SessionSchema,
VerificationTokenSchema,
ApiKeySchema,
type User,
type Account,
type Session,
type VerificationToken,
type ApiKey,
} from "./identity.zod";
Expand Down Expand Up @@ -140,77 +138,38 @@ describe('AccountSchema', () => {
});
});

describe('SessionSchema', () => {
it('should accept valid session data', () => {
const session: Session = {
id: 'session_123',
sessionToken: 'session_token_xyz',
userId: 'user_123',
expires: new Date(Date.now() + 86400000).toISOString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
ipAddress: '192.168.1.1',
userAgent: 'Mozilla/5.0',
fingerprint: 'fingerprint_xyz',
};

expect(() => SessionSchema.parse(session)).not.toThrow();
});

it('should accept minimal session data', () => {
const session = {
id: 'session_123',
sessionToken: 'session_token_xyz',
userId: 'user_123',
expires: new Date().toISOString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};

expect(() => SessionSchema.parse(session)).not.toThrow();
});

it('should accept session with device information', () => {
const session = {
id: 'session_123',
sessionToken: 'session_token_xyz',
userId: 'user_123',
expires: new Date().toISOString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
ipAddress: '10.0.0.1',
userAgent: 'Chrome/120.0.0.0',
fingerprint: 'device_fingerprint',
};

expect(() => SessionSchema.parse(session)).not.toThrow();
});

it('should accept session with activeOrganizationId', () => {
const session = {
id: 'session_123',
sessionToken: 'session_token_xyz',
userId: 'user_123',
activeOrganizationId: 'org_123',
expires: new Date().toISOString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};

expect(() => SessionSchema.parse(session)).not.toThrow();
});

it('should accept session without activeOrganizationId', () => {
const session = {
id: 'session_123',
sessionToken: 'session_token_xyz',
userId: 'user_123',
expires: new Date().toISOString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};

expect(() => SessionSchema.parse(session)).not.toThrow();
describe('Session is not declared here (#4641)', () => {
// Pin: this module no longer declares the bare `SessionSchema` name. The pin is
// compile-time (`typeof import` is type-level only — no runtime barrel load):
// if the name is re-added here, the conditional type flips to `true` and the
// `false` assignment fails `tsc --noEmit`.
//
// The bare names belong to `@objectstack/spec/api` alone — that declaration is
// the live one, embedded in `SessionResponseSchema` (the `/get-session` body).
// A second declaration on this entry meant the shape a consumer got depended
// only on their import path (#4411), and the two disagreed on field names
// (`expires` vs `expiresAt`, `sessionToken` vs `token`).
//
// Why this asserts at RUNTIME rather than with the `typeof import(...)`
// conditional-type pin used by #4581 / #4610: that pin cannot fail here.
// `packages/spec/tsconfig.json` excludes `**/*.test.ts`, so `pnpm typecheck`
// never compiles this file, and vitest transpiles without typechecking — a
// conditional-type assertion in a spec test is inert. (Filed separately; it
// silently weakens the pins those two PRs landed.) The module-namespace check
// below is cheap — this file already imports the module — and it actually runs.
//
// Scope, deliberately: this covers the VALUE export. A type-only
// `export type Session` has no runtime footprint, so no unit test can see it.
// Two things cover the type instead, and neither is vacuous:
// 1. house style derives it (`type Session = z.infer< typeof SessionSchema >`),
// so it cannot come back without the const this test already catches; and
// 2. `check:dual-source-exports` reads the BUILT entry `.d.ts` and enumerates
// types as well as consts — a re-added `Session` type on `./identity`
// fails it as a new dual-source name, which is how the baseline row that
// this PR deleted was phrased in the first place.
it('does not re-expose the bare SessionSchema name from ./identity', async () => {
const identityModule = await import('./identity.zod');
expect(Object.keys(identityModule)).not.toContain('SessionSchema');
});
});

Expand Down Expand Up @@ -352,21 +311,6 @@ describe('Type inference', () => {
expect(account.provider).toBe('google');
});

it('should correctly infer Session type', () => {
const session: Session = {
id: 'session_123',
sessionToken: 'token_xyz',
userId: 'user_123',
expires: new Date().toISOString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};

// This test passes if TypeScript compiles without errors
expect(session.id).toBe('session_123');
expect(session.userId).toBe('user_123');
});

it('should correctly infer VerificationToken type', () => {
const token: VerificationToken = {
identifier: 'test@example.com',
Expand Down
Loading
Loading