Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/rules/build-test-lint.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ with `supertest` are the primary way to verify behavior in this project.
## Test Runner: Vitest

The monorepo tests under **Vitest 4** using the `projects` model: the
root `vitest.config.ts` declares every project (`unit`, `e2e-packages`,
and one per example workspace); `vitest.shared.ts` carries the common
root `vitest.config.mts` declares every project (`unit`, `e2e-packages`,
and one per example workspace); `vitest.shared.mts` carries the common
plugin/settings. Select with `--project <name>`; `vitest run` with no
filter runs everything (requires `yarn build` first). Key facts:

Expand Down
3 changes: 2 additions & 1 deletion .claude/rules/editing-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ description: Code editing best practices for this repository
# Source of Truth

- Behavior and API truth: `packages/*/src/**`
- Public API surface for auth package: `packages/rockets-server-auth/swagger/swagger.json`
- Public API surface for auth package: `packages/rockets-server-auth/src/index.ts`
plus its `*.e2e-spec.ts` suites (there is no generated swagger.json anymore).
- When docs conflict with code, prefer code.
20 changes: 16 additions & 4 deletions .github/workflows/release-readiness.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
name: release-readiness

# Gate-only: ci-pr-test already runs build/lint/typecheck/unit/e2e on every
# pull request. This workflow adds only the release gates it does not cover.
on:
pull_request:
branches: ['main']
push:
branches: ['main']

jobs:
release-check:
release-gates:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -25,8 +27,18 @@ jobs:
uses: actions/cache@v4
with:
path: ~/.cache/firebase/emulators
key: firestore-emulator-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
# Keyed to the pinned firebase-tools version in
# scripts/run-emulator-suite.mjs — bump together.
key: firestore-emulator-${{ runner.os }}-firebase-tools-15.15.0
- name: Install
run: corepack yarn install --immutable
- name: Release readiness
run: corepack yarn release:check
- name: Build
run: corepack yarn build
- name: Package artifacts
run: corepack yarn release:packages
- name: Firestore emulator suite
run: corepack yarn test:firestore-emulator
- name: Samples build
run: corepack yarn samples:build
- name: Samples e2e
run: corepack yarn samples:test:e2e
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-tsdoc": "^0.3.0",
"firebase-tools": "15.15.0",
"husky": "^7.0.4",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
Expand Down Expand Up @@ -124,7 +123,7 @@
"test:ci": "vitest run --project unit --coverage --coverage.thresholds.statements=50 --coverage.thresholds.branches=50 --coverage.thresholds.functions=40 --coverage.thresholds.lines=50 --reporter=default --reporter=junit --outputFile=junit.xml",
"test:debug": "vitest --inspect-brk --no-file-parallelism",
"test:e2e": "vitest run --project e2e-packages",
"test:firestore-emulator": "firebase emulators:exec --only firestore --project demo-rockets --config firebase.json 'vitest run --config vitest.firestore.config.mts'",
"test:firestore-emulator": "node scripts/run-emulator-suite.mjs",
"test:e2e:cov": "vitest run --project e2e-packages --coverage --coverage.reportsDirectory=coverage-e2e --maxWorkers=1",
"test:all": "vitest run",
"doc": "rimraf ./docs && typedoc",
Expand Down
2 changes: 1 addition & 1 deletion packages/rockets-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,7 @@ expect.

| Option | Type | Required | Description |
| -------------- | ---------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `auth` | `AuthBootstrap` or array | optional† | Auth wiring from `defineFirebaseAuth()`, `defineRocketsAuth()`, or app-local helpers. Each entry supplies an `adapter`, optional `forRoot()`, and optional integration-owned defaults through `contributes`; explicit app options win. |
| `auth` | `AuthBootstrap` or array | optional† | Auth wiring from `defineFirebaseAuth()`, `defineRocketsAuth()`, or app-local helpers. Each entry supplies an `adapter` and optional `forRoot()`. `identity`/`contributes` are resolved only by `@concepta/rockets` (`createServer` / `RocketsModule`) — core rejects bootstraps that still carry them; pass `userMetadata`/`repository`/`resources` explicitly here instead. |
| `repository` | `RepositoryModuleInterface` or `RepositoryBootstrap` | optional | Default persistence adapter. A bootstrap owns both `forRoot(entities)` and `forFeature(entities)`. |
| `userMetadata` | `RocketsUserMetadataConfig` | optional | Entity + DTOs for the metadata table joined to external users. |
| `resources` | `ReadonlyArray<ResourceInput>` | optional | Mix of `defineResource`, `defineModuleResource`, and manual `RocketsResourceConfig`. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,30 @@ import type { RocketsUserMetadataConfig } from './rockets-user-metadata-config.i
import type { ResourceInput } from '../../infrastructure/resource/planner/app-registration-plan.types';

