diff --git a/.changeset/curly-badgers-column.md b/.changeset/curly-badgers-column.md deleted file mode 100644 index b9486f8c..00000000 --- a/.changeset/curly-badgers-column.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@opensaas/stack-ui': minor ---- - -Replace the admin UI's hardcoded `password`/`createdAt`/`updatedAt` default-column exclusion with curation driven by each field's declared `ui.listView.defaultColumn` (issue #1018). The list view, related-list tables, and the `ListTable` standalone component now share one implementation (`computeDefaultColumns`) instead of three independent name/type-matching copies, and a list's structural `createdAt`/`updatedAt` timestamp columns are identified from its own timestamp configuration rather than by name. - -**Behavior change:** an application field literally named (or typed) `password`, `createdAt`, or `updatedAt` that does NOT declare `ui.listView.defaultColumn: false` — and isn't your list's actual auto-timestamp column — is no longer hidden from default admin columns purely by name/type match. Real password fields (built with `password()`) and real system timestamps are unaffected; they're excluded via the declared flag instead. - -`ListTable` gains an optional `fields?: Record` prop to supply this curation metadata; without it (as before), every `fieldTypes` column shows absent an explicit `columns` list. diff --git a/.changeset/curly-badgers-credential.md b/.changeset/curly-badgers-credential.md deleted file mode 100644 index a8b2d38e..00000000 --- a/.changeset/curly-badgers-credential.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@opensaas/stack-auth': patch ---- - -Read-denied credential fields (ADR-0036) now also declare `ui.listView.defaultColumn: false`, so they're curated out of the admin's default table columns instead of rendering as permanently empty columns. diff --git a/.changeset/curly-badgers-curate.md b/.changeset/curly-badgers-curate.md deleted file mode 100644 index 73998980..00000000 --- a/.changeset/curly-badgers-curate.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@opensaas/stack-core': minor ---- - -Add `ui.listView.defaultColumn` to field config — a declared, presentation-only flag (default `true`) controlling whether a field belongs in a list/related-list table's default column set. Naming a field explicitly in `ui.listView.initialColumns` or a relationship's `ui.itemView.columns` always shows it regardless of this flag. - -```typescript -fields: { - internalScore: integer({ ui: { listView: { defaultColumn: false } } }), -} -``` - -`password()` now sets this flag to `false` by default instead of the admin UI matching on field type — a password field can opt back into default columns with `ui: { listView: { defaultColumn: true } } }`. diff --git a/.changeset/eleven-mice-jog.md b/.changeset/eleven-mice-jog.md deleted file mode 100644 index 5d4f30b6..00000000 --- a/.changeset/eleven-mice-jog.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -'@opensaas/stack-auth': minor ---- - -Bump the `better-auth` dev dependency to `1.7.1` and move the peer range off the stale `^1.3.29` floor to `^1.4.0` (the release line where better-auth's `index: true` flags — the basis for #937's index emission — first shipped; below it the generated schema silently omitted indexes better-auth itself declares). - -**New required `account.issuer` column.** better-auth 1.7 adds a required `issuer` column to its `account` model. Because `deriveAuthLists` derives the Auth lists from better-auth's own `getAuthTables()` (#987/#997), this column now appears in the generated schema automatically — no code change was needed, only verification against real generated output. Existing projects upgrading to `better-auth@^1.7` will see a **new NOT NULL column** on their `account` table and need a backfill for existing rows. Following better-auth's own `createLocalAccountIssuer`/`createOAuthAccountIssuer` helpers (`@better-auth/core/db`): - -```sql --- PostgreSQL / SQLite (|| is string concatenation on both) -UPDATE "Account" SET issuer = 'local:' || "providerId" WHERE issuer IS NULL AND "providerId" = 'credential'; -UPDATE "Account" SET issuer = 'local:oauth:' || "providerId" WHERE issuer IS NULL AND "providerId" != 'credential'; - --- MySQL (|| is logical OR by default, NOT concatenation — use CONCAT instead) -UPDATE `Account` SET issuer = CONCAT('local:', providerId) WHERE issuer IS NULL AND providerId = 'credential'; -UPDATE `Account` SET issuer = CONCAT('local:oauth:', providerId) WHERE issuer IS NULL AND providerId != 'credential'; -``` - -`"Account"`/`` `Account` `` above is the stack's own greenfield default table name — substitute your project's actual (and, on Postgres, schema-qualified) table name if you renamed it via `authPlugin({ account: { tableName } })` or adopted an existing install with `adoptBetterAuthTables({ useBetterAuthTableNames: true })` (physical table `account`, commonly under a non-`public` schema). - -URL-encode `providerId` if it can contain characters outside `[A-Za-z0-9_-]`. If you configured a custom OIDC provider with its own issuer URL, use that provider's real issuer instead of the synthetic `local:oauth:` value. - -**The new `@@unique([issuer, accountId])` constraint is not yet emitted.** better-auth 1.7 also declares this composite unique index at the table level, but the stack only derives _field_-level `unique`/`index` flags today — table-level index derivation is #985, which hasn't landed. This is deliberately out of scope here (per #986's own triage note: build on #985 once it lands, don't duplicate it). When it does land, make sure your backfilled `issuer` values don't collide on `(issuer, accountId)` for any account, or the constraint will fail to apply. - -**Breaking (MCP plugin users only): `@better-auth/mcp` is now a separate package.** better-auth 1.7 split the `mcp` plugin out of `better-auth/plugins` into its own package, rebuilt on the OAuth Provider RFC 8707/9728 resource model. `@opensaas/stack-auth/plugins` now re-exports `mcp` from `@better-auth/mcp` (added as an optional peer — install it if you use MCP). The plugin also now **requires** a `resource` option: - -```typescript -import { mcp } from '@opensaas/stack-auth/plugins' -import { jwt } from 'better-auth/plugins' - -authPlugin({ - betterAuthPlugins: [ - // The OAuth Provider mcp() is built on issues JWT-based access tokens - // and requires better-auth's own jwt() plugin registered alongside it — - // omitting it throws `BetterAuthError: jwt_config` at init. - jwt(), - mcp({ - loginPage: '/sign-in', - // The page where a user approves/denies an MCP client's requested - // scopes — also required as of better-auth 1.7. - consentPage: '/consent', - // RFC 8707/9728 canonical resource identifier — required as of - // better-auth 1.7. Must match your `mcp.basePath`. HTTP is only - // accepted on loopback hosts. - resource: `${process.env.BETTER_AUTH_URL}/api/mcp`, - }), - ], -}) -``` - -better-auth 1.7's MCP plugin also declares a substantially different OAuth table set — the old `oauthApplication`/`oauthAccessToken`/`oauthConsent` three became seven tables (`oauthClient`/`oauthAccessToken`/`oauthConsent`/`oauthRefreshToken`/`oauthResource`/`oauthClientResource`/`oauthClientAssertion`). Since the derivation is schema-driven this needed no code changes, but if you have the MCP plugin enabled, running `pnpm generate` will produce a significantly different Prisma schema for these tables (new/renamed models, and `Session` gains reverse relations to the two token tables that now reference it). Review the diff and migrate your database accordingly. - -**Workspace-wide: pins `@better-auth/utils` to `0.5.0` via a root `pnpm.overrides`.** better-auth 1.7.1's own published packages disagree on this transitive dependency — `better-auth` pins it at exactly `0.4.2` while `better-call` (used by `@better-auth/core`, `@better-auth/oauth-provider`, and `@better-auth/mcp`) requires `^0.5.0` — so pnpm resolves two separate physical instances of `@better-auth/core` depending on which peer chain a given package sits in. That split is invisible at runtime but breaks TypeScript: `jwt()` (from `better-auth/plugins`) and `mcp()` (from `@better-auth/mcp`) end up typed against different `@better-auth/core` instances, so `betterAuthPlugins: [jwt(), mcp(...)]` fails to type-check with a `BetterAuthPlugin` structural-mismatch error even though both plugins are otherwise correctly configured. The override forces one instance workspace-wide. If you hit the same error in your own app, add the equivalent override to your own `package.json`. - -Also fixes two `deriveAuthLists` gaps surfaced by the MCP plugin's expanded schema: - -- the scalar-field builder now threads a static `defaultValue` through for `number`-typed fields (`integer()`/`bigInt()`), matching the existing `string`/`boolean` behavior (e.g. `oauthResource.policyVersion`, which defaults to `1`) -- a plugin table that declares only one of `createdAt`/`updatedAt` upstream (several of the new OAuth tables declare `createdAt` alone) is no longer silently dropped — it derives as an ordinary required column instead. Previously any model with an asymmetric timestamp pair had the field skipped entirely with no replacement, which crashed the first real write that supplied it (better-auth's own OAuth Provider does this at `betterAuth()` init time, seeding an `oauthResource` row) diff --git a/.changeset/four-otters-derive.md b/.changeset/four-otters-derive.md deleted file mode 100644 index d0325ee0..00000000 --- a/.changeset/four-otters-derive.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@opensaas/stack-auth': patch ---- - -`deriveAuthLists` now derives the Auth lists (User/Session/Account/Verification/RateLimit) from better-auth's own `getAuthTables()` output instead of a hand-written transcription, closing the drift class behind #935/#937/#921/#986. Generated schema output is unchanged for existing projects — no migration needed. diff --git a/.changeset/gentle-otters-clean.md b/.changeset/gentle-otters-clean.md deleted file mode 100644 index 104afda8..00000000 --- a/.changeset/gentle-otters-clean.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@opensaas/stack-ui': patch ---- - -Remove comments that restated the line below them or duplicated rationale already stated elsewhere in `packages/ui/src`. No behavior changes. diff --git a/.changeset/loud-otters-jump.md b/.changeset/loud-otters-jump.md deleted file mode 100644 index ca2d68d7..00000000 --- a/.changeset/loud-otters-jump.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@opensaas/stack-core': minor ---- - -`OperationAccess.create` now throws `InvalidCreateAccessResultError` when the rule returns anything other than `true`/`false` — most notably a Prisma filter, which previously fell through the `create` access check unrecognised and was silently treated as a full allow (both the top-level write pipeline and nested-create paths were affected). - -Create has no existing row to scope a filter against, so a filter can no longer be honoured here: - -```typescript -// Before: type-checked, read as row-scoped, actually allowed everyone -create: ({ session }) => ({ ownerId: { equals: session.userId } }) - -// Now throws InvalidCreateAccessResultError. Scope ownership in a hook instead: -hooks: { - resolveInput: async ({ resolvedData, context, operation }) => { - if (operation === 'create') { - return { ...resolvedData, ownerId: context.session?.userId } - } - return resolvedData - }, -}, -access: { - operation: { - create: ({ session }) => !!session, // boolean only - }, -}, -``` - -`create: () => false` still denies via Silent failure as before; only a non-boolean result now throws. diff --git a/.changeset/plugin-table-credential-fields.md b/.changeset/plugin-table-credential-fields.md deleted file mode 100644 index 1ccb6d6d..00000000 --- a/.changeset/plugin-table-credential-fields.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@opensaas/stack-auth': minor ---- - -Extend the ADR-0036 credential field read-deny from the four base Auth models to better-auth plugin tables, and add a `credentialFields` config option for plugins the stack doesn't seed a set for. - -The following fields now ship field-level `read`-denied, on top of the existing `Session.token`/`Verification.value`/`Account.password`/`accessToken`/`refreshToken`/`idToken`: - -- `oauthClient.clientSecret`, `oauthAccessToken.token`, `oauthRefreshToken.token` (the `mcp`/oauth-provider plugin) -- `twoFactor.secret`, `twoFactor.backupCodes` (`twoFactor()`) - -An application opening one of these lists (e.g. declaring `OauthClient` under its own `lists` to grant access) no longer also exposes the credential column — same behavior as the existing base-model deny, `sudo()` still reads it. - -For a plugin the stack has no seeded credential set for, mark a field yourself: - -```typescript -authPlugin({ - betterAuthPlugins: [passkey()], - credentialFields: { passkey: ['publicKey'] }, -}) -``` - -`credentialFields` is additive only — it can add fields to any model (including a seeded one) but can never unmark a seeded field. An entry naming a field missing from a model your app actually derives throws at config time; an entry for a model your app doesn't derive is a no-op. diff --git a/.changeset/quiet-otters-declare.md b/.changeset/quiet-otters-declare.md deleted file mode 100644 index 9cb39877..00000000 --- a/.changeset/quiet-otters-declare.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@opensaas/stack-auth': minor -'@opensaas/stack-cli': minor -'@opensaas/stack-core': patch ---- - -Let an application declare model-level indexes (`db.indexes`) on the derived auth lists (`User`/`Session`/`Account`/`Verification`/`RateLimit`). - -Each per-model block in `authPlugin()` now accepts `indexes`, in the same shape as a list's own `db.indexes`: - -```typescript -authPlugin({ - // Adopt a live constraint's real name instead of Prisma's derived one. - user: { indexes: [{ fields: ['email'], unique: true, name: 'user_email_key' }] }, - session: { indexes: [{ fields: ['token'], unique: true, name: 'session_token_key' }] }, - // Extend a derived column into a composite index. - verification: { - indexes: [{ fields: ['identifier', { field: 'createdAt', sort: 'desc' }] }], - }, -}) -``` - -An entry covering a column the stack already derives an index for (e.g. `User.email`) suppresses that derived index for that column and emits only the app's entry, rather than erroring — the application's declaration wins (ADR-0035). Suppression is per-column: every other derived index on the model is unaffected. - -This also fixes a related generator gap: a list's `db.indexes` can now reference `createdAt`/`updatedAt` even when the list has no explicit field for them and relies on `db.timestamps` for the auto-injected columns (previously only a list with an explicitly declared `createdAt`/`updatedAt` field could be indexed on it). diff --git a/.changeset/quiet-otters-listen.md b/.changeset/quiet-otters-listen.md deleted file mode 100644 index ef67e90d..00000000 --- a/.changeset/quiet-otters-listen.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@opensaas/stack-auth': patch ---- - -Clean up comments in `packages/auth/src` per the CLAUDE.md Comments rule — removed restating/duplicated comments, kept public-API TSDoc and footgun/external-constraint warnings. No behavior changes. diff --git a/.changeset/silent-jars-refuse.md b/.changeset/silent-jars-refuse.md deleted file mode 100644 index d7a9f0c0..00000000 --- a/.changeset/silent-jars-refuse.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@opensaas/stack-ui': minor ---- - -Password columns are now identified by field type, not field name, across the list view, standalone `ListTable`, and item-view Relationship tables. A field declared `secret: password()` is now excluded from default columns even though it isn't named `password`; a field merely named `password` (e.g. `password: text()`) is no longer excluded unless it is actually a `password()` field. - -A `password` Cell is now registered in the cell registry, so a password-typed column shown via an explicit `columns` prop renders a fixed `••••••••` mask instead of the raw value. - -BREAKING (shipped as minor — pre-1.0 packages ship breaking changes as minor): the unused `getFieldDisplayValue` export has been removed from `@opensaas/stack-ui`. It had no callers in the rendering path — Cells render each field type directly — so nothing in this package depended on it; a consumer importing it directly should port to a project-local formatter. diff --git a/.changeset/silent-otters-redact.md b/.changeset/silent-otters-redact.md deleted file mode 100644 index 3b599c19..00000000 --- a/.changeset/silent-otters-redact.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@opensaas/stack-core': patch ---- - -Fix `HashedPassword.toJSON()` returning the raw bcrypt hash, so `JSON.stringify` of a row (e.g. a server→client prop, `Response.json()`, an MCP tool response) no longer leaks the stored hash for a `password()` field. - -`toJSON()` now returns `{ isSet: boolean }`, matching the redaction the admin UI already applies via `valueForClientSerialization`. `toString()`, `valueOf()`, `[Symbol.toPrimitive]`, and `==` comparison against the hash are unchanged. If you parse `JSON.stringify`'d rows and read the password field as a string, update that code to read `.isSet` instead — this is a visible output/type change on `HashedPassword.toJSON()`, though the field's read access remains the application's to configure (unchanged). diff --git a/.changeset/silent-plugins-cascade.md b/.changeset/silent-plugins-cascade.md deleted file mode 100644 index 545e7525..00000000 --- a/.changeset/silent-plugins-cascade.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@opensaas/stack-auth': minor ---- - -Better-auth plugin tables (e.g. the MCP plugin's `oauthApplication`/`oauthAccessToken`/`oauthConsent`) are now derived through the same registry as the four base Auth models, instead of a separate converter that dropped every reference to a bare column. Reference fields now become real `relationship()` foreign keys with the correct `onDelete` cascade, index, uniqueness and nullability, closing a data-integrity defect where deleting a user left their OAuth rows orphaned (neither the database nor better-auth's own `deleteUser` cleaned them up). Plugin-table scalar fields now also honour `fieldName` column maps and `index: true`, and list keys are PascalCased with `db.map` restoring the original physical table name. A reference whose target field isn't the target's `id` (e.g. `oauthAccessToken.clientId` → `oauthApplication.clientId`) is left as a plain scalar column, since `relationship()` only supports `id`-based foreign keys. - -No config changes are required — `authPlugin()`/`getAuthLists()` are unchanged. If your app already had an MCP-enabled config generated with an older version, regenerate and diff your schema: the OAuth tables' `userId` columns gain a foreign key, cascade and index they didn't have before. diff --git a/.changeset/silly-plums-arrive.md b/.changeset/silly-plums-arrive.md deleted file mode 100644 index 95bb9a8c..00000000 --- a/.changeset/silly-plums-arrive.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@opensaas/stack-core': minor ---- - -Extend `isIndexed` to `integer`, `timestamp`, and `select`, matching `text`, `decimal`, `bigInt`, `calendarDay`, and `relationship`. - -```typescript -fields: { - rank: integer({ isIndexed: true }), - publishedAt: timestamp({ isIndexed: true }), - status: select({ - options: [{ label: 'Draft', value: 'draft' }], - isIndexed: 'unique', - }), -} -``` - -`isIndexed: true` generates a block-level `@@index([field])`; `isIndexed: 'unique'` generates an inline `@unique`. `select` supports both under the default string column and a native-enum column (`db: { type: 'enum' }`). No field type's default indexing behavior changes — an existing config generates the same schema as before. diff --git a/.changeset/six-plugins-hide.md b/.changeset/six-plugins-hide.md deleted file mode 100644 index ca7756fe..00000000 --- a/.changeset/six-plugins-hide.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@opensaas/stack-auth': minor ---- - -BREAKING (pre-1.0): The derived auth lists' credential-bearing fields now ship with a field-level `read` deny, so opening operation-level access to a list no longer exposes them: - -- `Session.token` -- `Verification.value` -- `Account.password` -- `Account.accessToken` -- `Account.refreshToken` -- `Account.idToken` - -A denied field is stripped from a returned row, not an error — a `context.db` read on an opened list still succeeds and returns every other field, including a `findUnique` lookup that selects the row **by** the denied field itself (e.g. `context.db.session.findUnique({ where: { token } })` still finds the session; the returned `token` comes back stripped). Naming a denied field in `findMany`'s (or `count`'s) `where`/`orderBy` is different: the existing predicate-time read-access check (`validateQueryFieldReadAccess`) throws a `ValidationError` there instead, the same as it already does for any other field-level `read` deny. `sudo()` bypasses both — it's the supported path for an application with a genuine need. Sign-in, session refresh, email verification, and password reset are unaffected — better-auth's own flows write through the raw Prisma adapter, bypassing access control entirely. - -If your application opens one of these lists today and deliberately reads one of these fields through `context.db` — a returned row, a `findMany`/`count` predicate, or a `findUnique` selector — switch that access to `context.sudo().db...`. See ADR-0036. diff --git a/.changeset/tame-eagles-relax.md b/.changeset/tame-eagles-relax.md deleted file mode 100644 index 42e4555f..00000000 --- a/.changeset/tame-eagles-relax.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@opensaas/stack-cli': patch ---- - -Clean up stale/restating comments in migration, MCP, and commands source per CLAUDE.md's Comments rule. No behavior changes. diff --git a/.changeset/tame-hounds-nap.md b/.changeset/tame-hounds-nap.md deleted file mode 100644 index 81b71c36..00000000 --- a/.changeset/tame-hounds-nap.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@opensaas/stack-core': patch ---- - -MCP derived CRUD tool and custom tool failures (access denial, thrown engine/database errors, input schema validation) now return a successful JSON-RPC response with `result.isError: true` instead of a JSON-RPC `error` object, so the calling model can see and recover from them. Genuine protocol failures (unknown method, malformed request, unknown tool name) are unchanged. Note: the wire shape of tool failures changes — a consumer asserting on the old `error` shape will need to update. diff --git a/.changeset/tame-jokes-relax.md b/.changeset/tame-jokes-relax.md deleted file mode 100644 index 26199287..00000000 --- a/.changeset/tame-jokes-relax.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@opensaas/stack-core': patch ---- - -`relationship({ ref: 'ListName' })` list-only refs now accept `db.foreignKey: { map: '...' }` to rename the foreign key column. The boolean form (`true`/`false`) is still rejected there since ownership is implicit on a list-only ref. diff --git a/.changeset/tame-otters-comply.md b/.changeset/tame-otters-comply.md deleted file mode 100644 index 2652cd41..00000000 --- a/.changeset/tame-otters-comply.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@opensaas/stack-core': patch ---- - -Fix a nested create/update/delete through a list-only ref's synthetic reverse relation (`from__`) silently bypassing the target list's hooks and validation. It now runs the same pipeline a declared relationship field's nested write gets. Under `sudo()`, an undeclared key that isn't a synthetic reverse relation is now refused rather than passed through unchecked. diff --git a/.changeset/tame-otters-listen.md b/.changeset/tame-otters-listen.md deleted file mode 100644 index d2ea3545..00000000 --- a/.changeset/tame-otters-listen.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@opensaas/stack-auth': patch ---- - -Add a test that compares the derived Auth lists against better-auth's own `getAuthTables()` definitions, failing the build on future upstream schema drift instead of relying on a human to notice. diff --git a/.changeset/tame-rabbits-clean.md b/.changeset/tame-rabbits-clean.md deleted file mode 100644 index bf18d246..00000000 --- a/.changeset/tame-rabbits-clean.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@opensaas/stack-rag': patch ---- - -Clean up comments in `packages/rag/src` per the CLAUDE.md Comments rule — removed restatement and stale narration, kept public-API TSDoc, external-constraint notes, and genuine footgun warnings. No behavior changes. diff --git a/.changeset/three-otters-index.md b/.changeset/three-otters-index.md deleted file mode 100644 index 25a5d46a..00000000 --- a/.changeset/three-otters-index.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@opensaas/stack-auth': patch ---- - -The derived `Session.user` / `Account.user` foreign keys are now indexed (`isIndexed: true`, was `false`), and `Verification.identifier` is now indexed too — matching the three indexes better-auth itself declares (`session_userId_idx`, `account_userId_idx`, `verification_identifier_idx`). `prisma migrate diff` against a real better-auth install no longer reads these as three dropped indexes, and `Session`/`Account` lookups by `userId` are no longer unindexed. - -**Migration note:** existing projects will see a migration on their next `prisma migrate dev`/`db push` adding the three indexes. diff --git a/.changeset/tidy-otters-clap.md b/.changeset/tidy-otters-clap.md deleted file mode 100644 index 469666c6..00000000 --- a/.changeset/tidy-otters-clap.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@opensaas/stack-auth': patch ---- - -Fix `Session.user`/`Account.user` foreign key generating a `user` physical column instead of `userId`, mismatching better-auth's own schema and breaking clean-diff adoption (ADR-0007). An explicit `fields: { userId: ... }` override is unaffected. - -**Migration note:** existing greenfield projects need to rename the column on `Session` and `Account` (e.g. `ALTER TABLE "Session" RENAME COLUMN "user" TO "userId";` and the same for `Account`) to match the new generated schema. diff --git a/.changeset/tidy-otters-rest.md b/.changeset/tidy-otters-rest.md deleted file mode 100644 index e5910fa5..00000000 --- a/.changeset/tidy-otters-rest.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@opensaas/stack-cli': patch ---- - -Clean up restating/duplicated comments in `packages/cli/src/generator/` per the CLAUDE.md Comments rule. No behavior change. diff --git a/.changeset/tiny-clouds-listen.md b/.changeset/tiny-clouds-listen.md deleted file mode 100644 index 3f0193c0..00000000 --- a/.changeset/tiny-clouds-listen.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@opensaas/stack-storage': patch -'@opensaas/stack-storage-s3': patch -'@opensaas/stack-storage-vercel': patch -'@opensaas/stack-tiptap': patch -'create-opensaas-app': patch ---- - -Comment cleanup only, no behavior change: removed restating/narration comments, kept TSDoc on public config options and field builders, and kept external API/behavior constraint notes (Prisma, S3, Vercel Blob, Keystone parity, Next.js SSR, Zod). diff --git a/.changeset/tiny-cobras-jump.md b/.changeset/tiny-cobras-jump.md deleted file mode 100644 index cb1973e0..00000000 --- a/.changeset/tiny-cobras-jump.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@opensaas/stack-core': patch ---- - -Fix `P2002` unique-constraint errors losing per-field detail under Prisma 7 driver adapters (`@prisma/adapter-pg`, PGlite), where `meta.target` is left empty. The error handler now recovers the violated columns and constraint name from the adapter's error shape, and a new `uniqueConstraintOf(error)` helper exposes this to callers of `context.db.*` directly. Unique-violation messages under driver adapters change from the generic fallback back to field-specific text. diff --git a/.changeset/violet-otters-shine.md b/.changeset/violet-otters-shine.md deleted file mode 100644 index d433bb31..00000000 --- a/.changeset/violet-otters-shine.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@opensaas/stack-core': minor ---- - -The derived MCP `query` tool now accepts an optional `fields` projection — the wire form of the runtime's existing fragment field selection — so an assistant can select scalars and nested relation fields (with `where`/`orderBy`/`take`/`skip`, and a to-many's row count) in a single call instead of following a foreign key with a second one. Omitting `fields` is unchanged, a bare read exactly as before. - -```json -{ - "name": "list_post_query", - "arguments": { - "fields": { - "title": true, - "author": { "fields": { "name": true } }, - "comments": { - "fields": { "text": true }, - "where": { "approved": { "equals": true } }, - "take": 5, - "count": true - } - } - } -} -``` - -The generated tool schema enumerates two levels of each list's own fields and relations, per session, and refuses (as an `isError` tool result, never a protocol error) anything it doesn't advertise — an unknown field, or a relation named a third level deep. See the ADR (`docs/adr/0033-mcp-tools-advertise-a-bounded-projection.md`) for the full design. - -**Behaviour change:** `tools/list` is now evaluated per session. A list whose operation-level `query` access denies the session outright no longer appears in the tool listing at all — none of its four CRUD tools, and no relation entry elsewhere pointing at it. Previously every list's tools were listed regardless of session. diff --git a/.changeset/wise-hounds-listen.md b/.changeset/wise-hounds-listen.md deleted file mode 100644 index 44ad75e1..00000000 --- a/.changeset/wise-hounds-listen.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@opensaas/stack-core': minor -'@opensaas/stack-ui': patch -'@opensaas/stack-auth': patch ---- - -Fix admin UI URL round-trip for a list keyed with anything other than strict PascalCase (issue #991). `getListKeyFromUrl` reconstructs a list key by string transformation, which is lossy for a non-PascalCase key — a real example is a better-auth plugin's derived list (e.g. `oauthApplication`, from the `mcp` plugin's OAuth tables). Such a list appeared in navigation but its own link resolved to a key that did not exist in `config.lists`, rendering "List not found". - -`@opensaas/stack-core` adds `resolveListKeyFromUrl(urlSegment, listKeys)` alongside the existing `getListKeyFromUrl`, which is unchanged and still exported. The new resolver matches a URL segment against the config's actual list keys via `getUrlKey` — the same helper that builds the URL — instead of reconstructing one, so route lookup and URL generation cannot drift apart. It returns `undefined` for a segment matching no list (so callers keep rendering their existing "not found" state), and throws if two distinct list keys would produce the same URL segment. - -```typescript -import { resolveListKeyFromUrl } from '@opensaas/stack-core' - -resolveListKeyFromUrl('oauth-application', Object.keys(config.lists)) // 'oauthApplication' -resolveListKeyFromUrl('does-not-exist', Object.keys(config.lists)) // undefined -``` - -`@opensaas/stack-ui`'s `AdminUI` now uses `resolveListKeyFromUrl` for its route resolution, fixing the broken link for any such list. - -`@opensaas/stack-auth`'s `convertBetterAuthSchema` now PascalCases a better-auth plugin's camelCase `modelName` when deriving a list key (`oauthApplication` → `OauthApplication`, `rateLimit` → `RateLimit`), matching the repo's PascalCase list-key convention and fixing the same round-trip bug at the source for these lists. - -**Schema-affecting for `@opensaas/stack-auth` users with a better-auth plugin that declares extra tables** (e.g. `mcp`'s OAuth tables, or `rateLimit.storage: 'database'` with no `modelName` remap configured): the generated Prisma **model name** changes to match the new PascalCase list key. The physical **table name** does not change — the previous camelCase name is preserved via `db.map` (`@@map`) — so `prisma db push` / `prisma migrate dev` sees a model rename, not a table rename, and `context.db.oauthApplication` (the camelCase db accessor) keeps working unchanged. Regenerate (`pnpm generate`) and re-run your migration/push step after upgrading. diff --git a/.changeset/witty-goats-listen.md b/.changeset/witty-goats-listen.md deleted file mode 100644 index 62074f82..00000000 --- a/.changeset/witty-goats-listen.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@opensaas/stack-core': minor -'@opensaas/stack-cli': minor ---- - -Add `context.withSession(session)` — a sibling to `sudo()` for the other axis. It derives a `StackContext` that reuses the receiver's already-resolved config, client (including a transaction client — a call inside `context.transaction()` stays in that transaction), and storage, but carries a substituted session, so access control and hooks run against the new session as normal. - -This closes a gap for callers that are legitimately authorised but arrive without the session a list `validate` hook expects — an unattended dispatcher, a service principal, or a job runner: - -```typescript -// Runs with the job owner's session so hooks see the right identity, while -// still going through the normal access control checks for that session. -const asOwner = context.withSession(job.ownerSession) -await asOwner.db.task.update({ where: { id: job.taskId }, data: { status: 'done' } }) - -// Drop to anonymous -const anonymous = context.withSession(null) -``` - -`withSession` grants no authority of its own — the derived context can do exactly what any context built with that session directly could do. It's orthogonal to `sudo()`: `context.withSession(s).sudo()` and `context.sudo().withSession(s)` are equivalent, since `withSession` preserves the receiver's sudo state instead of resetting it. - -The generated `Context` type (`.opensaas/types.ts`) now includes `withSession: (session: TSession | null) => Context` alongside `sudo`, so the method is typed in application code — run `opensaas generate` (or `pnpm generate`) to pick it up. diff --git a/.changeset/wobbly-otters-scope.md b/.changeset/wobbly-otters-scope.md deleted file mode 100644 index 40925398..00000000 --- a/.changeset/wobbly-otters-scope.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@opensaas/stack-core': patch ---- - -Fix `include` on a to-one relationship throwing `PrismaClientValidationError` when the related list's `query` access resolves to a filter (Prisma only accepts a nested `where` on a to-many include). The relation is now fetched and access-scoped via a batched existence check instead, returning `null` for an excluded related row rather than throwing — a caller relying on the previous exception, or whose types assumed a non-null relation, should re-check nullability. diff --git a/packages/auth/CHANGELOG.md b/packages/auth/CHANGELOG.md index 116768ad..0bafb7c9 100644 --- a/packages/auth/CHANGELOG.md +++ b/packages/auth/CHANGELOG.md @@ -1,5 +1,156 @@ # @opensaas/stack-auth +## 0.40.0 + +### Minor Changes + +- [#1015](https://github.com/OpenSaasAU/stack/pull/1015) [`72c4ba3`](https://github.com/OpenSaasAU/stack/commit/72c4ba30e9f53762988a822bb7ead7eae0db270c) Thanks [@borisno2](https://github.com/borisno2)! - Bump the `better-auth` dev dependency to `1.7.1` and move the peer range off the stale `^1.3.29` floor to `^1.4.0` (the release line where better-auth's `index: true` flags — the basis for [#937](https://github.com/OpenSaasAU/stack/issues/937)'s index emission — first shipped; below it the generated schema silently omitted indexes better-auth itself declares). + + **New required `account.issuer` column.** better-auth 1.7 adds a required `issuer` column to its `account` model. Because `deriveAuthLists` derives the Auth lists from better-auth's own `getAuthTables()` ([#987](https://github.com/OpenSaasAU/stack/issues/987)/[#997](https://github.com/OpenSaasAU/stack/issues/997)), this column now appears in the generated schema automatically — no code change was needed, only verification against real generated output. Existing projects upgrading to `better-auth@^1.7` will see a **new NOT NULL column** on their `account` table and need a backfill for existing rows. Following better-auth's own `createLocalAccountIssuer`/`createOAuthAccountIssuer` helpers (`@better-auth/core/db`): + + ```sql + -- PostgreSQL / SQLite (|| is string concatenation on both) + UPDATE "Account" SET issuer = 'local:' || "providerId" WHERE issuer IS NULL AND "providerId" = 'credential'; + UPDATE "Account" SET issuer = 'local:oauth:' || "providerId" WHERE issuer IS NULL AND "providerId" != 'credential'; + + -- MySQL (|| is logical OR by default, NOT concatenation — use CONCAT instead) + UPDATE `Account` SET issuer = CONCAT('local:', providerId) WHERE issuer IS NULL AND providerId = 'credential'; + UPDATE `Account` SET issuer = CONCAT('local:oauth:', providerId) WHERE issuer IS NULL AND providerId != 'credential'; + ``` + + `"Account"`/`` `Account` `` above is the stack's own greenfield default table name — substitute your project's actual (and, on Postgres, schema-qualified) table name if you renamed it via `authPlugin({ account: { tableName } })` or adopted an existing install with `adoptBetterAuthTables({ useBetterAuthTableNames: true })` (physical table `account`, commonly under a non-`public` schema). + + URL-encode `providerId` if it can contain characters outside `[A-Za-z0-9_-]`. If you configured a custom OIDC provider with its own issuer URL, use that provider's real issuer instead of the synthetic `local:oauth:` value. + + **The new `@@unique([issuer, accountId])` constraint is not yet emitted.** better-auth 1.7 also declares this composite unique index at the table level, but the stack only derives _field_-level `unique`/`index` flags today — table-level index derivation is [#985](https://github.com/OpenSaasAU/stack/issues/985), which hasn't landed. This is deliberately out of scope here (per [#986](https://github.com/OpenSaasAU/stack/issues/986)'s own triage note: build on [#985](https://github.com/OpenSaasAU/stack/issues/985) once it lands, don't duplicate it). When it does land, make sure your backfilled `issuer` values don't collide on `(issuer, accountId)` for any account, or the constraint will fail to apply. + + **Breaking (MCP plugin users only): `@better-auth/mcp` is now a separate package.** better-auth 1.7 split the `mcp` plugin out of `better-auth/plugins` into its own package, rebuilt on the OAuth Provider RFC 8707/9728 resource model. `@opensaas/stack-auth/plugins` now re-exports `mcp` from `@better-auth/mcp` (added as an optional peer — install it if you use MCP). The plugin also now **requires** a `resource` option: + + ```typescript + import { mcp } from '@opensaas/stack-auth/plugins' + import { jwt } from 'better-auth/plugins' + + authPlugin({ + betterAuthPlugins: [ + // The OAuth Provider mcp() is built on issues JWT-based access tokens + // and requires better-auth's own jwt() plugin registered alongside it — + // omitting it throws `BetterAuthError: jwt_config` at init. + jwt(), + mcp({ + loginPage: '/sign-in', + // The page where a user approves/denies an MCP client's requested + // scopes — also required as of better-auth 1.7. + consentPage: '/consent', + // RFC 8707/9728 canonical resource identifier — required as of + // better-auth 1.7. Must match your `mcp.basePath`. HTTP is only + // accepted on loopback hosts. + resource: `${process.env.BETTER_AUTH_URL}/api/mcp`, + }), + ], + }) + ``` + + better-auth 1.7's MCP plugin also declares a substantially different OAuth table set — the old `oauthApplication`/`oauthAccessToken`/`oauthConsent` three became seven tables (`oauthClient`/`oauthAccessToken`/`oauthConsent`/`oauthRefreshToken`/`oauthResource`/`oauthClientResource`/`oauthClientAssertion`). Since the derivation is schema-driven this needed no code changes, but if you have the MCP plugin enabled, running `pnpm generate` will produce a significantly different Prisma schema for these tables (new/renamed models, and `Session` gains reverse relations to the two token tables that now reference it). Review the diff and migrate your database accordingly. + + **Workspace-wide: pins `@better-auth/utils` to `0.5.0` via a root `pnpm.overrides`.** better-auth 1.7.1's own published packages disagree on this transitive dependency — `better-auth` pins it at exactly `0.4.2` while `better-call` (used by `@better-auth/core`, `@better-auth/oauth-provider`, and `@better-auth/mcp`) requires `^0.5.0` — so pnpm resolves two separate physical instances of `@better-auth/core` depending on which peer chain a given package sits in. That split is invisible at runtime but breaks TypeScript: `jwt()` (from `better-auth/plugins`) and `mcp()` (from `@better-auth/mcp`) end up typed against different `@better-auth/core` instances, so `betterAuthPlugins: [jwt(), mcp(...)]` fails to type-check with a `BetterAuthPlugin` structural-mismatch error even though both plugins are otherwise correctly configured. The override forces one instance workspace-wide. If you hit the same error in your own app, add the equivalent override to your own `package.json`. + + Also fixes two `deriveAuthLists` gaps surfaced by the MCP plugin's expanded schema: + + - the scalar-field builder now threads a static `defaultValue` through for `number`-typed fields (`integer()`/`bigInt()`), matching the existing `string`/`boolean` behavior (e.g. `oauthResource.policyVersion`, which defaults to `1`) + - a plugin table that declares only one of `createdAt`/`updatedAt` upstream (several of the new OAuth tables declare `createdAt` alone) is no longer silently dropped — it derives as an ordinary required column instead. Previously any model with an asymmetric timestamp pair had the field skipped entirely with no replacement, which crashed the first real write that supplied it (better-auth's own OAuth Provider does this at `betterAuth()` init time, seeding an `oauthResource` row) + +- [#1019](https://github.com/OpenSaasAU/stack/pull/1019) [`77b7314`](https://github.com/OpenSaasAU/stack/commit/77b731452f24045995a1d3a2ffede0246d5743d3) Thanks [@borisno2](https://github.com/borisno2)! - Extend the ADR-0036 credential field read-deny from the four base Auth models to better-auth plugin tables, and add a `credentialFields` config option for plugins the stack doesn't seed a set for. + + The following fields now ship field-level `read`-denied, on top of the existing `Session.token`/`Verification.value`/`Account.password`/`accessToken`/`refreshToken`/`idToken`: + + - `oauthClient.clientSecret`, `oauthAccessToken.token`, `oauthRefreshToken.token` (the `mcp`/oauth-provider plugin) + - `twoFactor.secret`, `twoFactor.backupCodes` (`twoFactor()`) + + An application opening one of these lists (e.g. declaring `OauthClient` under its own `lists` to grant access) no longer also exposes the credential column — same behavior as the existing base-model deny, `sudo()` still reads it. + + For a plugin the stack has no seeded credential set for, mark a field yourself: + + ```typescript + authPlugin({ + betterAuthPlugins: [passkey()], + credentialFields: { passkey: ['publicKey'] }, + }) + ``` + + `credentialFields` is additive only — it can add fields to any model (including a seeded one) but can never unmark a seeded field. An entry naming a field missing from a model your app actually derives throws at config time; an entry for a model your app doesn't derive is a no-op. + +- [#1017](https://github.com/OpenSaasAU/stack/pull/1017) [`b30fa61`](https://github.com/OpenSaasAU/stack/commit/b30fa6135a6acca8c9be99fbdf5ffa7faab1959f) Thanks [@{](https://github.com/{)! - Let an application declare model-level indexes (`db.indexes`) on the derived auth lists (`User`/`Session`/`Account`/`Verification`/`RateLimit`). + + Each per-model block in `authPlugin()` now accepts `indexes`, in the same shape as a list's own `db.indexes`: + + ```typescript + authPlugin({ + // Adopt a live constraint's real name instead of Prisma's derived one. + indexes: [{ fields: ['email'], unique: true, name: 'user_email_key' }] }, + session: { indexes: [{ fields: ['token'], unique: true, name: 'session_token_key' }] }, + // Extend a derived column into a composite index. + verification: { + indexes: [{ fields: ['identifier', { field: 'createdAt', sort: 'desc' }] }], + }, + }) + ``` + + An entry covering a column the stack already derives an index for (e.g. `User.email`) suppresses that derived index for that column and emits only the app's entry, rather than erroring — the application's declaration wins (ADR-0035). Suppression is per-column: every other derived index on the model is unaffected. + + This also fixes a related generator gap: a list's `db.indexes` can now reference `createdAt`/`updatedAt` even when the list has no explicit field for them and relies on `db.timestamps` for the auto-injected columns (previously only a list with an explicitly declared `createdAt`/`updatedAt` field could be indexed on it). + +- [#1005](https://github.com/OpenSaasAU/stack/pull/1005) [`b67fdd1`](https://github.com/OpenSaasAU/stack/commit/b67fdd10d678f9fd209259b063186db9f9aaf20a) Thanks [@borisno2](https://github.com/borisno2)! - Better-auth plugin tables (e.g. the MCP plugin's `oauthApplication`/`oauthAccessToken`/`oauthConsent`) are now derived through the same registry as the four base Auth models, instead of a separate converter that dropped every reference to a bare column. Reference fields now become real `relationship()` foreign keys with the correct `onDelete` cascade, index, uniqueness and nullability, closing a data-integrity defect where deleting a user left their OAuth rows orphaned (neither the database nor better-auth's own `deleteUser` cleaned them up). Plugin-table scalar fields now also honour `fieldName` column maps and `index: true`, and list keys are PascalCased with `db.map` restoring the original physical table name. A reference whose target field isn't the target's `id` (e.g. `oauthAccessToken.clientId` → `oauthApplication.clientId`) is left as a plain scalar column, since `relationship()` only supports `id`-based foreign keys. + + No config changes are required — `authPlugin()`/`getAuthLists()` are unchanged. If your app already had an MCP-enabled config generated with an older version, regenerate and diff your schema: the OAuth tables' `userId` columns gain a foreign key, cascade and index they didn't have before. + +- [#1013](https://github.com/OpenSaasAU/stack/pull/1013) [`49687ea`](https://github.com/OpenSaasAU/stack/commit/49687eaf8ad80696d62e2616ba3dfef992985282) Thanks [@borisno2](https://github.com/borisno2)! - BREAKING (pre-1.0): The derived auth lists' credential-bearing fields now ship with a field-level `read` deny, so opening operation-level access to a list no longer exposes them: + + - `Session.token` + - `Verification.value` + - `Account.password` + - `Account.accessToken` + - `Account.refreshToken` + - `Account.idToken` + + A denied field is stripped from a returned row, not an error — a `context.db` read on an opened list still succeeds and returns every other field, including a `findUnique` lookup that selects the row **by** the denied field itself (e.g. `context.db.session.findUnique({ where: { token } })` still finds the session; the returned `token` comes back stripped). Naming a denied field in `findMany`'s (or `count`'s) `where`/`orderBy` is different: the existing predicate-time read-access check (`validateQueryFieldReadAccess`) throws a `ValidationError` there instead, the same as it already does for any other field-level `read` deny. `sudo()` bypasses both — it's the supported path for an application with a genuine need. Sign-in, session refresh, email verification, and password reset are unaffected — better-auth's own flows write through the raw Prisma adapter, bypassing access control entirely. + + If your application opens one of these lists today and deliberately reads one of these fields through `context.db` — a returned row, a `findMany`/`count` predicate, or a `findUnique` selector — switch that access to `context.sudo().db...`. See ADR-0036. + +### Patch Changes + +- [#1020](https://github.com/OpenSaasAU/stack/pull/1020) [`8e6707a`](https://github.com/OpenSaasAU/stack/commit/8e6707adcca9d7e062bc1747ec79a29082c09ef9) Thanks [@borisno2](https://github.com/borisno2)! - Read-denied credential fields (ADR-0036) now also declare `ui.listView.defaultColumn: false`, so they're curated out of the admin's default table columns instead of rendering as permanently empty columns. + +- [#997](https://github.com/OpenSaasAU/stack/pull/997) [`ed6ffcd`](https://github.com/OpenSaasAU/stack/commit/ed6ffcd5cc2b471ea680f75f108596ee6b87d083) Thanks [@borisno2](https://github.com/borisno2)! - `deriveAuthLists` now derives the Auth lists (User/Session/Account/Verification/RateLimit) from better-auth's own `getAuthTables()` output instead of a hand-written transcription, closing the drift class behind [#935](https://github.com/OpenSaasAU/stack/issues/935)/[#937](https://github.com/OpenSaasAU/stack/issues/937)/[#921](https://github.com/OpenSaasAU/stack/issues/921)/[#986](https://github.com/OpenSaasAU/stack/issues/986). Generated schema output is unchanged for existing projects — no migration needed. + +- [#972](https://github.com/OpenSaasAU/stack/pull/972) [`08c3787`](https://github.com/OpenSaasAU/stack/commit/08c3787a46ead83bbc6a3730dae4d89598fba1b2) Thanks [@borisno2](https://github.com/borisno2)! - Clean up comments in `packages/auth/src` per the CLAUDE.md Comments rule — removed restating/duplicated comments, kept public-API TSDoc and footgun/external-constraint warnings. No behavior changes. + +- [#996](https://github.com/OpenSaasAU/stack/pull/996) [`cfd366c`](https://github.com/OpenSaasAU/stack/commit/cfd366ccb62c3a858a95d0df859984c08e3b3a5f) Thanks [@borisno2](https://github.com/borisno2)! - Add a test that compares the derived Auth lists against better-auth's own `getAuthTables()` definitions, failing the build on future upstream schema drift instead of relying on a human to notice. + +- [#990](https://github.com/OpenSaasAU/stack/pull/990) [`37d7905`](https://github.com/OpenSaasAU/stack/commit/37d7905b9b5126e7d7826469af467775f4daab34) Thanks [@borisno2](https://github.com/borisno2)! - The derived `Session.user` / `Account.user` foreign keys are now indexed (`isIndexed: true`, was `false`), and `Verification.identifier` is now indexed too — matching the three indexes better-auth itself declares (`session_userId_idx`, `account_userId_idx`, `verification_identifier_idx`). `prisma migrate diff` against a real better-auth install no longer reads these as three dropped indexes, and `Session`/`Account` lookups by `userId` are no longer unindexed. + + **Migration note:** existing projects will see a migration on their next `prisma migrate dev`/`db push` adding the three indexes. + +- [#989](https://github.com/OpenSaasAU/stack/pull/989) [`9cc6f8d`](https://github.com/OpenSaasAU/stack/commit/9cc6f8dcb0ed2958c39da9e7648a7a462c10264a) Thanks [@borisno2](https://github.com/borisno2)! - Fix `Session.user`/`Account.user` foreign key generating a `user` physical column instead of `userId`, mismatching better-auth's own schema and breaking clean-diff adoption (ADR-0007). An explicit `fields: { userId: ... }` override is unaffected. + + **Migration note:** existing greenfield projects need to rename the column on `Session` and `Account` (e.g. `ALTER TABLE "Session" RENAME COLUMN "user" TO "userId";` and the same for `Account`) to match the new generated schema. + +- [#1002](https://github.com/OpenSaasAU/stack/pull/1002) [`48d2762`](https://github.com/OpenSaasAU/stack/commit/48d27626dfb636c481301116e46c826ef3156124) Thanks [@borisno2](https://github.com/borisno2)! - Fix admin UI URL round-trip for a list keyed with anything other than strict PascalCase (issue [#991](https://github.com/OpenSaasAU/stack/issues/991)). `getListKeyFromUrl` reconstructs a list key by string transformation, which is lossy for a non-PascalCase key — a real example is a better-auth plugin's derived list (e.g. `oauthApplication`, from the `mcp` plugin's OAuth tables). Such a list appeared in navigation but its own link resolved to a key that did not exist in `config.lists`, rendering "List not found". + + `@opensaas/stack-core` adds `resolveListKeyFromUrl(urlSegment, listKeys)` alongside the existing `getListKeyFromUrl`, which is unchanged and still exported. The new resolver matches a URL segment against the config's actual list keys via `getUrlKey` — the same helper that builds the URL — instead of reconstructing one, so route lookup and URL generation cannot drift apart. It returns `undefined` for a segment matching no list (so callers keep rendering their existing "not found" state), and throws if two distinct list keys would produce the same URL segment. + + ```typescript + import { resolveListKeyFromUrl } from '@opensaas/stack-core' + + resolveListKeyFromUrl('oauth-application', Object.keys(config.lists)) // 'oauthApplication' + resolveListKeyFromUrl('does-not-exist', Object.keys(config.lists)) // undefined + ``` + + `@opensaas/stack-ui`'s `AdminUI` now uses `resolveListKeyFromUrl` for its route resolution, fixing the broken link for any such list. + + `@opensaas/stack-auth`'s `convertBetterAuthSchema` now PascalCases a better-auth plugin's camelCase `modelName` when deriving a list key (`oauthApplication` → `OauthApplication`, `rateLimit` → `RateLimit`), matching the repo's PascalCase list-key convention and fixing the same round-trip bug at the source for these lists. + + **Schema-affecting for `@opensaas/stack-auth` users with a better-auth plugin that declares extra tables** (e.g. `mcp`'s OAuth tables, or `rateLimit.storage: 'database'` with no `modelName` remap configured): the generated Prisma **model name** changes to match the new PascalCase list key. The physical **table name** does not change — the previous camelCase name is preserved via `db.map` (`@@map`) — so `prisma db push` / `prisma migrate dev` sees a model rename, not a table rename, and `context.db.oauthApplication` (the camelCase db accessor) keeps working unchanged. Regenerate (`pnpm generate`) and re-run your migration/push step after upgrading. + ## 0.39.2 ## 0.39.1 diff --git a/packages/auth/package.json b/packages/auth/package.json index e71f1865..f6a624a5 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@opensaas/stack-auth", - "version": "0.39.2", + "version": "0.40.0", "description": "Better-auth integration for OpenSaas Stack", "type": "module", "main": "./dist/index.js", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index e1ee9bd9..afbfbb2c 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,55 @@ # @opensaas/stack-cli +## 0.40.0 + +### Minor Changes + +- [#1017](https://github.com/OpenSaasAU/stack/pull/1017) [`b30fa61`](https://github.com/OpenSaasAU/stack/commit/b30fa6135a6acca8c9be99fbdf5ffa7faab1959f) Thanks [@{](https://github.com/{)! - Let an application declare model-level indexes (`db.indexes`) on the derived auth lists (`User`/`Session`/`Account`/`Verification`/`RateLimit`). + + Each per-model block in `authPlugin()` now accepts `indexes`, in the same shape as a list's own `db.indexes`: + + ```typescript + authPlugin({ + // Adopt a live constraint's real name instead of Prisma's derived one. + indexes: [{ fields: ['email'], unique: true, name: 'user_email_key' }] }, + session: { indexes: [{ fields: ['token'], unique: true, name: 'session_token_key' }] }, + // Extend a derived column into a composite index. + verification: { + indexes: [{ fields: ['identifier', { field: 'createdAt', sort: 'desc' }] }], + }, + }) + ``` + + An entry covering a column the stack already derives an index for (e.g. `User.email`) suppresses that derived index for that column and emits only the app's entry, rather than erroring — the application's declaration wins (ADR-0035). Suppression is per-column: every other derived index on the model is unaffected. + + This also fixes a related generator gap: a list's `db.indexes` can now reference `createdAt`/`updatedAt` even when the list has no explicit field for them and relies on `db.timestamps` for the auto-injected columns (previously only a list with an explicitly declared `createdAt`/`updatedAt` field could be indexed on it). + +- [#1003](https://github.com/OpenSaasAU/stack/pull/1003) [`9de43c8`](https://github.com/OpenSaasAU/stack/commit/9de43c80c8ef996dc6f08f68f7c1d8451aa0f10e) Thanks [@borisno2](https://github.com/borisno2)! - Add `context.withSession(session)` — a sibling to `sudo()` for the other axis. It derives a `StackContext` that reuses the receiver's already-resolved config, client (including a transaction client — a call inside `context.transaction()` stays in that transaction), and storage, but carries a substituted session, so access control and hooks run against the new session as normal. + + This closes a gap for callers that are legitimately authorised but arrive without the session a list `validate` hook expects — an unattended dispatcher, a service principal, or a job runner: + + ```typescript + // Runs with the job owner's session so hooks see the right identity, while + // still going through the normal access control checks for that session. + const asOwner = context.withSession(job.ownerSession) + await asOwner.db.task.update({ where: { id: job.taskId }, data: { status: 'done' } }) + + // Drop to anonymous + const anonymous = context.withSession(null) + ``` + + `withSession` grants no authority of its own — the derived context can do exactly what any context built with that session directly could do. It's orthogonal to `sudo()`: `context.withSession(s).sudo()` and `context.sudo().withSession(s)` are equivalent, since `withSession` preserves the receiver's sudo state instead of resetting it. + + The generated `Context` type (`.opensaas/types.ts`) now includes `withSession: (session: TSession | null) => Context` alongside `sudo`, so the method is typed in application code — run `opensaas generate` (or `pnpm generate`) to pick it up. + +### Patch Changes + +- [#967](https://github.com/OpenSaasAU/stack/pull/967) [`ca20d45`](https://github.com/OpenSaasAU/stack/commit/ca20d458e969f964bb792331c9ec181314093431) Thanks [@borisno2](https://github.com/borisno2)! - Clean up stale/restating comments in migration, MCP, and commands source per CLAUDE.md's Comments rule. No behavior changes. + +- [#968](https://github.com/OpenSaasAU/stack/pull/968) [`026489c`](https://github.com/OpenSaasAU/stack/commit/026489c8fe34aaf1a29a93a882d4a57cca42bce0) Thanks [@borisno2](https://github.com/borisno2)! - Clean up restating/duplicated comments in `packages/cli/src/generator/` per the CLAUDE.md Comments rule. No behavior change. +- Updated dependencies [[`8e6707a`](https://github.com/OpenSaasAU/stack/commit/8e6707adcca9d7e062bc1747ec79a29082c09ef9), [`afd1a60`](https://github.com/OpenSaasAU/stack/commit/afd1a60a6ddaa558bf14887e45fa1c007e6669b0), [`b30fa61`](https://github.com/OpenSaasAU/stack/commit/b30fa6135a6acca8c9be99fbdf5ffa7faab1959f), [`16da817`](https://github.com/OpenSaasAU/stack/commit/16da8176114826d18d6747d27abedf75de6c3262), [`51ae299`](https://github.com/OpenSaasAU/stack/commit/51ae299b7624f97e890f85b3075c62d8e114cec2), [`f85c7d1`](https://github.com/OpenSaasAU/stack/commit/f85c7d1b92e76d5e8ae090f93c0ff94e0d6c36c1), [`0f2e12a`](https://github.com/OpenSaasAU/stack/commit/0f2e12a69710e759d8749b8536fd5b31836226e9), [`05c747a`](https://github.com/OpenSaasAU/stack/commit/05c747a18284ac769860f751a660b72591570571), [`0b5b51e`](https://github.com/OpenSaasAU/stack/commit/0b5b51e52787ea9e945206a109a7a56dc38e78e5), [`4ce64b4`](https://github.com/OpenSaasAU/stack/commit/4ce64b4f9868eca0f34cc0676e46440b3d8f16ce), [`48d2762`](https://github.com/OpenSaasAU/stack/commit/48d27626dfb636c481301116e46c826ef3156124), [`9de43c8`](https://github.com/OpenSaasAU/stack/commit/9de43c80c8ef996dc6f08f68f7c1d8451aa0f10e), [`52dfdd2`](https://github.com/OpenSaasAU/stack/commit/52dfdd2c051aa2f4b4cbd96a459213c34c3bf85c)]: + - @opensaas/stack-core@0.40.0 + ## 0.39.2 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 8895dd4e..e06f5b31 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@opensaas/stack-cli", - "version": "0.39.2", + "version": "0.40.0", "description": "CLI tools for OpenSaas Stack", "type": "module", "main": "./dist/index.js", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index f2a4e2da..15889d31 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,155 @@ # @opensaas/stack-core +## 0.40.0 + +### Minor Changes + +- [#1020](https://github.com/OpenSaasAU/stack/pull/1020) [`8e6707a`](https://github.com/OpenSaasAU/stack/commit/8e6707adcca9d7e062bc1747ec79a29082c09ef9) Thanks [@borisno2](https://github.com/borisno2)! - Add `ui.listView.defaultColumn` to field config — a declared, presentation-only flag (default `true`) controlling whether a field belongs in a list/related-list table's default column set. Naming a field explicitly in `ui.listView.initialColumns` or a relationship's `ui.itemView.columns` always shows it regardless of this flag. + + ```typescript + fields: { + internalScore: integer({ ui: { listView: { defaultColumn: false } } }), + } + ``` + + `password()` now sets this flag to `false` by default instead of the admin UI matching on field type — a password field can opt back into default columns with `ui: { listView: { defaultColumn: true } } }`. + +- [#1011](https://github.com/OpenSaasAU/stack/pull/1011) [`afd1a60`](https://github.com/OpenSaasAU/stack/commit/afd1a60a6ddaa558bf14887e45fa1c007e6669b0) Thanks [@borisno2](https://github.com/borisno2)! - `OperationAccess.create` now throws `InvalidCreateAccessResultError` when the rule returns anything other than `true`/`false` — most notably a Prisma filter, which previously fell through the `create` access check unrecognised and was silently treated as a full allow (both the top-level write pipeline and nested-create paths were affected). + + Create has no existing row to scope a filter against, so a filter can no longer be honoured here: + + ```typescript + // Before: type-checked, read as row-scoped, actually allowed everyone + create: ({ session }) => ({ ownerId: { equals: session.userId } }) + + // Now throws InvalidCreateAccessResultError. Scope ownership in a hook instead: + hooks: { + resolveInput: async ({ resolvedData, context, operation }) => { + if (operation === 'create') { + return { ...resolvedData, ownerId: context.session?.userId } + } + return resolvedData + }, + }, + access: { + operation: { + create: ({ session }) => !!session, // boolean only + }, + }, + ``` + + `create: () => false` still denies via Silent failure as before; only a non-boolean result now throws. + +- [#984](https://github.com/OpenSaasAU/stack/pull/984) [`51ae299`](https://github.com/OpenSaasAU/stack/commit/51ae299b7624f97e890f85b3075c62d8e114cec2) Thanks [@borisno2](https://github.com/borisno2)! - Extend `isIndexed` to `integer`, `timestamp`, and `select`, matching `text`, `decimal`, `bigInt`, `calendarDay`, and `relationship`. + + ```typescript + fields: { + rank: integer({ isIndexed: true }), + publishedAt: timestamp({ isIndexed: true }), + status: select({ + options: [{ label: 'Draft', value: 'draft' }], + isIndexed: 'unique', + }), + } + ``` + + `isIndexed: true` generates a block-level `@@index([field])`; `isIndexed: 'unique'` generates an inline `@unique`. `select` supports both under the default string column and a native-enum column (`db: { type: 'enum' }`). No field type's default indexing behavior changes — an existing config generates the same schema as before. + +- [#1007](https://github.com/OpenSaasAU/stack/pull/1007) [`4ce64b4`](https://github.com/OpenSaasAU/stack/commit/4ce64b4f9868eca0f34cc0676e46440b3d8f16ce) Thanks [@borisno2](https://github.com/borisno2)! - The derived MCP `query` tool now accepts an optional `fields` projection — the wire form of the runtime's existing fragment field selection — so an assistant can select scalars and nested relation fields (with `where`/`orderBy`/`take`/`skip`, and a to-many's row count) in a single call instead of following a foreign key with a second one. Omitting `fields` is unchanged, a bare read exactly as before. + + ```json + { + "name": "list_post_query", + "arguments": { + "fields": { + "title": true, + "author": { "fields": { "name": true } }, + "comments": { + "fields": { "text": true }, + "where": { "approved": { "equals": true } }, + "take": 5, + "count": true + } + } + } + } + ``` + + The generated tool schema enumerates two levels of each list's own fields and relations, per session, and refuses (as an `isError` tool result, never a protocol error) anything it doesn't advertise — an unknown field, or a relation named a third level deep. See the ADR (`docs/adr/0033-mcp-tools-advertise-a-bounded-projection.md`) for the full design. + + **Behaviour change:** `tools/list` is now evaluated per session. A list whose operation-level `query` access denies the session outright no longer appears in the tool listing at all — none of its four CRUD tools, and no relation entry elsewhere pointing at it. Previously every list's tools were listed regardless of session. + +- [#1002](https://github.com/OpenSaasAU/stack/pull/1002) [`48d2762`](https://github.com/OpenSaasAU/stack/commit/48d27626dfb636c481301116e46c826ef3156124) Thanks [@borisno2](https://github.com/borisno2)! - Fix admin UI URL round-trip for a list keyed with anything other than strict PascalCase (issue [#991](https://github.com/OpenSaasAU/stack/issues/991)). `getListKeyFromUrl` reconstructs a list key by string transformation, which is lossy for a non-PascalCase key — a real example is a better-auth plugin's derived list (e.g. `oauthApplication`, from the `mcp` plugin's OAuth tables). Such a list appeared in navigation but its own link resolved to a key that did not exist in `config.lists`, rendering "List not found". + + `@opensaas/stack-core` adds `resolveListKeyFromUrl(urlSegment, listKeys)` alongside the existing `getListKeyFromUrl`, which is unchanged and still exported. The new resolver matches a URL segment against the config's actual list keys via `getUrlKey` — the same helper that builds the URL — instead of reconstructing one, so route lookup and URL generation cannot drift apart. It returns `undefined` for a segment matching no list (so callers keep rendering their existing "not found" state), and throws if two distinct list keys would produce the same URL segment. + + ```typescript + import { resolveListKeyFromUrl } from '@opensaas/stack-core' + + resolveListKeyFromUrl('oauth-application', Object.keys(config.lists)) // 'oauthApplication' + resolveListKeyFromUrl('does-not-exist', Object.keys(config.lists)) // undefined + ``` + + `@opensaas/stack-ui`'s `AdminUI` now uses `resolveListKeyFromUrl` for its route resolution, fixing the broken link for any such list. + + `@opensaas/stack-auth`'s `convertBetterAuthSchema` now PascalCases a better-auth plugin's camelCase `modelName` when deriving a list key (`oauthApplication` → `OauthApplication`, `rateLimit` → `RateLimit`), matching the repo's PascalCase list-key convention and fixing the same round-trip bug at the source for these lists. + + **Schema-affecting for `@opensaas/stack-auth` users with a better-auth plugin that declares extra tables** (e.g. `mcp`'s OAuth tables, or `rateLimit.storage: 'database'` with no `modelName` remap configured): the generated Prisma **model name** changes to match the new PascalCase list key. The physical **table name** does not change — the previous camelCase name is preserved via `db.map` (`@@map`) — so `prisma db push` / `prisma migrate dev` sees a model rename, not a table rename, and `context.db.oauthApplication` (the camelCase db accessor) keeps working unchanged. Regenerate (`pnpm generate`) and re-run your migration/push step after upgrading. + +- [#1003](https://github.com/OpenSaasAU/stack/pull/1003) [`9de43c8`](https://github.com/OpenSaasAU/stack/commit/9de43c80c8ef996dc6f08f68f7c1d8451aa0f10e) Thanks [@borisno2](https://github.com/borisno2)! - Add `context.withSession(session)` — a sibling to `sudo()` for the other axis. It derives a `StackContext` that reuses the receiver's already-resolved config, client (including a transaction client — a call inside `context.transaction()` stays in that transaction), and storage, but carries a substituted session, so access control and hooks run against the new session as normal. + + This closes a gap for callers that are legitimately authorised but arrive without the session a list `validate` hook expects — an unattended dispatcher, a service principal, or a job runner: + + ```typescript + // Runs with the job owner's session so hooks see the right identity, while + // still going through the normal access control checks for that session. + const asOwner = context.withSession(job.ownerSession) + await asOwner.db.task.update({ where: { id: job.taskId }, data: { status: 'done' } }) + + // Drop to anonymous + const anonymous = context.withSession(null) + ``` + + `withSession` grants no authority of its own — the derived context can do exactly what any context built with that session directly could do. It's orthogonal to `sudo()`: `context.withSession(s).sudo()` and `context.sudo().withSession(s)` are equivalent, since `withSession` preserves the receiver's sudo state instead of resetting it. + + The generated `Context` type (`.opensaas/types.ts`) now includes `withSession: (session: TSession | null) => Context` alongside `sudo`, so the method is typed in application code — run `opensaas generate` (or `pnpm generate`) to pick it up. + +### Patch Changes + +- [#1017](https://github.com/OpenSaasAU/stack/pull/1017) [`b30fa61`](https://github.com/OpenSaasAU/stack/commit/b30fa6135a6acca8c9be99fbdf5ffa7faab1959f) Thanks [@{](https://github.com/{)! - Let an application declare model-level indexes (`db.indexes`) on the derived auth lists (`User`/`Session`/`Account`/`Verification`/`RateLimit`). + + Each per-model block in `authPlugin()` now accepts `indexes`, in the same shape as a list's own `db.indexes`: + + ```typescript + authPlugin({ + // Adopt a live constraint's real name instead of Prisma's derived one. + indexes: [{ fields: ['email'], unique: true, name: 'user_email_key' }] }, + session: { indexes: [{ fields: ['token'], unique: true, name: 'session_token_key' }] }, + // Extend a derived column into a composite index. + verification: { + indexes: [{ fields: ['identifier', { field: 'createdAt', sort: 'desc' }] }], + }, + }) + ``` + + An entry covering a column the stack already derives an index for (e.g. `User.email`) suppresses that derived index for that column and emits only the app's entry, rather than erroring — the application's declaration wins (ADR-0035). Suppression is per-column: every other derived index on the model is unaffected. + + This also fixes a related generator gap: a list's `db.indexes` can now reference `createdAt`/`updatedAt` even when the list has no explicit field for them and relies on `db.timestamps` for the auto-injected columns (previously only a list with an explicitly declared `createdAt`/`updatedAt` field could be indexed on it). + +- [#983](https://github.com/OpenSaasAU/stack/pull/983) [`16da817`](https://github.com/OpenSaasAU/stack/commit/16da8176114826d18d6747d27abedf75de6c3262) Thanks [@borisno2](https://github.com/borisno2)! - Fix `HashedPassword.toJSON()` returning the raw bcrypt hash, so `JSON.stringify` of a row (e.g. a server→client prop, `Response.json()`, an MCP tool response) no longer leaks the stored hash for a `password()` field. + + `toJSON()` now returns `{ isSet: boolean }`, matching the redaction the admin UI already applies via `valueForClientSerialization`. `toString()`, `valueOf()`, `[Symbol.toPrimitive]`, and `==` comparison against the hash are unchanged. If you parse `JSON.stringify`'d rows and read the password field as a string, update that code to read `.isSet` instead — this is a visible output/type change on `HashedPassword.toJSON()`, though the field's read access remains the application's to configure (unchanged). + +- [#999](https://github.com/OpenSaasAU/stack/pull/999) [`f85c7d1`](https://github.com/OpenSaasAU/stack/commit/f85c7d1b92e76d5e8ae090f93c0ff94e0d6c36c1) Thanks [@borisno2](https://github.com/borisno2)! - MCP derived CRUD tool and custom tool failures (access denial, thrown engine/database errors, input schema validation) now return a successful JSON-RPC response with `result.isError: true` instead of a JSON-RPC `error` object, so the calling model can see and recover from them. Genuine protocol failures (unknown method, malformed request, unknown tool name) are unchanged. Note: the wire shape of tool failures changes — a consumer asserting on the old `error` shape will need to update. + +- [#1006](https://github.com/OpenSaasAU/stack/pull/1006) [`0f2e12a`](https://github.com/OpenSaasAU/stack/commit/0f2e12a69710e759d8749b8536fd5b31836226e9) Thanks [@borisno2](https://github.com/borisno2)! - `relationship({ ref: 'ListName' })` list-only refs now accept `db.foreignKey: { map: '...' }` to rename the foreign key column. The boolean form (`true`/`false`) is still rejected there since ownership is implicit on a list-only ref. + +- [#1004](https://github.com/OpenSaasAU/stack/pull/1004) [`05c747a`](https://github.com/OpenSaasAU/stack/commit/05c747a18284ac769860f751a660b72591570571) Thanks [@borisno2](https://github.com/borisno2)! - Fix a nested create/update/delete through a list-only ref's synthetic reverse relation (`from__`) silently bypassing the target list's hooks and validation. It now runs the same pipeline a declared relationship field's nested write gets. Under `sudo()`, an undeclared key that isn't a synthetic reverse relation is now refused rather than passed through unchecked. + +- [#1000](https://github.com/OpenSaasAU/stack/pull/1000) [`0b5b51e`](https://github.com/OpenSaasAU/stack/commit/0b5b51e52787ea9e945206a109a7a56dc38e78e5) Thanks [@borisno2](https://github.com/borisno2)! - Fix `P2002` unique-constraint errors losing per-field detail under Prisma 7 driver adapters (`@prisma/adapter-pg`, PGlite), where `meta.target` is left empty. The error handler now recovers the violated columns and constraint name from the adapter's error shape, and a new `uniqueConstraintOf(error)` helper exposes this to callers of `context.db.*` directly. Unique-violation messages under driver adapters change from the generic fallback back to field-specific text. + +- [#1001](https://github.com/OpenSaasAU/stack/pull/1001) [`52dfdd2`](https://github.com/OpenSaasAU/stack/commit/52dfdd2c051aa2f4b4cbd96a459213c34c3bf85c) Thanks [@borisno2](https://github.com/borisno2)! - Fix `include` on a to-one relationship throwing `PrismaClientValidationError` when the related list's `query` access resolves to a filter (Prisma only accepts a nested `where` on a to-many include). The relation is now fetched and access-scoped via a batched existence check instead, returning `null` for an excluded related row rather than throwing — a caller relying on the previous exception, or whose types assumed a non-null relation, should re-check nullability. + ## 0.39.2 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index 300626bc..1e8e4786 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@opensaas/stack-core", - "version": "0.39.2", + "version": "0.40.0", "description": "Core stack for OpenSaas - schema definition, access control, and runtime utilities", "type": "module", "main": "./dist/index.js", diff --git a/packages/create-opensaas-app/CHANGELOG.md b/packages/create-opensaas-app/CHANGELOG.md index 6d211327..8ab45e39 100644 --- a/packages/create-opensaas-app/CHANGELOG.md +++ b/packages/create-opensaas-app/CHANGELOG.md @@ -1,5 +1,11 @@ # create-opensaas-app +## 0.3.4 + +### Patch Changes + +- [#973](https://github.com/OpenSaasAU/stack/pull/973) [`8f76533`](https://github.com/OpenSaasAU/stack/commit/8f765333e3067c741c69f535927cc82115c60ed1) Thanks [@borisno2](https://github.com/borisno2)! - Comment cleanup only, no behavior change: removed restating/narration comments, kept TSDoc on public config options and field builders, and kept external API/behavior constraint notes (Prisma, S3, Vercel Blob, Keystone parity, Next.js SSR, Zod). + ## 0.3.3 ### Patch Changes diff --git a/packages/create-opensaas-app/package.json b/packages/create-opensaas-app/package.json index ca706dd8..d21ad262 100644 --- a/packages/create-opensaas-app/package.json +++ b/packages/create-opensaas-app/package.json @@ -1,6 +1,6 @@ { "name": "create-opensaas-app", - "version": "0.3.3", + "version": "0.3.4", "description": "Create a new OpenSaas Stack application", "type": "module", "bin": { diff --git a/packages/rag/CHANGELOG.md b/packages/rag/CHANGELOG.md index fae004e1..38b68a82 100644 --- a/packages/rag/CHANGELOG.md +++ b/packages/rag/CHANGELOG.md @@ -1,5 +1,11 @@ # @opensaas/stack-rag +## 0.40.0 + +### Patch Changes + +- [#970](https://github.com/OpenSaasAU/stack/pull/970) [`fa1819b`](https://github.com/OpenSaasAU/stack/commit/fa1819b0a71b5f21175e6e87d64dd6b398255a8a) Thanks [@borisno2](https://github.com/borisno2)! - Clean up comments in `packages/rag/src` per the CLAUDE.md Comments rule — removed restatement and stale narration, kept public-API TSDoc, external-constraint notes, and genuine footgun warnings. No behavior changes. + ## 0.39.2 ## 0.39.1 diff --git a/packages/rag/package.json b/packages/rag/package.json index ea31b1dd..5748973b 100644 --- a/packages/rag/package.json +++ b/packages/rag/package.json @@ -1,6 +1,6 @@ { "name": "@opensaas/stack-rag", - "version": "0.39.2", + "version": "0.40.0", "description": "RAG and AI embeddings integration for OpenSaas Stack", "type": "module", "main": "./dist/index.js", diff --git a/packages/storage-s3/CHANGELOG.md b/packages/storage-s3/CHANGELOG.md index 3070e332..b31e7a12 100644 --- a/packages/storage-s3/CHANGELOG.md +++ b/packages/storage-s3/CHANGELOG.md @@ -1,5 +1,11 @@ # @opensaas/stack-storage-s3 +## 0.40.0 + +### Patch Changes + +- [#973](https://github.com/OpenSaasAU/stack/pull/973) [`8f76533`](https://github.com/OpenSaasAU/stack/commit/8f765333e3067c741c69f535927cc82115c60ed1) Thanks [@borisno2](https://github.com/borisno2)! - Comment cleanup only, no behavior change: removed restating/narration comments, kept TSDoc on public config options and field builders, and kept external API/behavior constraint notes (Prisma, S3, Vercel Blob, Keystone parity, Next.js SSR, Zod). + ## 0.39.2 ## 0.39.1 diff --git a/packages/storage-s3/package.json b/packages/storage-s3/package.json index 4e21a6e9..72b11519 100644 --- a/packages/storage-s3/package.json +++ b/packages/storage-s3/package.json @@ -1,6 +1,6 @@ { "name": "@opensaas/stack-storage-s3", - "version": "0.39.2", + "version": "0.40.0", "description": "AWS S3 storage provider for OpenSaas Stack file uploads", "type": "module", "exports": { diff --git a/packages/storage-vercel/CHANGELOG.md b/packages/storage-vercel/CHANGELOG.md index f93d2d24..68c02788 100644 --- a/packages/storage-vercel/CHANGELOG.md +++ b/packages/storage-vercel/CHANGELOG.md @@ -1,5 +1,11 @@ # @opensaas/stack-storage-vercel +## 0.40.0 + +### Patch Changes + +- [#973](https://github.com/OpenSaasAU/stack/pull/973) [`8f76533`](https://github.com/OpenSaasAU/stack/commit/8f765333e3067c741c69f535927cc82115c60ed1) Thanks [@borisno2](https://github.com/borisno2)! - Comment cleanup only, no behavior change: removed restating/narration comments, kept TSDoc on public config options and field builders, and kept external API/behavior constraint notes (Prisma, S3, Vercel Blob, Keystone parity, Next.js SSR, Zod). + ## 0.39.2 ## 0.39.1 diff --git a/packages/storage-vercel/package.json b/packages/storage-vercel/package.json index adad679c..73f558df 100644 --- a/packages/storage-vercel/package.json +++ b/packages/storage-vercel/package.json @@ -1,6 +1,6 @@ { "name": "@opensaas/stack-storage-vercel", - "version": "0.39.2", + "version": "0.40.0", "description": "Vercel Blob storage provider for OpenSaas Stack file uploads", "type": "module", "exports": { diff --git a/packages/storage/CHANGELOG.md b/packages/storage/CHANGELOG.md index 5e95c582..a3704b2f 100644 --- a/packages/storage/CHANGELOG.md +++ b/packages/storage/CHANGELOG.md @@ -1,5 +1,11 @@ # @opensaas/stack-storage +## 0.40.0 + +### Patch Changes + +- [#973](https://github.com/OpenSaasAU/stack/pull/973) [`8f76533`](https://github.com/OpenSaasAU/stack/commit/8f765333e3067c741c69f535927cc82115c60ed1) Thanks [@borisno2](https://github.com/borisno2)! - Comment cleanup only, no behavior change: removed restating/narration comments, kept TSDoc on public config options and field builders, and kept external API/behavior constraint notes (Prisma, S3, Vercel Blob, Keystone parity, Next.js SSR, Zod). + ## 0.39.2 ## 0.39.1 diff --git a/packages/storage/package.json b/packages/storage/package.json index f4f1db43..a770accc 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -1,6 +1,6 @@ { "name": "@opensaas/stack-storage", - "version": "0.39.2", + "version": "0.40.0", "description": "File and image upload field types with pluggable storage providers for OpenSaas Stack", "type": "module", "exports": { diff --git a/packages/tiptap/CHANGELOG.md b/packages/tiptap/CHANGELOG.md index 87e64793..58d72af3 100644 --- a/packages/tiptap/CHANGELOG.md +++ b/packages/tiptap/CHANGELOG.md @@ -1,5 +1,11 @@ # @opensaas/stack-tiptap +## 0.40.0 + +### Patch Changes + +- [#973](https://github.com/OpenSaasAU/stack/pull/973) [`8f76533`](https://github.com/OpenSaasAU/stack/commit/8f765333e3067c741c69f535927cc82115c60ed1) Thanks [@borisno2](https://github.com/borisno2)! - Comment cleanup only, no behavior change: removed restating/narration comments, kept TSDoc on public config options and field builders, and kept external API/behavior constraint notes (Prisma, S3, Vercel Blob, Keystone parity, Next.js SSR, Zod). + ## 0.39.2 ## 0.39.1 diff --git a/packages/tiptap/package.json b/packages/tiptap/package.json index 1248655e..468483d4 100644 --- a/packages/tiptap/package.json +++ b/packages/tiptap/package.json @@ -1,6 +1,6 @@ { "name": "@opensaas/stack-tiptap", - "version": "0.39.2", + "version": "0.40.0", "description": "Tiptap rich text editor integration for OpenSaas Stack", "type": "module", "main": "./dist/index.js", diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index c5e2852e..b30825ca 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -1,5 +1,42 @@ # @opensaas/stack-ui +## 0.40.0 + +### Minor Changes + +- [#1020](https://github.com/OpenSaasAU/stack/pull/1020) [`8e6707a`](https://github.com/OpenSaasAU/stack/commit/8e6707adcca9d7e062bc1747ec79a29082c09ef9) Thanks [@borisno2](https://github.com/borisno2)! - Replace the admin UI's hardcoded `password`/`createdAt`/`updatedAt` default-column exclusion with curation driven by each field's declared `ui.listView.defaultColumn` (issue [#1018](https://github.com/OpenSaasAU/stack/issues/1018)). The list view, related-list tables, and the `ListTable` standalone component now share one implementation (`computeDefaultColumns`) instead of three independent name/type-matching copies, and a list's structural `createdAt`/`updatedAt` timestamp columns are identified from its own timestamp configuration rather than by name. + + **Behavior change:** an application field literally named (or typed) `password`, `createdAt`, or `updatedAt` that does NOT declare `ui.listView.defaultColumn: false` — and isn't your list's actual auto-timestamp column — is no longer hidden from default admin columns purely by name/type match. Real password fields (built with `password()`) and real system timestamps are unaffected; they're excluded via the declared flag instead. + + `ListTable` gains an optional `fields?: Record` prop to supply this curation metadata; without it (as before), every `fieldTypes` column shows absent an explicit `columns` list. + +- [#1016](https://github.com/OpenSaasAU/stack/pull/1016) [`98465a5`](https://github.com/OpenSaasAU/stack/commit/98465a553178d8ff8b6dbb0c7fe413965646debf) Thanks [@borisno2](https://github.com/borisno2)! - Password columns are now identified by field type, not field name, across the list view, standalone `ListTable`, and item-view Relationship tables. A field declared `secret: password()` is now excluded from default columns even though it isn't named `password`; a field merely named `password` (e.g. `password: text()`) is no longer excluded unless it is actually a `password()` field. + + A `password` Cell is now registered in the cell registry, so a password-typed column shown via an explicit `columns` prop renders a fixed `••••••••` mask instead of the raw value. + + BREAKING (shipped as minor — pre-1.0 packages ship breaking changes as minor): the unused `getFieldDisplayValue` export has been removed from `@opensaas/stack-ui`. It had no callers in the rendering path — Cells render each field type directly — so nothing in this package depended on it; a consumer importing it directly should port to a project-local formatter. + +### Patch Changes + +- [#971](https://github.com/OpenSaasAU/stack/pull/971) [`dfdca11`](https://github.com/OpenSaasAU/stack/commit/dfdca11490a29490a6dc4961a07f8e75675c75a7) Thanks [@borisno2](https://github.com/borisno2)! - Remove comments that restated the line below them or duplicated rationale already stated elsewhere in `packages/ui/src`. No behavior changes. + +- [#1002](https://github.com/OpenSaasAU/stack/pull/1002) [`48d2762`](https://github.com/OpenSaasAU/stack/commit/48d27626dfb636c481301116e46c826ef3156124) Thanks [@borisno2](https://github.com/borisno2)! - Fix admin UI URL round-trip for a list keyed with anything other than strict PascalCase (issue [#991](https://github.com/OpenSaasAU/stack/issues/991)). `getListKeyFromUrl` reconstructs a list key by string transformation, which is lossy for a non-PascalCase key — a real example is a better-auth plugin's derived list (e.g. `oauthApplication`, from the `mcp` plugin's OAuth tables). Such a list appeared in navigation but its own link resolved to a key that did not exist in `config.lists`, rendering "List not found". + + `@opensaas/stack-core` adds `resolveListKeyFromUrl(urlSegment, listKeys)` alongside the existing `getListKeyFromUrl`, which is unchanged and still exported. The new resolver matches a URL segment against the config's actual list keys via `getUrlKey` — the same helper that builds the URL — instead of reconstructing one, so route lookup and URL generation cannot drift apart. It returns `undefined` for a segment matching no list (so callers keep rendering their existing "not found" state), and throws if two distinct list keys would produce the same URL segment. + + ```typescript + import { resolveListKeyFromUrl } from '@opensaas/stack-core' + + resolveListKeyFromUrl('oauth-application', Object.keys(config.lists)) // 'oauthApplication' + resolveListKeyFromUrl('does-not-exist', Object.keys(config.lists)) // undefined + ``` + + `@opensaas/stack-ui`'s `AdminUI` now uses `resolveListKeyFromUrl` for its route resolution, fixing the broken link for any such list. + + `@opensaas/stack-auth`'s `convertBetterAuthSchema` now PascalCases a better-auth plugin's camelCase `modelName` when deriving a list key (`oauthApplication` → `OauthApplication`, `rateLimit` → `RateLimit`), matching the repo's PascalCase list-key convention and fixing the same round-trip bug at the source for these lists. + + **Schema-affecting for `@opensaas/stack-auth` users with a better-auth plugin that declares extra tables** (e.g. `mcp`'s OAuth tables, or `rateLimit.storage: 'database'` with no `modelName` remap configured): the generated Prisma **model name** changes to match the new PascalCase list key. The physical **table name** does not change — the previous camelCase name is preserved via `db.map` (`@@map`) — so `prisma db push` / `prisma migrate dev` sees a model rename, not a table rename, and `context.db.oauthApplication` (the camelCase db accessor) keeps working unchanged. Regenerate (`pnpm generate`) and re-run your migration/push step after upgrading. + ## 0.39.2 ## 0.39.1 diff --git a/packages/ui/package.json b/packages/ui/package.json index 2820db1a..5bfe1301 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opensaas/stack-ui", - "version": "0.39.2", + "version": "0.40.0", "description": "Composable React UI components for OpenSaas Stack", "type": "module", "main": "./dist/index.js",