@wemogy/better-auth-cosmos 0.0.1 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wemogy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,45 +1,111 @@
1
1
  # @wemogy/better-auth-cosmos
2
2
 
3
- ## ⚠️ IMPORTANT NOTICE ⚠️
3
+ `@wemogy/better-auth-cosmos` is a production-ready adapter that connects **better-auth** to Azure Cosmos DB. Containers are derived from the better-auth schema at runtime, so the core models and any active plugin models are provisioned automatically with query-optimized partition keys.
4
4
 
5
- **This package is created solely for the purpose of setting up OIDC (OpenID Connect) trusted publishing with npm.**
5
+ Maintained by **wemogy**.
6
+
7
+ ## Contents
6
8
 
7
- This is **NOT** a functional package and contains **NO** code or functionality beyond the OIDC setup configuration.
9
+ - [Features](#features)
10
+ - [Requirements](#requirements)
11
+ - [Installation](#installation)
12
+ - [Quick Start](#quick-start)
13
+ - [Configuration](#configuration)
14
+ - [Troubleshooting](#troubleshooting)
15
+ - [Contributing](#contributing)
16
+ - [License](#license)
8
17
 
9
- ## Purpose
18
+ ## Features
10
19
 
11
- This package exists to:
12
- 1. Configure OIDC trusted publishing for the package name `@wemogy/better-auth-cosmos`
13
- 2. Enable secure, token-less publishing from CI/CD workflows
14
- 3. Establish provenance for packages published under this name
20
+ - Native integration with Azure Cosmos DB including automatic container naming
21
+ - Containers derived from the better-auth schema: active plugins (including third-party ones) get their containers created automatically
22
+ - Query-optimized partition keys per model (e.g. sessions by `/token`), overridable via `partitionKeys`
23
+ - Parameterized Cosmos SQL queries
24
+ - Optional debug logging to aid local development and acceptance testing
15
25
 
16
- ## What is OIDC Trusted Publishing?
26
+ ## Requirements
17
27
 
18
- OIDC trusted publishing allows package maintainers to publish packages directly from their CI/CD workflows without needing to manage npm access tokens. Instead, it uses OpenID Connect to establish trust between the CI/CD provider (like GitHub Actions) and npm.
28
+ - Node.js 20 or later
29
+ - Access to an Azure Cosmos DB account with permission to manage databases and containers
30
+ - better-auth ^1.3.33 or later
31
+ - An existing better-auth configuration
19
32
 
20
- ## Setup Instructions
33
+ ## Installation
21
34
 
22
- To properly configure OIDC trusted publishing for this package:
35
+ Install the package using your package manager of choice:
23
36
 
24
- 1. Go to [npmjs.com](https://www.npmjs.com/) and navigate to your package settings
25
- 2. Configure the trusted publisher (e.g., GitHub Actions)
26
- 3. Specify the repository and workflow that should be allowed to publish
27
- 4. Use the configured workflow to publish your actual package
37
+ ```bash
38
+ npm install @wemogy/better-auth-cosmos
39
+ # or
40
+ pnpm add @wemogy/better-auth-cosmos
41
+ # or
42
+ yarn add @wemogy/better-auth-cosmos
43
+ ```
28
44
 
29
- ## DO NOT USE THIS PACKAGE
45
+ ## Quick Start
30
46
 
31
- This package is a placeholder for OIDC configuration only. It:
32
- - Contains no executable code
33
- - Provides no functionality
34
- - Should not be installed as a dependency
35
- - Exists only for administrative purposes
47
+ ```typescript
48
+ import { betterAuth } from 'better-auth';
49
+ import { buildCosmosAdapter } from '@wemogy/better-auth-cosmos';
36
50
 
37
- ## More Information
51
+ export const createAuth = async () => {
52
+ const cosmosAdapter = await buildCosmosAdapter({
53
+ adapterId: 'cosmos',
54
+ adapterName: 'Cosmos Adapter',
55
+ dbCredentials: {
56
+ endpoint: process.env.COSMOS_DB_ENDPOINT!,
57
+ key: process.env.COSMOS_DB_KEY!,
58
+ },
59
+ dbName: process.env.COSMOS_DB_NAME ?? 'better-auth',
60
+ debugLogs: process.env.NODE_ENV !== 'production',
61
+ usePlural: false,
62
+ });
38
63
 
39
- For more details about npm's trusted publishing feature, see:
40
- - [npm Trusted Publishing Documentation](https://docs.npmjs.com/generating-provenance-statements)
41
- - [GitHub Actions OIDC Documentation](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)
64
+ return betterAuth({
65
+ database: cosmosAdapter,
66
+ });
67
+ };
68
+ ```
42
69
 
43
- ---
70
+ ### Environment variables
44
71
 
45
- **Maintained for OIDC setup purposes only**
72
+ ```bash
73
+ COSMOS_DB_ENDPOINT="https://<your-account>.documents.azure.com:443/"
74
+ COSMOS_DB_KEY="<secret>"
75
+ COSMOS_DB_NAME="better-auth"
76
+ ```
77
+
78
+ ## Configuration
79
+
80
+ `buildCosmosAdapter` accepts the following options:
81
+
82
+ | Option | Type | Default | Description |
83
+ | --------------- | ------------------------- | ------------ | ----------------------------------------------------------------------------------------------- |
84
+ | `adapterId` | `string` | **required** | Identifier used by better-auth to track the adapter instance. |
85
+ | `adapterName` | `string` | **required** | Friendly name used in logs and debugging. |
86
+ | `dbCredentials` | `CosmosClientOptions` | **required** | Credentials and configuration passed to the underlying `CosmosClient` (e.g. `endpoint`, `key`). |
87
+ | `dbName` | `string` | **required** | Name of the Cosmos DB database where containers will be created. |
88
+ | `debugLogs` | `DBAdapterDebugLogOption` | `false` | Enables verbose logging. Useful in development environments only. |
89
+ | `usePlural` | `boolean` | `false` | Whether to pluralize container names (e.g. `users` instead of `user`). |
90
+ | `partitionKeys` | `Record<string, string>` | None | Partition key path per model (e.g. `{ session: '/userId' }`), overriding the built-in defaults. |
91
+
92
+ The database is created on startup; containers are created lazily from the better-auth schema when the adapter initializes, so only the models required by your active plugins are provisioned. Known models get optimized partition keys (`session` → `/token`, `verification` → `/identifier`, `account`/`twoFactor` → `/userId`, organization-scoped models → `/organizationId`); unknown plugin models default to `/id`. Note that partition keys are immutable on existing containers.
93
+
94
+ ## Troubleshooting
95
+
96
+ - **Initialization fails with 401 Unauthorized:** Confirm that the configured `endpoint` and `key` belong to the same Cosmos DB account and that the key has data plane permissions.
97
+ - **Missing collections:** Ensure the configured database exists and the account has unlimited container throughput or the necessary RU/s reserved.
98
+ - **Debug logs missing:** Set `debugLogs: true` explicitly or run with `NODE_ENV=development`.
99
+
100
+ ## Contributing
101
+
102
+ We welcome improvements and bug fixes. To contribute:
103
+
104
+ 1. Fork the repository and create a branch.
105
+ 2. Install dependencies with `pnpm install` from the root.
106
+ 3. Run the tests via `npm test` in the `packages/better-auth-cosmos` directory. For integration tests, provide a Cosmos DB instance.
107
+ 4. Submit a pull request describing your changes.
108
+
109
+ ## License
110
+
111
+ UNLICENSED © wemogy
@@ -0,0 +1,39 @@
1
+ import { CosmosClientOptions, ItemDefinition, SqlQuerySpec } from '@azure/cosmos';
2
+ export interface ContainerSpec {
3
+ name: string;
4
+ /**
5
+ * Partition key path, e.g. '/id' or '/userId'. Defaults to '/id'.
6
+ * Must be a single leading-slash segment.
7
+ */
8
+ partitionKey?: string;
9
+ }
10
+ export declare class Cosmos {
11
+ private client;
12
+ private database;
13
+ /**
14
+ * Partition key field per (final, possibly pluralized) container name.
15
+ * Needed for deletes, where the partition key value must be supplied explicitly.
16
+ */
17
+ private partitionKeyFields;
18
+ private ensuredContainers;
19
+ private constructor();
20
+ static create(credentials: CosmosClientOptions, dbName?: string, containers?: ContainerSpec[], usePlural?: boolean): Promise<Cosmos>;
21
+ private static pluralize;
22
+ private createContainers;
23
+ /**
24
+ * Create the given containers if they don't exist yet. Idempotent: each
25
+ * container is only created once per Cosmos instance. Names are expected
26
+ * to be final container names (custom model names / pluralization applied).
27
+ */
28
+ ensureContainers(containers: ContainerSpec[]): Promise<void>;
29
+ private ensureContainer;
30
+ private getContainer;
31
+ create<T extends ItemDefinition>(containerName: string, item: T): Promise<T & import("@azure/cosmos").Resource>;
32
+ update<T extends ItemDefinition>(containerName: string, item: T): Promise<ItemDefinition & import("@azure/cosmos").Resource>;
33
+ findOne<T extends ItemDefinition>(containerName: string, query: string | SqlQuerySpec): Promise<T | undefined>;
34
+ findMany<T extends ItemDefinition>(containerName: string, query: string | SqlQuerySpec): Promise<T[]>;
35
+ count(containerName: string, query: string | SqlQuerySpec): Promise<number>;
36
+ delete(containerName: string, item: ItemDefinition & {
37
+ id: string;
38
+ }): Promise<void>;
39
+ }
package/dist/cosmos.js ADDED
@@ -0,0 +1,129 @@
1
+ import { CosmosClient } from '@azure/cosmos';
2
+ /**
3
+ * Derive the document field name from a partition key path, rejecting anything
4
+ * that is not a single leading-slash segment (e.g. `userId`, `/a/b`). Cosmos
5
+ * requires partition key paths to start with `/`, and the delete path needs a
6
+ * top-level field name — validating here turns those mistakes into an early,
7
+ * explicit error instead of a deferred container-creation failure or a silently
8
+ * mis-addressed delete.
9
+ */
10
+ const partitionKeyFieldFromPath = (partitionKey) => {
11
+ if (!/^\/[^/]+$/.test(partitionKey)) {
12
+ throw new Error(`Invalid partition key path "${partitionKey}": expected a single leading-slash segment such as "/id" or "/userId".`);
13
+ }
14
+ return partitionKey.slice(1);
15
+ };
16
+ export class Cosmos {
17
+ client;
18
+ database;
19
+ /**
20
+ * Partition key field per (final, possibly pluralized) container name.
21
+ * Needed for deletes, where the partition key value must be supplied explicitly.
22
+ */
23
+ partitionKeyFields = {};
24
+ ensuredContainers = new Map();
25
+ constructor(credentials) {
26
+ this.client = new CosmosClient(credentials);
27
+ }
28
+ static async create(credentials, dbName, containers, usePlural) {
29
+ const instance = new Cosmos(credentials);
30
+ if (dbName) {
31
+ const { database } = await instance.client.databases.createIfNotExists({ id: dbName });
32
+ instance.database = database;
33
+ }
34
+ if (instance.database && containers) {
35
+ await instance.createContainers(containers, usePlural);
36
+ }
37
+ return instance;
38
+ }
39
+ static pluralize(word) {
40
+ if (word.endsWith('s') || word.endsWith('sh') || word.endsWith('ch') || word.endsWith('x') || word.endsWith('z')) {
41
+ return word + 'es';
42
+ }
43
+ if (word.endsWith('y') && !/[aeiou]y$/.test(word)) {
44
+ return word.slice(0, -1) + 'ies';
45
+ }
46
+ return word + 's';
47
+ }
48
+ async createContainers(containers, usePlural) {
49
+ await Promise.all(containers.map(({ name, partitionKey }) => {
50
+ const finalName = usePlural ? Cosmos.pluralize(name) : name;
51
+ return this.ensureContainer({ name: finalName, partitionKey });
52
+ }));
53
+ }
54
+ /**
55
+ * Create the given containers if they don't exist yet. Idempotent: each
56
+ * container is only created once per Cosmos instance. Names are expected
57
+ * to be final container names (custom model names / pluralization applied).
58
+ */
59
+ async ensureContainers(containers) {
60
+ await Promise.all(containers.map(container => this.ensureContainer(container)));
61
+ }
62
+ ensureContainer({ name, partitionKey = '/id' }) {
63
+ let pending = this.ensuredContainers.get(name);
64
+ if (!pending) {
65
+ // Validate the path up front so a bad config fails loudly here rather than
66
+ // as a deferred container-creation rejection.
67
+ this.partitionKeyFields[name] = partitionKeyFieldFromPath(partitionKey);
68
+ pending = this.database.containers.createIfNotExists({ id: name, partitionKey: { paths: [partitionKey] } }).catch(error => {
69
+ // Don't cache a failed creation: a transient error (throttling, network)
70
+ // would otherwise poison this container for the whole process lifetime.
71
+ // Drop the entry so a later operation can retry.
72
+ this.ensuredContainers.delete(name);
73
+ // eslint-disable-next-line no-console
74
+ console.error(`[better-auth-cosmos] Failed to create container "${name}":`, error);
75
+ throw error;
76
+ });
77
+ this.ensuredContainers.set(name, pending);
78
+ }
79
+ return pending;
80
+ }
81
+ getContainer(containerName) {
82
+ return this.database.container(containerName);
83
+ }
84
+ async create(containerName, item) {
85
+ const { resource } = await this.getContainer(containerName).items.create(item);
86
+ if (!resource) {
87
+ throw new Error(`Cosmos create returned no resource for container "${containerName}"`);
88
+ }
89
+ return resource;
90
+ }
91
+ async update(containerName, item) {
92
+ const { resource } = await this.getContainer(containerName).items.upsert(item);
93
+ if (!resource) {
94
+ throw new Error(`Cosmos upsert returned no resource for container "${containerName}"`);
95
+ }
96
+ return resource;
97
+ }
98
+ async findOne(containerName, query) {
99
+ const container = this.getContainer(containerName);
100
+ const { resources } = await container.items.query(query).fetchAll();
101
+ return resources[0];
102
+ }
103
+ async findMany(containerName, query) {
104
+ const container = this.getContainer(containerName);
105
+ const { resources } = await container.items.query(query).fetchAll();
106
+ return resources;
107
+ }
108
+ async count(containerName, query) {
109
+ const { resources } = await this.getContainer(containerName).items.query(query).fetchAll();
110
+ // `SELECT VALUE COUNT(1)` always yields exactly one row; an empty result
111
+ // means the query shape is wrong, which must not be reported as "0 matches".
112
+ if (resources.length === 0) {
113
+ throw new Error(`Count query returned no rows for container "${containerName}"`);
114
+ }
115
+ return resources[0];
116
+ }
117
+ async delete(containerName, item) {
118
+ const partitionKeyField = this.partitionKeyFields[containerName] ?? 'id';
119
+ const partitionKeyValue = item[partitionKeyField];
120
+ // Refuse to guess the partition value: substituting item.id when the real
121
+ // partition key field is absent would address the wrong logical partition.
122
+ if (partitionKeyValue === undefined || partitionKeyValue === null) {
123
+ throw new Error(`Document "${item.id}" in container "${containerName}" is missing partition key field "${partitionKeyField}"; refusing to delete to avoid targeting the wrong partition.`);
124
+ }
125
+ await this.getContainer(containerName)
126
+ .item(item.id, partitionKeyValue)
127
+ .delete();
128
+ }
129
+ }
@@ -0,0 +1,86 @@
1
+ import { ItemDefinition } from '@azure/cosmos';
2
+ import type { CleanedWhere, Where } from 'better-auth/adapters';
3
+ import { Cosmos } from './cosmos';
4
+ interface CosmosAdapterDeps {
5
+ cosmos: Cosmos;
6
+ getModelName: (model: string) => string;
7
+ getFieldName: (args: {
8
+ model: string;
9
+ field: string;
10
+ }) => string;
11
+ /**
12
+ * Allowed document field names per model, derived from the better-auth schema.
13
+ * Whitelists identifiers (where/sortBy/select) before they are interpolated
14
+ * into Cosmos SQL — Cosmos cannot parameterize identifiers, so an unvalidated
15
+ * field name would be a SQL injection vector.
16
+ */
17
+ validFields: Record<string, ReadonlySet<string>>;
18
+ /**
19
+ * Resolves once all containers required by the active better-auth schema exist.
20
+ */
21
+ ready: Promise<void>;
22
+ }
23
+ export declare class CosmosAdapter {
24
+ private readonly cosmos;
25
+ private readonly getModelName;
26
+ private readonly getFieldName;
27
+ private readonly validFields;
28
+ private readonly ready;
29
+ constructor({ cosmos, getModelName, getFieldName, validFields, ready }: CosmosAdapterDeps);
30
+ /**
31
+ * Reject any identifier that is not a known field of the model. better-auth's
32
+ * adapter factory already maps and validates `where` field names (and throws
33
+ * on unknown ones), so for `where` this is a second line of defense; for
34
+ * `sortBy` and `select` — which the factory does not validate — it is the
35
+ * primary guard against an unvalidated identifier reaching the query text.
36
+ */
37
+ private assertField;
38
+ private mapField;
39
+ private mapSelect;
40
+ private mapSortBy;
41
+ private assertWhere;
42
+ create<T extends ItemDefinition>({ model, data, select: _select }: {
43
+ model: string;
44
+ data: T;
45
+ select?: string[];
46
+ }): Promise<T & import("@azure/cosmos").Resource>;
47
+ update<T extends ItemDefinition>({ model, where, update }: {
48
+ model: string;
49
+ where: Required<Where>[];
50
+ update: T;
51
+ }): Promise<(ItemDefinition & import("@azure/cosmos").Resource) | null>;
52
+ updateMany<T extends ItemDefinition>({ model, where, update }: {
53
+ model: string;
54
+ where: CleanedWhere[];
55
+ update: T;
56
+ }): Promise<number>;
57
+ delete<T extends ItemDefinition>({ model, where }: {
58
+ model: string;
59
+ where?: CleanedWhere[];
60
+ }): Promise<void>;
61
+ deleteMany<T extends ItemDefinition>({ model, where }: {
62
+ model: string;
63
+ where?: CleanedWhere[];
64
+ }): Promise<number>;
65
+ findOne<T extends ItemDefinition>({ model, select, where }: {
66
+ model: string;
67
+ select?: string[];
68
+ where: CleanedWhere[];
69
+ }): Promise<T | undefined>;
70
+ findMany<T extends ItemDefinition>({ model, select, where, sortBy, offset, limit, }: {
71
+ model: string;
72
+ select?: string[];
73
+ where?: CleanedWhere[];
74
+ sortBy?: {
75
+ field: string;
76
+ direction: 'asc' | 'desc';
77
+ };
78
+ offset?: number;
79
+ limit?: number;
80
+ }): Promise<T[]>;
81
+ count({ model, where }: {
82
+ model: string;
83
+ where?: CleanedWhere[];
84
+ }): Promise<number>;
85
+ }
86
+ export {};
@@ -0,0 +1,113 @@
1
+ import { queryBuilder } from './util/queryBuilder';
2
+ export class CosmosAdapter {
3
+ cosmos;
4
+ getModelName;
5
+ getFieldName;
6
+ validFields;
7
+ ready;
8
+ constructor({ cosmos, getModelName, getFieldName, validFields, ready }) {
9
+ this.cosmos = cosmos;
10
+ this.getModelName = getModelName;
11
+ this.getFieldName = getFieldName;
12
+ this.validFields = validFields;
13
+ this.ready = ready;
14
+ }
15
+ /**
16
+ * Reject any identifier that is not a known field of the model. better-auth's
17
+ * adapter factory already maps and validates `where` field names (and throws
18
+ * on unknown ones), so for `where` this is a second line of defense; for
19
+ * `sortBy` and `select` — which the factory does not validate — it is the
20
+ * primary guard against an unvalidated identifier reaching the query text.
21
+ */
22
+ assertField(model, field) {
23
+ const allowed = this.validFields[model];
24
+ if (allowed && !allowed.has(field)) {
25
+ throw new Error(`Unknown field "${field}" for model "${model}"`);
26
+ }
27
+ }
28
+ // `select` and `sortBy` arrive with logical field names (the factory does not
29
+ // transform them), so map them to the physical document property names and
30
+ // validate the result before it reaches the query.
31
+ mapField(model, field) {
32
+ const mapped = this.getFieldName({ model, field });
33
+ this.assertField(model, mapped);
34
+ return mapped;
35
+ }
36
+ mapSelect(model, select) {
37
+ return select?.map(field => this.mapField(model, field));
38
+ }
39
+ mapSortBy(model, sortBy) {
40
+ if (!sortBy) {
41
+ return undefined;
42
+ }
43
+ return { ...sortBy, field: this.mapField(model, sortBy.field) };
44
+ }
45
+ // `where` field names arrive already mapped to physical column names by the
46
+ // factory; validate them as defense in depth before they reach the query.
47
+ assertWhere(model, where) {
48
+ where?.forEach(({ field }) => {
49
+ this.assertField(model, field);
50
+ });
51
+ }
52
+ async create({ model, data, select: _select }) {
53
+ void _select;
54
+ await this.ready;
55
+ return await this.cosmos.create(this.getModelName(model), data);
56
+ }
57
+ async update({ model, where, update }) {
58
+ if (!where?.length) {
59
+ return null;
60
+ }
61
+ this.assertWhere(model, where);
62
+ await this.ready;
63
+ const existingItem = await this.cosmos.findOne(this.getModelName(model), queryBuilder({ where }));
64
+ if (!existingItem) {
65
+ return null;
66
+ }
67
+ const updatedItem = { ...existingItem, ...update };
68
+ return await this.cosmos.update(this.getModelName(model), updatedItem);
69
+ }
70
+ async updateMany({ model, where, update }) {
71
+ this.assertWhere(model, where);
72
+ await this.ready;
73
+ const existingItems = await this.cosmos.findMany(this.getModelName(model), queryBuilder({ where }));
74
+ const updated = await Promise.all(existingItems.map(item => {
75
+ const updatedItem = { ...(item || {}), ...update };
76
+ return this.cosmos.update(this.getModelName(model), updatedItem);
77
+ }));
78
+ return updated.length;
79
+ }
80
+ async delete({ model, where }) {
81
+ this.assertWhere(model, where);
82
+ await this.ready;
83
+ const existingItem = await this.cosmos.findOne(this.getModelName(model), queryBuilder({ where }));
84
+ if (existingItem?.id) {
85
+ await this.cosmos.delete(this.getModelName(model), existingItem);
86
+ }
87
+ }
88
+ async deleteMany({ model, where }) {
89
+ this.assertWhere(model, where);
90
+ await this.ready;
91
+ const existingItems = await this.cosmos.findMany(this.getModelName(model), queryBuilder({ where }));
92
+ const deleted = await Promise.all(existingItems.filter(item => item.id).map(item => this.cosmos.delete(this.getModelName(model), item)));
93
+ return deleted.length;
94
+ }
95
+ async findOne({ model, select, where }) {
96
+ this.assertWhere(model, where);
97
+ const mappedSelect = this.mapSelect(model, select);
98
+ await this.ready;
99
+ return await this.cosmos.findOne(this.getModelName(model), queryBuilder({ select: mappedSelect, where }));
100
+ }
101
+ async findMany({ model, select, where, sortBy, offset, limit, }) {
102
+ this.assertWhere(model, where);
103
+ const mappedSelect = this.mapSelect(model, select);
104
+ const mappedSortBy = this.mapSortBy(model, sortBy);
105
+ await this.ready;
106
+ return await this.cosmos.findMany(this.getModelName(model), queryBuilder({ select: mappedSelect, where, sortBy: mappedSortBy, offset, limit }));
107
+ }
108
+ async count({ model, where }) {
109
+ this.assertWhere(model, where);
110
+ await this.ready;
111
+ return await this.cosmos.count(this.getModelName(model), queryBuilder({ where, countOnly: true }));
112
+ }
113
+ }
@@ -0,0 +1,38 @@
1
+ import { CosmosClientOptions } from '@azure/cosmos';
2
+ import type { BetterAuthOptions } from 'better-auth';
3
+ import { type AdapterFactory, type DBAdapterDebugLogOption } from 'better-auth/adapters';
4
+ import { CosmosAdapter } from './cosmosAdapter';
5
+ export { CosmosAdapter };
6
+ interface CosmosAdapterConfig {
7
+ /**
8
+ * A unique identifier for the adapter.
9
+ */
10
+ adapterId: string;
11
+ /**
12
+ * The name of the adapter.
13
+ */
14
+ adapterName: string;
15
+ /**
16
+ * Helps you debug issues with the adapter.
17
+ */
18
+ debugLogs?: DBAdapterDebugLogOption;
19
+ /**
20
+ * If the table names in the schema are plural.
21
+ */
22
+ usePlural?: boolean;
23
+ /**
24
+ * Cosmos DB credentials
25
+ */
26
+ dbCredentials: CosmosClientOptions;
27
+ /**
28
+ * Database name
29
+ */
30
+ dbName: string;
31
+ /**
32
+ * Partition key path per model (e.g. `{ session: '/userId' }`).
33
+ * Overrides the built-in defaults, which are chosen to match
34
+ * Better Auth's hottest lookup per container.
35
+ */
36
+ partitionKeys?: Record<string, string>;
37
+ }
38
+ export declare const buildCosmosAdapter: (config: CosmosAdapterConfig) => Promise<AdapterFactory<BetterAuthOptions>>;
package/dist/index.js ADDED
@@ -0,0 +1,70 @@
1
+ import { createAdapterFactory } from 'better-auth/adapters';
2
+ import { Cosmos } from './cosmos';
3
+ import { CosmosAdapter } from './cosmosAdapter';
4
+ export { CosmosAdapter };
5
+ /**
6
+ * Default partition key per container, aligned with the most frequent
7
+ * Better Auth query against it (session by token on every request,
8
+ * verification by identifier, account/twoFactor by userId, org-scoped
9
+ * models by organizationId, ...). Falls back to '/id'.
10
+ */
11
+ const defaultPartitionKeys = {
12
+ user: '/id',
13
+ session: '/token',
14
+ verification: '/identifier',
15
+ account: '/userId',
16
+ organization: '/id',
17
+ member: '/organizationId',
18
+ team: '/organizationId',
19
+ invitation: '/organizationId',
20
+ teamMember: '/teamId',
21
+ twoFactor: '/userId',
22
+ };
23
+ export const buildCosmosAdapter = async (config) => {
24
+ const { adapterId, adapterName, dbCredentials, dbName, debugLogs = false, usePlural = false, partitionKeys } = config;
25
+ const cosmos = await Cosmos.create(dbCredentials, dbName);
26
+ return createAdapterFactory({
27
+ config: {
28
+ adapterId,
29
+ adapterName,
30
+ usePlural,
31
+ debugLogs,
32
+ supportsJSON: true,
33
+ supportsDates: false,
34
+ supportsBooleans: true,
35
+ supportsNumericIds: false,
36
+ },
37
+ adapter: ({ options: _options, schema, debugLog, getModelName, getFieldName, getFieldAttributes }) => {
38
+ // Mark parameters as intentionally unused to match Better Auth adapter signature
39
+ void _options;
40
+ void debugLog;
41
+ void getFieldAttributes;
42
+ // Derive containers from the schema, which contains exactly the models
43
+ // required by the active better-auth plugins (including third-party ones).
44
+ const containers = Object.keys(schema).map(model => ({
45
+ name: getModelName(model),
46
+ partitionKey: partitionKeys?.[model] ?? defaultPartitionKeys[model] ?? '/id',
47
+ }));
48
+ const ready = cosmos.ensureContainers(containers);
49
+ // The same `ready` promise is awaited in every adapter operation, so a
50
+ // container-creation failure resurfaces there. This handler runs on a
51
+ // detached chain purely to (a) avoid an unhandled-rejection warning and
52
+ // (b) log the failure so it is visible even if no operation runs yet.
53
+ ready.catch(error => {
54
+ // eslint-disable-next-line no-console
55
+ console.error('[better-auth-cosmos] Cosmos container provisioning failed:', error);
56
+ });
57
+ // Whitelist of physical field names per model, used to reject unknown
58
+ // identifiers before they are interpolated into Cosmos SQL.
59
+ const validFields = {};
60
+ for (const [model, definition] of Object.entries(schema)) {
61
+ const fields = new Set(['id']);
62
+ for (const field of Object.keys(definition.fields)) {
63
+ fields.add(getFieldName({ model, field }));
64
+ }
65
+ validFields[model] = fields;
66
+ }
67
+ return new CosmosAdapter({ cosmos, getModelName, getFieldName, validFields, ready });
68
+ },
69
+ });
70
+ };
@@ -0,0 +1,14 @@
1
+ export interface Account {
2
+ id: string;
3
+ userId: string;
4
+ accountId: string;
5
+ providerId: string;
6
+ accessToken?: string;
7
+ refreshToken?: string;
8
+ idToken?: string;
9
+ accessTokenExpiresAt?: Date;
10
+ refreshTokenExpiresAt?: Date;
11
+ scope?: string;
12
+ createdAt: Date;
13
+ updatedAt: Date;
14
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ export interface Invitation {
2
+ id: string;
3
+ email: string;
4
+ inviterId: string;
5
+ organizationId: string;
6
+ role: string;
7
+ status: string;
8
+ expiresAt: Date;
9
+ teamId?: string;
10
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,8 @@
1
+ export interface Member {
2
+ id: string;
3
+ userId: string;
4
+ organizationId: string;
5
+ role: string;
6
+ createdAt: Date;
7
+ updatedAt?: Date;
8
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,9 @@
1
+ export interface Organization {
2
+ id: string;
3
+ name: string;
4
+ slug: string;
5
+ logo?: string;
6
+ metadata?: Record<string, unknown>;
7
+ createdAt: Date;
8
+ updatedAt?: Date;
9
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,12 @@
1
+ export interface Session {
2
+ id: string;
3
+ userId: string;
4
+ expiresAt: Date;
5
+ token: string;
6
+ ipAddress?: string;
7
+ userAgent?: string;
8
+ activeOrganizationId?: string;
9
+ activeTeamId?: string;
10
+ createdAt: Date;
11
+ updatedAt: Date;
12
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ export interface Team {
2
+ id: string;
3
+ name: string;
4
+ organizationId: string;
5
+ createdAt: Date;
6
+ updatedAt?: Date;
7
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ export interface TeamMember {
2
+ id: string;
3
+ teamId: string;
4
+ userId: string;
5
+ createdAt: Date;
6
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ export interface TwoFactor {
2
+ id: string;
3
+ userId: string;
4
+ secret?: string;
5
+ backupCodes?: string;
6
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ export interface User {
2
+ id: string;
3
+ email: string;
4
+ name?: string;
5
+ image?: string;
6
+ emailVerified: boolean;
7
+ twoFactorEnabled?: boolean;
8
+ createdAt: Date;
9
+ updatedAt: Date;
10
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ export interface VerificationToken {
2
+ id: string;
3
+ identifier: string;
4
+ token: string;
5
+ expiresAt: Date;
6
+ createdAt: Date;
7
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,93 @@
1
+ export interface CustomAdapterConfig {
2
+ debugLogs?: boolean;
3
+ usePlural?: boolean;
4
+ }
5
+ export interface User {
6
+ id: string;
7
+ email: string;
8
+ name?: string;
9
+ image?: string;
10
+ emailVerified: boolean;
11
+ twoFactorEnabled?: boolean;
12
+ createdAt: Date;
13
+ updatedAt: Date;
14
+ }
15
+ export interface Session {
16
+ id: string;
17
+ userId: string;
18
+ expiresAt: Date;
19
+ token: string;
20
+ ipAddress?: string;
21
+ userAgent?: string;
22
+ activeOrganizationId?: string;
23
+ activeTeamId?: string;
24
+ createdAt: Date;
25
+ updatedAt: Date;
26
+ }
27
+ export interface Account {
28
+ id: string;
29
+ userId: string;
30
+ accountId: string;
31
+ providerId: string;
32
+ accessToken?: string;
33
+ refreshToken?: string;
34
+ idToken?: string;
35
+ accessTokenExpiresAt?: Date;
36
+ refreshTokenExpiresAt?: Date;
37
+ scope?: string;
38
+ createdAt: Date;
39
+ updatedAt: Date;
40
+ }
41
+ export interface VerificationToken {
42
+ id: string;
43
+ identifier: string;
44
+ token: string;
45
+ expiresAt: Date;
46
+ createdAt: Date;
47
+ }
48
+ export interface Organization {
49
+ id: string;
50
+ name: string;
51
+ slug: string;
52
+ logo?: string;
53
+ metadata?: Record<string, unknown>;
54
+ createdAt: Date;
55
+ updatedAt?: Date;
56
+ }
57
+ export interface Member {
58
+ id: string;
59
+ userId: string;
60
+ organizationId: string;
61
+ role: string;
62
+ createdAt: Date;
63
+ updatedAt?: Date;
64
+ }
65
+ export interface Invitation {
66
+ id: string;
67
+ email: string;
68
+ inviterId: string;
69
+ organizationId: string;
70
+ role: string;
71
+ status: string;
72
+ expiresAt: Date;
73
+ teamId?: string;
74
+ }
75
+ export interface Team {
76
+ id: string;
77
+ name: string;
78
+ organizationId: string;
79
+ createdAt: Date;
80
+ updatedAt?: Date;
81
+ }
82
+ export interface TeamMember {
83
+ id: string;
84
+ teamId: string;
85
+ userId: string;
86
+ createdAt: Date;
87
+ }
88
+ export interface TwoFactor {
89
+ id: string;
90
+ userId: string;
91
+ secret?: string;
92
+ backupCodes?: string;
93
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,18 @@
1
+ import type { SqlQuerySpec } from '@azure/cosmos';
2
+ import type { CleanedWhere } from 'better-auth/adapters';
3
+ interface QueryBuilderOptions {
4
+ select?: string[];
5
+ where?: CleanedWhere[];
6
+ sortBy?: {
7
+ field: string;
8
+ direction: 'asc' | 'desc';
9
+ };
10
+ offset?: number;
11
+ limit?: number;
12
+ /**
13
+ * Build a `SELECT VALUE COUNT(1)` query instead of returning documents.
14
+ */
15
+ countOnly?: boolean;
16
+ }
17
+ export declare const queryBuilder: ({ select, where, sortBy, offset, limit, countOnly }: QueryBuilderOptions) => SqlQuerySpec;
18
+ export {};
@@ -0,0 +1,99 @@
1
+ export const queryBuilder = ({ select = ['*'], where, sortBy, offset, limit, countOnly }) => {
2
+ const parameters = [];
3
+ const addParameter = (value) => {
4
+ const name = `@p${parameters.length}`;
5
+ parameters.push({ name, value: value });
6
+ return name;
7
+ };
8
+ // Group conditions by connector the same way better-auth's reference adapters
9
+ // do: AND-connector conditions form one group, OR-connector conditions another,
10
+ // and the two groups are AND-ed together — i.e. `(a AND b) AND (c OR d)`.
11
+ // Parentheses are only emitted when both groups are present, so the SQL is
12
+ // unambiguous for mixed connectors without changing the all-AND / all-OR shape.
13
+ const andConditions = [];
14
+ const orConditions = [];
15
+ for (const w of where ?? []) {
16
+ (w.connector === 'OR' ? orConditions : andConditions).push(mapCondition(w, addParameter));
17
+ }
18
+ const andClause = andConditions.join(' AND ');
19
+ const orClause = orConditions.join(' OR ');
20
+ let whereClause = '';
21
+ if (andConditions.length && orConditions.length) {
22
+ whereClause = `(${andClause}) AND (${orClause})`;
23
+ }
24
+ else {
25
+ whereClause = andClause || orClause;
26
+ }
27
+ const columns = select.length === 1 && select.at(0) === '*' ? '*' : select.map(column => `c.${column}`).join(', ');
28
+ let query = `SELECT ${countOnly ? 'VALUE COUNT(1)' : columns} FROM c${whereClause ? ` WHERE ${whereClause}` : ''}${sortBy ? ` ORDER BY c.${sortBy.field} ${sortBy.direction}` : ''}`;
29
+ // Handle pagination
30
+ // offset/limit are interpolated into the query text (Cosmos has no parameter
31
+ // binding for these), so they must be validated as non-negative integers.
32
+ const safeOffset = offset === undefined ? undefined : assertNonNegativeInteger(offset, 'offset');
33
+ const safeLimit = limit === undefined ? undefined : assertNonNegativeInteger(limit, 'limit');
34
+ // If limit is provided, always include OFFSET (default 0) and LIMIT
35
+ // If only offset is provided, include OFFSET and LIMIT 0
36
+ if (safeLimit !== undefined) {
37
+ query += ` OFFSET ${safeOffset ?? 0} LIMIT ${safeLimit}`;
38
+ }
39
+ else if (safeOffset !== undefined && safeOffset > 0) {
40
+ query += ` OFFSET ${safeOffset} LIMIT 0`;
41
+ }
42
+ return { query: query.trim(), parameters };
43
+ };
44
+ const assertNonNegativeInteger = (value, name) => {
45
+ if (!Number.isInteger(value) || value < 0) {
46
+ throw new Error(`Invalid ${name}: expected a non-negative integer, received ${value}`);
47
+ }
48
+ return value;
49
+ };
50
+ const mapCondition = (where, addParameter) => {
51
+ if (where.operator === 'contains') {
52
+ return `CONTAINS(c.${where.field}, ${addParameter(where.value)}, true)`;
53
+ }
54
+ if (where.operator === 'starts_with') {
55
+ return `STARTSWITH(c.${where.field}, ${addParameter(where.value)}, true)`;
56
+ }
57
+ if (where.operator === 'ends_with') {
58
+ return `ENDSWITH(c.${where.field}, ${addParameter(where.value)}, true)`;
59
+ }
60
+ if (where.operator === 'in' && Array.isArray(where.value)) {
61
+ return `ARRAY_CONTAINS(${addParameter(where.value)}, c.${where.field})`;
62
+ }
63
+ if (where.operator === 'not_in' && Array.isArray(where.value)) {
64
+ return `NOT ARRAY_CONTAINS(${addParameter(where.value)}, c.${where.field})`;
65
+ }
66
+ // Comparing against null with `=` / `!=` yields undefined in Cosmos SQL,
67
+ // so null checks need the IS_NULL builtin instead.
68
+ if (where.value === null) {
69
+ if (where.operator === 'ne') {
70
+ return `NOT IS_NULL(c.${where.field})`;
71
+ }
72
+ return `IS_NULL(c.${where.field})`;
73
+ }
74
+ let mappedOperator;
75
+ switch (where.operator) {
76
+ case 'eq':
77
+ mappedOperator = '=';
78
+ break;
79
+ case 'ne':
80
+ mappedOperator = '!=';
81
+ break;
82
+ case 'lt':
83
+ mappedOperator = '<';
84
+ break;
85
+ case 'lte':
86
+ mappedOperator = '<=';
87
+ break;
88
+ case 'gt':
89
+ mappedOperator = '>';
90
+ break;
91
+ case 'gte':
92
+ mappedOperator = '>=';
93
+ break;
94
+ default:
95
+ mappedOperator = '=';
96
+ break;
97
+ }
98
+ return `c.${where.field} ${mappedOperator} ${addParameter(where.value)}`;
99
+ };
package/package.json CHANGED
@@ -1,10 +1,61 @@
1
1
  {
2
2
  "name": "@wemogy/better-auth-cosmos",
3
- "version": "0.0.1",
4
- "description": "OIDC trusted publishing setup package for @wemogy/better-auth-cosmos",
3
+ "version": "1.0.1",
4
+ "description": "A custom better-auth adapter that integrates with Azure Cosmos DB.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/wemogy/better-auth.git",
8
+ "directory": "packages/better-auth-cosmos"
9
+ },
10
+ "homepage": "https://github.com/wemogy/better-auth/wiki",
11
+ "bugs": {
12
+ "url": "https://github.com/wemogy/better-auth/issues"
13
+ },
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "main": "dist/index.js",
18
+ "type": "module",
19
+ "files": [
20
+ "dist",
21
+ "LICENSE",
22
+ "README.md"
23
+ ],
24
+ "dependencies": {
25
+ "@azure/cosmos": "^4.7.0"
26
+ },
27
+ "peerDependencies": {
28
+ "better-auth": "^1.3.34"
29
+ },
30
+ "devDependencies": {
31
+ "@better-auth/test-utils": "^1.6.17",
32
+ "@eslint/js": "^9.38.0",
33
+ "@types/node": "^24.9.1",
34
+ "better-auth": "^1.3.34",
35
+ "eslint": "^9.38.0",
36
+ "eslint-plugin-check-file": "^3.3.0",
37
+ "eslint-plugin-import": "^2.32.0",
38
+ "eslint-plugin-prettier": "^5.5.6",
39
+ "eslint-plugin-react-naming-convention": "^2.2.4",
40
+ "globals": "^16.4.0",
41
+ "typescript": "^5.9.3",
42
+ "typescript-eslint": "^8.46.2",
43
+ "vite": "^6.4.2",
44
+ "vitest": "^3.2.6"
45
+ },
5
46
  "keywords": [
6
- "oidc",
7
- "trusted-publishing",
8
- "setup"
9
- ]
10
- }
47
+ "better-auth",
48
+ "cosmosdb",
49
+ "adapter"
50
+ ],
51
+ "author": "wemogy IT",
52
+ "license": "MIT",
53
+ "scripts": {
54
+ "build": "tsc",
55
+ "test": "vitest run",
56
+ "dev": "tsc --w",
57
+ "lint": "eslint src/**/*.ts",
58
+ "format": "prettier --write \"src/**/*.ts\"",
59
+ "check": "prettier --check \"src/**/*.ts\" && eslint src/**/*.ts"
60
+ }
61
+ }