/**
* Defaults an auth integration can contribute to the surrounding Rockets app.
* Explicit options on `RocketsModule` take precedence over every contribution.
* Identity ownership an auth integration claims over the surrounding app:
* the persistence rows, root repository, and user-metadata contract for the
* single user space every adapter in the chain authenticates into. At most
* ONE bootstrap per app may carry this — the server rejects two owners at
* composition time. Explicit options on `RocketsModule` still win.
*
* Resolved by `@concepta/rockets` (`createServer` / `RocketsModule`);
* `RocketsCoreModule` does not read it.
*/
export interface AuthBootstrapContributions {
export interface AuthBootstrapIdentity {
readonly resources?: ReadonlyArray<ResourceInput>;
readonly userMetadata?: RocketsUserMetadataConfig;
readonly repository?: RepositoryModuleInterface | RepositoryBootstrap;
}

/**
* Per-integration guard preferences. Unlike {@link AuthBootstrapIdentity},
* these can coexist across an auth chain; conflicting values fail at
* composition. Explicit options on `RocketsModule` take precedence.
*
* Resolved by `@concepta/rockets` (`createServer` / `RocketsModule`);
* `RocketsCoreModule` does not read them.
*/
export interface AuthBootstrapContributions {
/**
* Contribute `false` only together with `providesAppGuard: true` — an
* integration may swap the global guard, never remove it. Removing the
Expand All @@ -37,6 +54,8 @@ export interface AuthBootstrap<
> {
readonly adapter: Type<Adapter>;
readonly forRoot?: () => DynamicModule;
/** Persistence and server defaults owned here; explicit app options win. */
/** Singular identity ownership — at most one bootstrap per app. */
readonly identity?: AuthBootstrapIdentity;
/** Guard preferences owned by this integration; explicit app options win. */
readonly contributes?: AuthBootstrapContributions;
}
1 change: 1 addition & 0 deletions packages/rockets-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export type { DefineModuleResourceInput } from './infrastructure/resource/define
export type {
AuthBootstrap,
AuthBootstrapContributions,
AuthBootstrapIdentity,
} from './domain/interfaces/auth-bootstrap.interface';
export { defineAuthAdapter } from './infrastructure/auth/define-auth-adapter';
export type { DefineAuthAdapterOptions } from './infrastructure/auth/define-auth-adapter';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import type {
AuthAttemptResult,
AuthRequest,
} from '../../domain/interfaces/auth-adapter.interface';
import type { AuthBootstrapContributions } from '../../domain/interfaces/auth-bootstrap.interface';
import type {
AuthBootstrapContributions,
AuthBootstrapIdentity,
} from '../../domain/interfaces/auth-bootstrap.interface';
// Imported from the package index (not the module directly) so this spec fails
// to compile if `defineAuthAdapter` stops being part of the public surface.
import { defineAuthAdapter } from '../../index';
Expand All @@ -24,15 +27,18 @@ describe('defineAuthAdapter', () => {
provide: 'AUTH_DEPENDENCY',
useValue: true,
};
const contributes: AuthBootstrapContributions = { resources: [] };
const identity: AuthBootstrapIdentity = { resources: [] };
const contributes: AuthBootstrapContributions = { providesAppGuard: true };

const bootstrap = defineAuthAdapter(SpecAuthAdapter, {
providers: [dependency],
identity,
contributes,
});
const module = bootstrap.forRoot!();

expect(bootstrap.adapter).toBe(SpecAuthAdapter);
expect(bootstrap.identity).toBe(identity);
expect(bootstrap.contributes).toBe(contributes);
expect(module.providers).toEqual([dependency, SpecAuthAdapter]);
expect(module.exports).toEqual([SpecAuthAdapter]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,22 @@ import type { AuthAdapterInterface } from '../../domain/interfaces/auth-adapter.
import type {
AuthBootstrap,
AuthBootstrapContributions,
AuthBootstrapIdentity,
} from '../../domain/interfaces/auth-bootstrap.interface';

/** Optional Nest wiring and server defaults owned by a custom auth adapter. */
/**
* Optional Nest wiring and server defaults owned by a custom auth adapter.
*
* `identity` and `contributes` are for integration PACKAGES that own user
* persistence or guard behavior. App code should set `userMetadata`,
* `repository`, and `resources` on the module options instead.
*/
export interface DefineAuthAdapterOptions {
readonly imports?: NonNullable<DynamicModule['imports']>;
readonly controllers?: NonNullable<DynamicModule['controllers']>;
readonly providers?: ReadonlyArray<Provider>;
readonly exports?: NonNullable<DynamicModule['exports']>;
readonly identity?: AuthBootstrapIdentity;
readonly contributes?: AuthBootstrapContributions;
}

Expand All @@ -28,6 +36,7 @@ export function defineAuthAdapter<Adapter extends AuthAdapterInterface>(
): AuthBootstrap<Adapter> {
return {
adapter,
identity: options.identity,
contributes: options.contributes,
forRoot: () => ({
module: class AuthAdapterHostModule {},
Expand Down
47 changes: 47 additions & 0 deletions packages/rockets-core/src/rockets-core.module-definition.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { Injectable } from '@nestjs/common';

import type {
AuthAdapterInterface,
AuthAttemptResult,
AuthRequest,
} from './domain/interfaces/auth-adapter.interface';
import { RocketsCoreModule } from './rockets-core.module';
import { defineAuthAdapter } from './infrastructure/auth/define-auth-adapter';

@Injectable()
class BoundaryAuthAdapter implements AuthAdapterInterface {
async authenticate(_request: AuthRequest): Promise<AuthAttemptResult> {
return { matched: false };
}
}

describe('RocketsCoreModule — auth bootstrap boundary', () => {
it('rejects a bootstrap that still carries identity', () => {
expect(() =>
RocketsCoreModule.forRoot({
auth: defineAuthAdapter(BoundaryAuthAdapter, {
identity: { resources: [] },
}),
}),
).toThrow(/identity\/contributes/);
});

it('rejects a bootstrap that still carries contributes', () => {
expect(() =>
RocketsCoreModule.forRoot({
auth: defineAuthAdapter(BoundaryAuthAdapter, {
contributes: { providesAppGuard: true },
}),
}),
).toThrow(/identity\/contributes/);
});

it('accepts a plain adapter bootstrap', () => {
expect(
RocketsCoreModule.forRoot({
auth: defineAuthAdapter(BoundaryAuthAdapter),
}),
).toBeDefined();
});
});
16 changes: 16 additions & 0 deletions packages/rockets-core/src/rockets-core.module-definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,22 @@ function normalizeAuthBootstraps(
): ReadonlyArray<AuthBootstrap> {
if (input === undefined) return [];
const entries = Array.isArray(input) ? input : [input];
// Core would silently drop these — refuse instead of booting an app that
// is missing the persistence/guard defaults the integration declared.
for (const bootstrap of entries) {
if (
bootstrap.identity !== undefined ||
bootstrap.contributes !== undefined
) {
throw new Error(
'RocketsCoreModule: auth bootstrap "' +
bootstrap.adapter.name +
'" carries identity/contributes, which only @concepta/rockets ' +
'(createServer / RocketsModule) resolves. Use createServer(), or ' +
'pass userMetadata/repository/resources explicitly to core.',
);
}
}
return entries;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';

const { fakeDb, createDoc } = vi.hoisted(() => {
const createDoc = vi.fn(async (): Promise<void> => undefined);
const getAll = vi.fn(async (...refs: Array<{ readonly id: string }>) =>
refs.map((ref) => ({
exists: true,
id: ref.id,
data: () => ({ title: `row-${ref.id}` }),
})),
);
const fakeDb = {
getAll,
collection: (_name: string) => ({
doc: (id: string) => ({ id, create: createDoc }),
}),
};
return { fakeDb, createDoc };
});

vi.mock('firebase-admin/app', () => ({ getApp: () => ({}) }));
vi.mock('firebase-admin/firestore', () => ({ getFirestore: () => fakeDb }));

import { AdminFirestoreBackend } from '../backends/admin-firestore.backend';
import { FirestoreDuplicateIdException } from '../exceptions/firestore-duplicate-id.exception';

describe('AdminFirestoreBackend', () => {
beforeEach(() => {
fakeDb.getAll.mockClear();
createDoc.mockClear();
});

it('chunks getAll so id lists of any size load without a cap', async () => {
const backend = new AdminFirestoreBackend();
const documentIds = Array.from(
{ length: 800 },
(_, index) => `doc-${index}`,
);

const rows = await backend.queryBranch('widgets', {
branch: { documentIds, filters: [], postFilters: [] },
});

expect(rows).toHaveLength(800);
expect(fakeDb.getAll).toHaveBeenCalledTimes(3);
expect(fakeDb.getAll.mock.calls[0]).toHaveLength(300);
expect(fakeDb.getAll.mock.calls[2]).toHaveLength(200);
expect(rows[799]).toMatchObject({ id: 'doc-799', title: 'row-doc-799' });
});

it('translates the SDK ALREADY_EXISTS error into the contract exception', async () => {
const backend = new AdminFirestoreBackend();
createDoc.mockRejectedValueOnce({ code: 6 });

await expect(
backend.create('widgets', 'dup-id', { title: 'x' }),
).rejects.toBeInstanceOf(FirestoreDuplicateIdException);
});

it('re-throws SDK errors that are not duplicates untouched', async () => {
const backend = new AdminFirestoreBackend();
createDoc.mockRejectedValueOnce({ code: 13, message: 'internal' });

await expect(
backend.create('widgets', 'any-id', { title: 'x' }),
).rejects.toMatchObject({ code: 13 });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import type { RepositoryInterface } from '@concepta/nestjs-repository';

import { InMemoryFirestoreBackend } from '../backends/in-memory-firestore.backend';
import { FirestoreDuplicateIdException } from '../exceptions/firestore-duplicate-id.exception';
import { FirestoreRepositoryModule } from '../firestore-repository.module';

class WidgetEntity {
Expand Down Expand Up @@ -455,7 +456,7 @@ describe(FirestoreRepositoryModule.name, () => {

await expect(
repo.create({ id: 'same-id', title: 'Replacement' }),
).rejects.toThrow();
).rejects.toBeInstanceOf(FirestoreDuplicateIdException);
await expect(
repo.findOne({ where: Where.eq('id', 'same-id') }),
).resolves.toMatchObject({ title: 'Original' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,27 @@ describe('local Firestore value semantics', () => {
).toBe(true);
});

it('matches server equality for NaN and signed zero', () => {
// Firestore `== NaN` compiles to the IS_NAN unary filter, so NaN matches
// NaN; -0 equals 0. NaN never equals a non-NaN number.
expect(firestoreValuesEqual(Number.NaN, Number.NaN)).toBe(true);
expect(firestoreValuesEqual(Number.NaN, 5)).toBe(false);
expect(firestoreValuesEqual(-0, 0)).toBe(true);
expect(firestoreValuesEqual(0, -0)).toBe(true);
expect(firestoreValuesEqual([Number.NaN], [Number.NaN])).toBe(true);
expect(firestoreValuesEqual({ value: -0 }, { value: 0 })).toBe(true);
});

it('orders strings by code points like the server, not UTF-16 units', () => {
// U+1F600 (😀, UTF-16 surrogate D83D) vs U+FB01 (fi): code-point order
// puts the emoji AFTER, UTF-16 unit order would put it before.
expect(compareFirestoreValues('😀', 'fi')).toBeGreaterThan(0);
expect(compareFirestoreValues('fi', '😀')).toBeLessThan(0);
expect(compareFirestoreValues('abc', 'abd')).toBeLessThan(0);
expect(compareFirestoreValues('ab', 'abc')).toBeLessThan(0);
expect(compareFirestoreValues('😀', '😀')).toBe(0);
});

it('compares timestamp-like SDK values with dates before SDK equality', () => {
const date = new Date('2026-01-01T00:00:00.000Z');
const timestamp = new TimestampStub(date);
Expand Down
Loading