Skip to content

Version Packages - #969

Merged
borisno2 merged 1 commit into
mainfrom
changeset-release/main
Aug 22, 2026
Merged

Version Packages#969
borisno2 merged 1 commit into
mainfrom
changeset-release/main

Conversation

@github-actions

@github-actions github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

This PR was opened by the Changesets release GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.

Releases

@opensaas/stack-auth@0.40.0

Minor Changes

  • #1015 72c4ba3 Thanks @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'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):

    -- 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:

    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 77b7314 Thanks @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:

    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 b30fa61 Thanks @{! - 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:

    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 b67fdd1 Thanks @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.clientIdoauthApplication.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 49687ea Thanks @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 8e6707a Thanks @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 ed6ffcd Thanks @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/#937/#921/#986. Generated schema output is unchanged for existing projects — no migration needed.

  • #972 08c3787 Thanks @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 cfd366c Thanks @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 37d7905 Thanks @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 9cc6f8d Thanks @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 48d2762 Thanks @borisno2! - 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.

    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 (oauthApplicationOauthApplication, rateLimitRateLimit), 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.

@opensaas/stack-cli@0.40.0

Minor Changes

  • #1017 b30fa61 Thanks @{! - 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:

    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 9de43c8 Thanks @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:

    // 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<TSession> type (.opensaas/types.ts) now includes withSession: (session: TSession | null) => Context<TSession> alongside sudo, so the method is typed in application code — run opensaas generate (or pnpm generate) to pick it up.

Patch Changes

@opensaas/stack-core@0.40.0

Minor Changes

  • #1020 8e6707a Thanks @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.

    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 afd1a60 Thanks @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:

    // 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 51ae299 Thanks @borisno2! - Extend isIndexed to integer, timestamp, and select, matching text, decimal, bigInt, calendarDay, and relationship.

    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 4ce64b4 Thanks @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.

    {
      "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 48d2762 Thanks @borisno2! - 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.

    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 (oauthApplicationOauthApplication, rateLimitRateLimit), 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 9de43c8 Thanks @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:

    // 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<TSession> type (.opensaas/types.ts) now includes withSession: (session: TSession | null) => Context<TSession> alongside sudo, so the method is typed in application code — run opensaas generate (or pnpm generate) to pick it up.

Patch Changes

  • #1017 b30fa61 Thanks @{! - 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:

    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 16da817 Thanks @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 f85c7d1 Thanks @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 0f2e12a Thanks @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 05c747a Thanks @borisno2! - Fix a nested create/update/delete through a list-only ref's synthetic reverse relation (from_<List>_<field>) 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 0b5b51e Thanks @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 52dfdd2 Thanks @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.

@opensaas/stack-ui@0.40.0

Minor Changes

  • #1020 8e6707a Thanks @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). 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<string, SerializableFieldConfig> prop to supply this curation metadata; without it (as before), every fieldTypes column shows absent an explicit columns list.

  • #1016 98465a5 Thanks @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 dfdca11 Thanks @borisno2! - Remove comments that restated the line below them or duplicated rationale already stated elsewhere in packages/ui/src. No behavior changes.

  • #1002 48d2762 Thanks @borisno2! - 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.

    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 (oauthApplicationOauthApplication, rateLimitRateLimit), 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.

create-opensaas-app@0.3.4

Patch Changes

  • #973 8f76533 Thanks @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).

@opensaas/stack-rag@0.40.0

Patch Changes

  • #970 fa1819b Thanks @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.

@opensaas/stack-storage@0.40.0

Patch Changes

  • #973 8f76533 Thanks @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).

@opensaas/stack-storage-s3@0.40.0

Patch Changes

  • #973 8f76533 Thanks @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).

@opensaas/stack-storage-vercel@0.40.0

Patch Changes

  • #973 8f76533 Thanks @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).

@opensaas/stack-tiptap@0.40.0

Patch Changes

  • #973 8f76533 Thanks @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).

@vercel

vercel Bot commented Aug 19, 2026

Copy link
Copy Markdown

Deployment failed for project stack-docs with the following error:

Resource is limited - try again in 24 hours (more than 100, code: "api-deployments-free-per-day").

Learn More: https://vercel.com/open-saas?upgradeToPro=build-rate-limit

@github-actions
github-actions Bot force-pushed the changeset-release/main branch 5 times, most recently from ffbbc6e to 64d74d2 Compare August 19, 2026 19:57
@vercel

vercel Bot commented Aug 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
stack-docs Ready Ready Preview Aug 22, 2026 3:10am

@github-actions
github-actions Bot force-pushed the changeset-release/main branch 11 times, most recently from ac6e315 to 5895043 Compare August 22, 2026 10:34
@github-actions
github-actions Bot force-pushed the changeset-release/main branch from 5895043 to c7a5689 Compare August 22, 2026 11:08
@borisno2
borisno2 enabled auto-merge (squash) August 22, 2026 11:13
@borisno2
borisno2 merged commit 42fc19b into main Aug 22, 2026
4 of 5 checks passed
@borisno2
borisno2 deleted the changeset-release/main branch August 22, 2026 11:19
@github-actions

Copy link
Copy Markdown
Contributor Author

Coverage Report for Core Package Coverage (./packages/core)

Status Category Percentage Covered / Total
🟢 Lines 94.05% (🎯 65%) 1631 / 1734
🟢 Statements 92.32% (🎯 65%) 1768 / 1915
🟢 Functions 97.41% (🎯 62%) 264 / 271
🟢 Branches 86.18% (🎯 50%) 1279 / 1484
File CoverageNo changed files found.
Generated in workflow #1798 for commit c7a5689 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor Author

Coverage Report for UI Package Coverage (./packages/ui)

Status Category Percentage Covered / Total
🔵 Lines 78.45% 244 / 311
🔵 Statements 77.95% 251 / 322
🔵 Functions 69.81% 74 / 106
🔵 Branches 66.94% 160 / 239
File CoverageNo changed files found.
Generated in workflow #1798 for commit c7a5689 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor Author

Coverage Report for CLI Package Coverage (./packages/cli)

Status Category Percentage Covered / Total
🔵 Lines 79% 1547 / 1958
🔵 Statements 78.81% 1615 / 2049
🔵 Functions 86.11% 217 / 252
🔵 Branches 69.66% 758 / 1088
File CoverageNo changed files found.
Generated in workflow #1798 for commit c7a5689 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor Author

Coverage Report for Auth Package Coverage (./packages/auth)

Status Category Percentage Covered / Total
🔵 Lines 99.49% 196 / 197
🔵 Statements 98.13% 211 / 215
🔵 Functions 100% 45 / 45
🔵 Branches 91.26% 188 / 206
File CoverageNo changed files found.
Generated in workflow #1798 for commit c7a5689 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor Author

Coverage Report for Storage Package Coverage (./packages/storage)

Status Category Percentage Covered / Total
🔵 Lines 78.57% 220 / 280
🔵 Statements 80.06% 245 / 306
🔵 Functions 86.07% 68 / 79
🔵 Branches 75.88% 214 / 282
File CoverageNo changed files found.
Generated in workflow #1798 for commit c7a5689 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor Author

Coverage Report for RAG Package Coverage (./packages/rag)

Status Category Percentage Covered / Total
🔵 Lines 47.97% 355 / 740
🔵 Statements 48.14% 377 / 783
🔵 Functions 54.26% 70 / 129
🔵 Branches 42.55% 180 / 423
File CoverageNo changed files found.
Generated in workflow #1798 for commit c7a5689 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor Author

Coverage Report for Storage S3 Package Coverage (./packages/storage-s3)

Status Category Percentage Covered / Total
🔵 Lines 100% 40 / 40
🔵 Statements 100% 40 / 40
🔵 Functions 100% 9 / 9
🔵 Branches 100% 19 / 19
File CoverageNo changed files found.
Generated in workflow #1798 for commit c7a5689 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor Author

Coverage Report for Storage Vercel Package Coverage (./packages/storage-vercel)

Status Category Percentage Covered / Total
🔵 Lines 100% 68 / 68
🔵 Statements 100% 71 / 71
🔵 Functions 100% 15 / 15
🔵 Branches 97.87% 46 / 47
File CoverageNo changed files found.
Generated in workflow #1798 for commit c7a5689 by the Vitest Coverage Report Action

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant