diff --git a/.changeset/session-dual-source-c4.md b/.changeset/session-dual-source-c4.md new file mode 100644 index 0000000000..edd97f0647 --- /dev/null +++ b/.changeset/session-dual-source-c4.md @@ -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. diff --git a/content/docs/references/api/auth.mdx b/content/docs/references/api/auth.mdx index eb52eb6dd4..128888f531 100644 --- a/content/docs/references/api/auth.mdx +++ b/content/docs/references/api/auth.mdx @@ -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); @@ -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 diff --git a/content/docs/references/api/identity.mdx b/content/docs/references/api/identity.mdx deleted file mode 100644 index 6a5084f6c2..0000000000 --- a/content/docs/references/api/identity.mdx +++ /dev/null @@ -1,35 +0,0 @@ ---- -title: Identity -description: Identity protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -## TypeScript Usage - -```typescript -import { SessionSchema } from '@objectstack/spec/api'; -import type { Session } from '@objectstack/spec/api'; - -// Validate data -const result = SessionSchema.parse(data); -``` - ---- - -## Session - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | | -| **expiresAt** | `string` | ✅ | | -| **token** | `string` | optional | | -| **ipAddress** | `string` | optional | | -| **userAgent** | `string` | optional | | -| **userId** | `string` | ✅ | | - - ---- - diff --git a/content/docs/references/api/meta.json b/content/docs/references/api/meta.json index ae048e48a9..fc9ef0dc22 100644 --- a/content/docs/references/api/meta.json +++ b/content/docs/references/api/meta.json @@ -30,7 +30,6 @@ "core-services", "events", "export", - "identity", "metadata", "package-api", "package-registry", diff --git a/content/docs/references/identity/identity.mdx b/content/docs/references/identity/identity.mdx index f6c95c937f..989338fe24 100644 --- a/content/docs/references/identity/identity.mdx +++ b/content/docs/references/identity/identity.mdx @@ -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); @@ -82,26 +82,6 @@ const result = AccountSchema.parse(data); | **metadata** | `Record` | 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 diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 38f7c36ddd..0404cf4cc4 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -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 | diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 58ffbfdbb9..c92efda61b 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -4412,8 +4412,6 @@ "SCIMUser (type)", "SCIMUserSchema (const)", "SCIM_SCHEMAS (const)", - "Session (type)", - "SessionSchema (const)", "TokenPayload (interface)", "User (type)", "UserSchema (const)", diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 3013edc047..c9857043d7 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -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", diff --git a/packages/spec/dual-source-exports.baseline.json b/packages/spec/dual-source-exports.baseline.json index fa8f96ea06..06bf9369b3 100644 --- a/packages/spec/dual-source-exports.baseline.json +++ b/packages/spec/dual-source-exports.baseline.json @@ -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)]" ] diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 239958113e..9163533b16 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -864,7 +864,6 @@ "identity/SCIMPatchRequest", "identity/SCIMPhoneNumber", "identity/SCIMUser", - "identity/Session", "identity/User", "identity/VerificationToken", "integration/CircuitBreakerConfig", diff --git a/packages/spec/src/identity/identity.test.ts b/packages/spec/src/identity/identity.test.ts index 8873f7d445..7e47068bf4 100644 --- a/packages/spec/src/identity/identity.test.ts +++ b/packages/spec/src/identity/identity.test.ts @@ -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"; @@ -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'); }); }); @@ -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', diff --git a/packages/spec/src/identity/identity.zod.ts b/packages/spec/src/identity/identity.zod.ts index f47f27b5f8..87b22d9593 100644 --- a/packages/spec/src/identity/identity.zod.ts +++ b/packages/spec/src/identity/identity.zod.ts @@ -141,64 +141,28 @@ export const AccountSchema = lazySchema(() => z.object({ export type Account = z.infer; -/** - * Session Schema - * User session data model +/* + * The bare `Session` / `SessionSchema` names are NOT declared here (#4641). + * + * This module used to carry a second `Session` declaration alongside the one in + * `api/auth.zod.ts`, so which shape a consumer got depended only on whether they + * imported from `@objectstack/spec/identity` or `@objectstack/spec/api` — the + * #4411 trap, and the two shapes did not even agree on field names (`expires` vs + * `expiresAt`, `sessionToken` vs `token`). + * + * The `./api` declaration is the live one: it is embedded in + * `SessionResponseSchema`, the body served for `AuthEndpointPaths.getSession` + * (`/get-session`, `/me`, `/refresh`). The declaration that stood here had no + * importer anywhere in framework, cloud or objectui outside its own unit test, + * was wired into no parent schema, and had drifted from the table it claimed to + * describe: the ENFORCED session record is the `sys_session` ObjectSchema in + * `@objectstack/platform-objects` (`identity/sys-session.object.ts`), which + * spells the columns `token` / `expires_at` and has no `fingerprint` at all. + * + * Need the wire shape? `import type { Session } from '@objectstack/spec/api'`. + * Need the persisted record? Read the `sys_session` object — it is what the + * migration and the auth plugin actually enforce. */ -export const SessionSchema = lazySchema(() => z.object({ - /** - * Unique session identifier - */ - id: z.string().describe('Unique session identifier'), - - /** - * Session token - */ - sessionToken: z.string().describe('Session token'), - - /** - * Associated user ID - */ - userId: z.string().describe('Associated user ID'), - - /** - * Active organization ID for this session - * Used for context switching in multi-tenant applications - */ - activeOrganizationId: z.string().optional().describe('Active organization ID for context switching'), - - /** - * Session expiry timestamp - */ - expires: z.string().datetime().describe('Session expiry timestamp'), - - /** - * Session creation timestamp - */ - createdAt: z.string().datetime().describe('Session creation timestamp'), - - /** - * Last update timestamp - */ - updatedAt: z.string().datetime().describe('Last update timestamp'), - - /** - * IP address of the session - */ - ipAddress: z.string().optional().describe('IP address'), - - /** - * User agent string - */ - userAgent: z.string().optional().describe('User agent string'), - - /** - * Device fingerprint - */ - fingerprint: z.string().optional().describe('Device fingerprint'), -})); - -export type Session = z.infer; /** * Verification Token Schema