Skip to content

Feat/improvement for v 0 2 - #1

Merged
DoniLite merged 15 commits into
mainfrom
feat/improvement-for-v-0-2
Jul 8, 2026
Merged

Feat/improvement for v 0 2#1
DoniLite merged 15 commits into
mainfrom
feat/improvement-for-v-0-2

Conversation

@DoniLite

@DoniLite DoniLite commented Jul 8, 2026

Copy link
Copy Markdown
Owner

Summary by Sourcery

Introduce a new Wrap composition root with configurable auth integration and Swagger generation, refactor controllers/services to support non-CRUD routing, and add offline-first sync support plus tests.

New Features:

  • Add Wrap composition root class to centralize Hono setup, controller registration, Swagger UI, and server startup for generated apps.
  • Introduce RouterController and WrapService base classes to support non-CRUD controllers/services and nested controller composition.
  • Add offline-first sync support to BaseRepository/BaseService with cursor-based change pull and batch apply semantics.
  • Extend SwaggerGenerator to derive path params from route definitions, integrate AuthController-driven security schemes, and allow UI customization.
  • Provide JwtCookieAuthController as a concrete AuthController implementation with sliding JWT cookie sessions and OpenAPI security schemes.

Bug Fixes:

  • Fix DTO validation context detection to work with real Hono Context instances and test helper contexts.
  • Ensure Can access-control decorator reads identity from the new AuthController-provided context variable instead of legacy jwtPayload.

Enhancements:

  • Refactor auth middleware API around a paradigm-agnostic AuthController contract, keeping createAuth as a thin deprecated shim for backwards compatibility.
  • Update registry typing to expose a strongly typed identity on Hono context variables and wire app factories to use the merged AppVariables.
  • Refine BaseController into RouterController plus CRUD-specific BaseController and introduce shared controller mounting utilities.
  • Improve Swagger setup helper and generator to accept auth-aware configuration and optional UI options, including withCredentials defaults when auth is present.

Tests:

  • Add sync-focused repository tests covering cursor-based change pulls, pagination, conflict resolution, and tombstone handling.
  • Add JwtCookieAuthController tests for bearer/cookie auth, sliding sessions, role guards, revocation, and OpenAPI scheme exposure.
  • Add SwaggerGenerator tests for path parameter extraction, regex param handling, and auth-tagged security requirements.
  • Add Wrap composition root tests to cover health routing, nested controller wiring, middleware plugins, and guarded mounts.

DoniLite and others added 10 commits July 8, 2026 05:11
…across controllers and services

Every hono `Context` call inside the package consuming the `AppVariables` now
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The check only matched @donilite/wrap/testing's testContext() test
double (a plain object whose req.json/query/formData are own-enumerable
properties). Against a real Hono Context, HonoRequest.json()/query()
live on the prototype, so Object.keys(c.req) never contains them and
every @ValidateDTO-decorated call (BaseService.create/update) threw
"must receive the Hono context" on real HTTP requests — masked in
tests because they only ever went through testContext().

Now checks `instanceof Context` first (stable across Hono versions,
unlike sniffing method names on the request object), falling back to
the duck-typed check so the test double keeps working.
createAuth() was a rigid, JWT+cookie-specific function — extending it
for any other auth paradigm meant copying it wholesale. AuthController
is now the real source of truth: an abstract class that only mandates
authenticate()/revoke(); the guard shape (authMiddleware) is identical
across paradigms so it's implemented once via Hono's own
createMiddleware() helper. Authorization is a generic guard(predicate)
over the resolved identity — roles, permissions, scopes, tenant checks
are all equally first-class, and the framework never assumes RBAC.
Concrete presets are free to constructor-inject a repository/service,
so auth can participate in the same entity/event/transaction machinery
as the rest of the app instead of being stuck stateless.

JwtCookieAuthController is the reference preset: today's bearer/cookie
behavior, with requireRoles() as its own convenience built on guard()
rather than a framework mandate. createAuth() becomes a thin deprecated
shim delegating to it. decorators/access.ts's @can now reads
c.get("identity") — the one canonical context key — instead of the
JWT-flavored "jwtPayload".

The identity shape is typed through the registry, the same
declaration-merging mechanism already used for schema/variables/roles:
`declare module "@donilite/wrap" { interface WrapRegistry { identity: MyShape } }`.
AppVariables merges identity in automatically so c.get("identity") is
typed everywhere without every consumer redeclaring it.
…us offline-first sync

BaseController and BaseService forced a CRUD service/repository generic
even on features that don't need one (health checks, root routes,
orchestration services). RouterController now holds the route-scanning/
middleware-assembly logic on its own — BaseController<Service> extends
it, adding only the service field, so a controller can extend
RouterController directly with no fake service. WrapService is the
equivalent split for services without a repository.

RouterController also gains register() — the same child-controller
composition primitive as Wrap (mount at the child's own
@controller basePath, optionally prefixed), so controllers can compose
children under themselves and it works from registerCustomRoutes()
too. register()'s middlewares option scopes a middleware to the whole
mount (including the mount's own bare path): it's applied on the
PARENT app before .route() attaches the child, which is what makes it
actually guard the child's routes regardless of when the child
registered them on itself. joinPath/mountController are the shared
helpers behind both Wrap.register() and this.

BaseRepository gains findChangedSince()/applyBatch() for offline-first
sync (mobile app stores locally, syncs to backend without going
through the DB directly): cursor pull ordered by updatedAt, including
soft-deleted rows as tombstones (BaseRow's $onUpdate already bumps
updatedAt on any set() call, including the soft-delete's { deletedAt }
update); batch push with last-write-wins conflict resolution — a
client change older than the server's current updatedAt is reported
back as a conflict and skipped rather than applied. Deletes in
applyBatch go through the same soft-delete path as reads so they
surface as tombstones, not the repository's hard delete(). BaseService
forwards both, same pattern as findById/findAll.
Path params like /:id never showed up in the generated OpenAPI spec —
they were only ever added from an explicit route.params option that
nothing in the codebase actually set. Parameters are now derived from
the route path itself (:name and Hono's :name{regex} constrained
syntax), with any explicit route.params entry only overriding
type/description. normalizePath's :id -> {id} conversion is fixed the
same way — it previously mangled regex-constrained params into
{id{[0-9]+}} instead of {id}.

"Does this route need auth" detection moves from sniffing middleware
function names (mw.name === "authMiddleware", breaks on bound/wrapped
functions) to a WRAP_AUTH_MIDDLEWARE tag set once by AuthController.
Security schemes in the generated spec now come from the registered
AuthController's openApiSecurityScheme() instead of a hardcoded
bearer+cookie pair, falling back to that pair when no AuthController is
registered. SwaggerGenerator takes an AuthController instance (not the
class) to match Wrap.with()'s ergonomics — AuthControllerClass is a
narrow structural type covering just the static hook, since typeof
AuthController would also encode the constructor's argument list,
which varies per preset and broke assignability for no reason.

setupSwaggerUI/setupSwagger forward UI options to @hono/swagger-ui, so
an app can enable withCredentials/persistAuthorization for cookie-
session "Try it out" support.
Every generated project hand-wired the same boilerplate in its
entrypoint: new Hono(), cors/secureHeaders/requestId/bodyLimit,
app.onError(errorHandler()), app.notFound(...), setupSwagger(...) —
and still had to import and mount every feature's Hono sub-app
manually. Wrap owns all of that: constructing it wires the standard
middleware stack and error/404 handling; .with() registers a global
middleware or the app's AuthController (made available to .swagger()
without being auto-applied globally); .register() mounts a controller
at its own @controller basePath (optionally with scoped middlewares,
see the previous commit); .swagger() wires OpenAPI + UI, deferring to
the registered AuthController for security schemes and defaulting to
credentialed/persisted "Try it out".

.get/.post/.put/.patch/.delete/.use/.request are thin passthroughs to
the underlying Hono app — ad-hoc routes, path-scoped middleware and
direct testing (app.request(...)) don't need the escape hatch. .raw
stays for what genuinely needs the raw Hono instance (realtime
websocket upgrade, a custom Bun.serve topology).
src/index.ts now composes the app through Wrap instead of hand-rolling
Hono: cors config in, auth registered via .with(), the admin guard via
.use("/admin/*", ...), swagger via .swagger(), realtime wired through
the raw escape hatch (it needs the underlying Hono instance and the
Bun.serve server handle directly) and .get() for the ergonomic routes.

IndexController is the single controller registered on Wrap; it
composes ExampleController as a child via register() in its own
constructor — parent-child controller composition, not a flat list of
independently Wrap-registered controllers. ExampleController's
@controller basePath ("/api/examples") stays the one place that path
is declared. example.app.ts is gone — register() replaces what it did
by hand.

middleware/auth.ts constructs a JwtCookieAuthController instead of
calling the deprecated createAuth(); web.factory.ts's webFactory is
now typed with the framework-merged AppVariables instead of the app's
own narrower Variables, since every controller's Hono app needs to
agree with what AuthController/Wrap expect (Variables stays what the
app registers into WrapRegistry — the two are different consumers of
the same declaration).
…ation

None of tonight's new surface had automated coverage: Wrap itself,
AuthController/JwtCookieAuthController, the sync methods, and the
swagger fixes were only exercised by throwaway verification scripts
during development. The existing suite also bypassed the real
composition path — example.controller.test.ts builds
new ExampleController().getApp() directly, never going through Wrap.

wrap.test.ts boots a real Wrap, registers IndexController (which
registers ExampleController as its own child) and drives requests
through app.raw/app.request — the actual src/index.ts wiring, not a
bypass — plus register()'s middlewares option guarding a whole mount
including its bare path.

auth.test.ts exercises JwtCookieAuthController end-to-end over real
HTTP (bearer, sliding cookie sessions, requireRoles, revoke, wrong-
secret rejection) rather than unit-testing pieces in isolation.

example.repository.sync.test.ts covers findChangedSince pagination and
cursor exclusivity, and applyBatch's conflict-vs-applied outcomes
including the soft-delete tombstone path.

swagger.test.ts covers the path-param derivation bug fix (plain and
regex-constrained), AuthController-driven security schemes, and that
only routes actually tagged by AuthController middleware get a
security block.
@sourcery-ai

sourcery-ai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Reviewer's Guide

Introduces a new Wrap composition root and RouterController abstraction, refactors auth into a reusable AuthController/JwtCookieAuthController model with identity-aware context and OpenAPI integration, adds offline-first sync support to BaseRepository/BaseService, enhances Swagger generation and templates to use the new architecture, and adds tests for sync, auth, swagger, and Wrap wiring.

Sequence diagram for auth middleware and Swagger security integration

sequenceDiagram
  actor Dev
  participant Wrap
  participant JwtCookieAuthController as Auth
  participant SwaggerGenerator as Swagger
  participant Hono as App

  Dev->>Wrap: new Wrap(options)
  Dev->>Auth: new JwtCookieAuthController(options)
  Dev->>Wrap: with(auth)
  Wrap->>Wrap: store AuthController instance

  Dev->>Wrap: swagger(config, uiOptions)
  Wrap->>Swagger: new SwaggerGenerator(config, auth)
  Swagger->>Auth: openApiSecurityScheme()
  Auth-->>Swagger: securitySchemes
  Swagger->>App: setupSwaggerUI(app, path, uiOptions)

  %% Request-time auth flow
  actor User
  participant Route as ProtectedRoute

  User->>App: HTTP request to guarded path
  App->>Auth: authMiddleware
  Auth->>Auth: authenticate(c)
  alt unauthenticated
    Auth-->>App: 401 Unauthorized
  else authenticated
    Auth->>App: c.set("identity", identity)
    App->>Route: handler(c)
  end
Loading

File-Level Changes

Change Details Files
Introduce RouterController, shared mounting utilities, and a Wrap composition root to centralize app wiring, controller registration, and Swagger.
  • Extract RouterController from BaseController to handle route scanning, middleware assembly, error handling, and nested controller registration without a service dependency.
  • Add joinPath and mountController helpers to normalize mount paths and uniformly mount child controllers with optional scoped middlewares.
  • Implement Wrap class that owns the Hono app, installs default middlewares and error handling, supports auth plugins, controller registration, ad-hoc routes, Swagger integration, and Bun.serve-based listening.
packages/wrap/src/base.controller.ts
packages/wrap/src/wrap.ts
packages/wrap/src/index.ts
packages/create-wrap/template/src/index.controller.ts
packages/create-wrap/template/tests/wrap.test.ts
Refactor auth into an AuthController abstraction with a JwtCookieAuthController preset, deprecate createAuth, and wire it into templates and Swagger.
  • Define AuthController base with authenticate/revoke hooks, tagged authMiddleware/guard, and OpenAPI hook; expose WRAP_AUTH_MIDDLEWARE for metadata.
  • Implement JwtCookieAuthController handling JWT bearer/cookie auth, sliding sessions, role-based guards, OpenAPI security schemes, and legacy Auth interface compatibility.
  • Replace createAuth-based template wiring with JwtCookieAuthController, update Can decorator to use c.get('identity'), and extend registry/AppVariables with typed AuthIdentity.
packages/wrap/src/middleware/auth/auth.controller.ts
packages/wrap/src/middleware/auth/jwt-cookie.controller.ts
packages/wrap/src/middleware/auth/auth.middleware.ts
packages/wrap/src/middleware/auth/types.ts
packages/wrap/src/decorators/access.ts
packages/wrap/src/registry.ts
packages/create-wrap/template/src/middleware/auth.ts
packages/create-wrap/template/tests/auth.test.ts
Add offline-first sync support (cursor-based pull and batch push with conflict resolution) to BaseRepository and BaseService, plus template tests.
  • Introduce SyncChange, SyncBatchResult, SyncPage types and implement BaseRepository.findChangedSince to page changes by updatedAt, including soft-deleted tombstones.
  • Implement BaseRepository.applyBatch to apply create/update/delete operations with last-write-wins conflict handling on updatedAt and soft-deleting deletes, emitting entity events.
  • Expose findChangedSince and applyBatch helpers on BaseService and add template tests verifying sync behavior on ExampleRepository.
packages/wrap/src/base.repository.ts
packages/wrap/src/base.service.ts
packages/create-wrap/template/tests/example.repository.sync.test.ts
Enhance Swagger generation with AuthController-driven security schemes, better path/parameter handling, and configurable Swagger UI options.
  • Wire SwaggerGenerator to optional AuthControllerClass, using its openApiSecurityScheme output or default schemes, and tag routes as secured via WRAP_AUTH_MIDDLEWARE instead of name sniffing.
  • Derive path parameters from Hono route syntax including :id and :id{regex}, replacing route.params as the primary source, and update path conversion regex accordingly.
  • Extend setupSwagger/setupSwaggerUI to accept generic Hono types and optional SwaggerUIOptions (including UI customizations), and update template bootstrap to use Wrap.swagger with auth-aware defaults.
packages/wrap/src/swagger/index.ts
packages/create-wrap/template/src/index.ts
packages/create-wrap/template/tests/swagger.test.ts
Tighten validation and typing around Hono Context/AppVariables and update template factory typing.
  • Update ValidateDTO decorator to distinguish real Hono Context instances via instanceof while still supporting testContext() via duck-typing.
  • Adjust BaseController/BaseService/Can middleware signatures to use Context<{ Variables: AppVariables }> and ensure identity is always present in AppVariables.
  • Change template webFactory to use framework-merged AppVariables instead of raw JwtVariables and document custom variables layering.
packages/wrap/src/decorators/validation.ts
packages/wrap/src/base.controller.ts
packages/wrap/src/base.service.ts
packages/wrap/src/decorators/access.ts
packages/create-wrap/template/src/factory/web.factory.ts

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 5 issues, and left some high level feedback:

  • In AuthController.with and related wiring, relying on instanceof AuthController to detect auth plugins could be brittle if multiple copies of the library or different module contexts are loaded; consider using a branded symbol or static marker on the class instead of instanceof for more robust detection.
  • The new BaseRepository.applyBatch processes the batch sequentially without any transactional guardrails; if you expect batches to be atomic, consider wrapping the loop in a transaction (or making it pluggable) so that partial failures don’t leave the server-side state only partially applied.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `AuthController.with` and related wiring, relying on `instanceof AuthController` to detect auth plugins could be brittle if multiple copies of the library or different module contexts are loaded; consider using a branded symbol or static marker on the class instead of `instanceof` for more robust detection.
- The new `BaseRepository.applyBatch` processes the batch sequentially without any transactional guardrails; if you expect batches to be atomic, consider wrapping the loop in a transaction (or making it pluggable) so that partial failures don’t leave the server-side state only partially applied.

## Individual Comments

### Comment 1
<location path="packages/create-wrap/template/src/middleware/auth.ts" line_range="17" />
<code_context>
 });

-export const { authMiddleware, setupCookieSession, clearCookieSession } = auth;
+export const authMiddleware = auth;
+export const setupCookieSession = auth.setupCookieSession.bind(auth);
+export const clearCookieSession = auth.clearCookieSession.bind(auth);
</code_context>
<issue_to_address>
**issue (bug_risk):** Exporting `authMiddleware` as the controller instance will break middleware usage

`auth` is a `JwtCookieAuthController` instance, not a `MiddlewareHandler`. Exporting `authMiddleware = auth` changes the public API: any code doing `app.use('/admin/*', authMiddleware)` will now receive an object instead of a function and fail at runtime.

To preserve the previous behavior, export the controller’s middleware function instead:

```ts
export const authMiddleware = auth.authMiddleware;
```

and keep `auth` as the controller instance for `app.with(auth)` and chaining.
</issue_to_address>

### Comment 2
<location path="packages/wrap/src/decorators/validation.ts" line_range="42-50" />
<code_context>
+      // stable check — but it doesn't match the plain-object test double
+      // from `@donilite/wrap/testing`'s `testContext()`, so that shape is
+      // still accepted via the duck-typed fallback below.
+      const c: Context<{ Variables: AppVariables }> | undefined = args.find(
         (arg) =>
-          arg &&
-          typeof arg === "object" &&
-          "req" in arg &&
-          typeof (arg as any).json === "function",
+          arg instanceof Context ||
+          (arg &&
+            typeof arg === "object" &&
+            "req" in arg &&
+            arg.req &&
+            typeof arg.req === "object" &&
+            Object.keys(arg.req).includes(provider)),
       );

</code_context>
<issue_to_address>
**issue (bug_risk):** The new context detection logic may miss valid test contexts and is brittle

The new fallback relies on `Object.keys(arg.req).includes(provider)`, but real Hono `Context` instances expose methods like `json`/`formData` on the prototype, not as own enumerable properties. That means the detection effectively depends only on the `instanceof Context` branch, and third-party or custom test doubles may be skipped if they use non-enumerable properties or different method names.

To keep the fast-path while preserving the previous duck-typing behaviour, consider checking for a callable `req[provider]` instead of enumerating keys:

```ts
const c = args.find((arg) =>
  arg instanceof Context ||
  (arg && typeof arg === 'object' &&
    'req' in arg && arg.req && typeof arg.req === 'object' &&
    typeof (arg.req as any)[provider] === 'function')
) as Context<{ Variables: AppVariables }> | undefined;
```

This keeps the detection robust for both real contexts and compatible mocks.
</issue_to_address>

### Comment 3
<location path="packages/wrap/src/base.repository.ts" line_range="552-554" />
<code_context>
+    options?: { limit?: number },
+  ): Promise<SyncPage<InferEntity<Tb>>> {
+    const updatedAtColumn = this.requireColumn("updatedAt");
+    const cursorDate = typeof cursor === "string" ? new Date(cursor) : cursor;
+    const limit = options?.limit ?? 200;
+
+    const rows = (await this.db
+      .select()
+      .from(this.table as PgTable)
+      .where(gt(updatedAtColumn, cursorDate))
+      .orderBy(asc(updatedAtColumn))
+      .limit(limit)) as InferEntity<Tb>[];
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Lack of validation for invalid cursor dates can lead to surprising sync behaviour

`new Date(cursor)` may yield `Invalid Date` for malformed strings, which is then passed into `gt(updatedAtColumn, cursorDate)`. Depending on the driver, this could throw or produce incorrect result sets (e.g., all or no rows). Consider validating the parsed date and failing fast, for example by checking `Number.isNaN(cursorDate.getTime())` and throwing a clear error when the cursor is invalid to avoid inconsistent sync windows.

```suggestion
    const updatedAtColumn = this.requireColumn("updatedAt");
    const cursorDate =
      typeof cursor === "string" ? new Date(cursor) : cursor;

    if (Number.isNaN(cursorDate.getTime())) {
      throw new Error(
        `Invalid cursor date passed to findChangedSince: ${String(cursor)}`,
      );
    }

    const limit = options?.limit ?? 200;
```
</issue_to_address>

### Comment 4
<location path="packages/create-wrap/template/tests/auth.test.ts" line_range="46-23" />
<code_context>
+  return { token, cookie: cookie.split(";")[0] ?? "" };
+}
+
+describe("JwtCookieAuthController", () => {
+  it("rejects requests without a token", async () => {
+    const auth = new JwtCookieAuthController({ secret: SECRET });
+    const res = await buildApp(auth).request("/private");
+    expect(res.status).toBe(401);
+  });
+
+  it("accepts a bearer token and exposes the identity", async () => {
</code_context>
<issue_to_address>
**suggestion (testing):** Add a test for the generic guard() API with a non-role-based predicate

Since `guard()` is the core authorization primitive (with `requireRoles` as a thin wrapper), please add a test that wires up `auth.guard(identity => identity.userId === "some-id")` to a route and asserts:

- A request whose `userId` does not match gets a 403
- A request with the matching `userId` succeeds

This will exercise the generic guard path (including `c.get("identity")`, the default 403, and `WRAP_AUTH_MIDDLEWARE` tagging) so we catch regressions even if `requireRoles` is refactored.

Suggested implementation:

```typescript
describe("JwtCookieAuthController", () => {
  it("rejects requests without a token", async () => {
    const auth = new JwtCookieAuthController({ secret: SECRET });
    const res = await buildApp(auth).request("/private");
    expect(res.status).toBe(401);
  });

  it("accepts a bearer token and exposes the identity", async () => {
    const auth = new JwtCookieAuthController({ secret: SECRET });
    const { token } = await loginAs(auth, "u1", UserRoles.ADMIN);

    const res = await buildApp(auth).request("/private", {
      headers: { Authorization: `Bearer ${token}` },
    });
    expect(res.status).toBe(200);

    const body = (await res.json()) as {
      identity: { userId: string; role: string };
    };

    expect(body.identity.userId).toBe("u1");
    expect(body.identity.role).toBe(UserRoles.ADMIN);
  });

  it("applies generic guard() predicates and returns 403 by default", async () => {
    const auth = new JwtCookieAuthController({ secret: SECRET });

    // Predicate only allows a specific userId
    const predicate = (identity: { userId: string }) => identity.userId === "allowed-user";

    const app = buildAppWithGuard(auth, predicate);

    // First, a user that does NOT match the predicate should be rejected with 403
    const { token: otherToken } = await loginAs(auth, "other-user", UserRoles.ADMIN);
    const forbiddenResponse = await app.request("/guarded", {
      headers: { Authorization: `Bearer ${otherToken}` },
    });
    expect(forbiddenResponse.status).toBe(403);

    // Then, a user that matches the predicate should be allowed
    const { token: allowedToken } = await loginAs(auth, "allowed-user", UserRoles.ADMIN);
    const okResponse = await app.request("/guarded", {
      headers: { Authorization: `Bearer ${allowedToken}` },
    });
    expect(okResponse.status).toBe(200);
  });

```

To fully implement `buildAppWithGuard` so this test exercises the generic `guard()` path (including `c.get("identity")`, the default 403, and `WRAP_AUTH_MIDDLEWARE` tagging), you should:

1. **Define `buildAppWithGuard` in this test file** (near `buildApp` / `loginAs`) roughly as:
   - Create the app using the same framework used by `buildApp` (e.g. `new Hono()`).
   - Apply your auth middleware (`WRAP_AUTH_MIDDLEWARE`) in the same way `buildApp` does.
   - Register a route `/guarded` with `auth.guard(predicate)` before the handler, e.g.:

   ```ts
   function buildAppWithGuard(
     auth: JwtCookieAuthController,
     predicate: (identity: { userId: string }) => boolean,
   ) {
     const app = new Hono();

     // This should mirror how buildApp wires the auth middleware,
     // including any WRAP_AUTH_MIDDLEWARE tagging.
     app.use("*", auth.wrap());

     app.get(
       "/guarded",
       auth.guard(predicate),
       (c) => {
         const identity = c.get("identity");
         return c.json({ ok: true, identity });
       },
     );

     return app;
   }
   ```

   Adjust `Hono`, `auth.wrap()`, or middleware wiring to match how `buildApp` is currently implemented in your codebase.

2. **Ensure the default 403 behavior is exercised**:
   - The `auth.guard` implementation should *not* call `next()` when the predicate fails and should set a 403 status (or delegate to a shared `forbidden` helper). The first part of the test (`other-user`) is asserting that.
   - The route handler should only be reached when `auth.guard` calls `next()` for an allowed identity (`allowed-user`).

3. **Reuse existing imports/conventions**:
   - Import `Hono` or the equivalent app type only if this test file isn’t already importing it.
   - Use the same `WRAP_AUTH_MIDDLEWARE` tagging mechanism as in `buildApp` so that the test truly covers that path.

These adjustments will ensure the new test validates the generic `guard()` authorization primitive independently of role-based helpers like `requireRoles`.
</issue_to_address>

### Comment 5
<location path="packages/create-wrap/template/tests/auth.test.ts" line_range="69-80" />
<code_context>
+    expect(body.identity.role).toBe(UserRoles.ADMIN);
+  });
+
+  it("accepts a session cookie and slides (refreshes) it", async () => {
+    const auth = new JwtCookieAuthController({ secret: SECRET });
+    const { cookie } = await loginAs(auth, "u2", UserRoles.USER);
+    expect(cookie).toContain("session=");
+
+    const res = await buildApp(auth).request("/private", {
+      headers: { Cookie: cookie },
+    });
+    expect(res.status).toBe(200);
+    // authMiddleware re-signs and refreshes the cookie on every request.
</code_context>
<issue_to_address>
**suggestion (testing):** Consider asserting that identity is set on the context for cookie-based auth as well

To fully verify cookie-based auth from the app’s perspective, also assert that `identity` is populated on the cookie-based `/private` request. For example, parse the JSON body and check that `body.identity.userId` and `body.identity.role` match the logged-in user, mirroring the bearer-token test and exercising `setupCookieSession`’s `c.set("identity", ...)` in this flow.

```suggestion
  it("accepts a session cookie and slides (refreshes) it", async () => {
    const auth = new JwtCookieAuthController({ secret: SECRET });
    const { cookie } = await loginAs(auth, "u2", UserRoles.USER);
    expect(cookie).toContain("session=");

    const res = await buildApp(auth).request("/private", {
      headers: { Cookie: cookie },
    });
    expect(res.status).toBe(200);

    const body = (await res.json()) as {
      identity: { userId: string; role: string };
    };
    expect(body.identity.userId).toBe("u2");
    expect(body.identity.role).toBe(UserRoles.USER);

    // authMiddleware re-signs and refreshes the cookie on every request.
    expect(res.headers.get("set-cookie")).toContain("session=");
  });
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread packages/create-wrap/template/src/middleware/auth.ts Outdated
Comment thread packages/wrap/src/decorators/validation.ts Outdated
Comment thread packages/wrap/src/base.repository.ts
app.post("/logout", (c) => {
auth.revoke(c);
return c.json({ ok: true });
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add a test for the generic guard() API with a non-role-based predicate

Since guard() is the core authorization primitive (with requireRoles as a thin wrapper), please add a test that wires up auth.guard(identity => identity.userId === "some-id") to a route and asserts:

  • A request whose userId does not match gets a 403
  • A request with the matching userId succeeds

This will exercise the generic guard path (including c.get("identity"), the default 403, and WRAP_AUTH_MIDDLEWARE tagging) so we catch regressions even if requireRoles is refactored.

Suggested implementation:

describe("JwtCookieAuthController", () => {
  it("rejects requests without a token", async () => {
    const auth = new JwtCookieAuthController({ secret: SECRET });
    const res = await buildApp(auth).request("/private");
    expect(res.status).toBe(401);
  });

  it("accepts a bearer token and exposes the identity", async () => {
    const auth = new JwtCookieAuthController({ secret: SECRET });
    const { token } = await loginAs(auth, "u1", UserRoles.ADMIN);

    const res = await buildApp(auth).request("/private", {
      headers: { Authorization: `Bearer ${token}` },
    });
    expect(res.status).toBe(200);

    const body = (await res.json()) as {
      identity: { userId: string; role: string };
    };

    expect(body.identity.userId).toBe("u1");
    expect(body.identity.role).toBe(UserRoles.ADMIN);
  });

  it("applies generic guard() predicates and returns 403 by default", async () => {
    const auth = new JwtCookieAuthController({ secret: SECRET });

    // Predicate only allows a specific userId
    const predicate = (identity: { userId: string }) => identity.userId === "allowed-user";

    const app = buildAppWithGuard(auth, predicate);

    // First, a user that does NOT match the predicate should be rejected with 403
    const { token: otherToken } = await loginAs(auth, "other-user", UserRoles.ADMIN);
    const forbiddenResponse = await app.request("/guarded", {
      headers: { Authorization: `Bearer ${otherToken}` },
    });
    expect(forbiddenResponse.status).toBe(403);

    // Then, a user that matches the predicate should be allowed
    const { token: allowedToken } = await loginAs(auth, "allowed-user", UserRoles.ADMIN);
    const okResponse = await app.request("/guarded", {
      headers: { Authorization: `Bearer ${allowedToken}` },
    });
    expect(okResponse.status).toBe(200);
  });

To fully implement buildAppWithGuard so this test exercises the generic guard() path (including c.get("identity"), the default 403, and WRAP_AUTH_MIDDLEWARE tagging), you should:

  1. Define buildAppWithGuard in this test file (near buildApp / loginAs) roughly as:

    • Create the app using the same framework used by buildApp (e.g. new Hono()).
    • Apply your auth middleware (WRAP_AUTH_MIDDLEWARE) in the same way buildApp does.
    • Register a route /guarded with auth.guard(predicate) before the handler, e.g.:
    function buildAppWithGuard(
      auth: JwtCookieAuthController,
      predicate: (identity: { userId: string }) => boolean,
    ) {
      const app = new Hono();
    
      // This should mirror how buildApp wires the auth middleware,
      // including any WRAP_AUTH_MIDDLEWARE tagging.
      app.use("*", auth.wrap());
    
      app.get(
        "/guarded",
        auth.guard(predicate),
        (c) => {
          const identity = c.get("identity");
          return c.json({ ok: true, identity });
        },
      );
    
      return app;
    }

    Adjust Hono, auth.wrap(), or middleware wiring to match how buildApp is currently implemented in your codebase.

  2. Ensure the default 403 behavior is exercised:

    • The auth.guard implementation should not call next() when the predicate fails and should set a 403 status (or delegate to a shared forbidden helper). The first part of the test (other-user) is asserting that.
    • The route handler should only be reached when auth.guard calls next() for an allowed identity (allowed-user).
  3. Reuse existing imports/conventions:

    • Import Hono or the equivalent app type only if this test file isn’t already importing it.
    • Use the same WRAP_AUTH_MIDDLEWARE tagging mechanism as in buildApp so that the test truly covers that path.

These adjustments will ensure the new test validates the generic guard() authorization primitive independently of role-based helpers like requireRoles.

Comment on lines +69 to +80
it("accepts a session cookie and slides (refreshes) it", async () => {
const auth = new JwtCookieAuthController({ secret: SECRET });
const { cookie } = await loginAs(auth, "u2", UserRoles.USER);
expect(cookie).toContain("session=");

const res = await buildApp(auth).request("/private", {
headers: { Cookie: cookie },
});
expect(res.status).toBe(200);
// authMiddleware re-signs and refreshes the cookie on every request.
expect(res.headers.get("set-cookie")).toContain("session=");
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider asserting that identity is set on the context for cookie-based auth as well

To fully verify cookie-based auth from the app’s perspective, also assert that identity is populated on the cookie-based /private request. For example, parse the JSON body and check that body.identity.userId and body.identity.role match the logged-in user, mirroring the bearer-token test and exercising setupCookieSession’s c.set("identity", ...) in this flow.

Suggested change
it("accepts a session cookie and slides (refreshes) it", async () => {
const auth = new JwtCookieAuthController({ secret: SECRET });
const { cookie } = await loginAs(auth, "u2", UserRoles.USER);
expect(cookie).toContain("session=");
const res = await buildApp(auth).request("/private", {
headers: { Cookie: cookie },
});
expect(res.status).toBe(200);
// authMiddleware re-signs and refreshes the cookie on every request.
expect(res.headers.get("set-cookie")).toContain("session=");
});
it("accepts a session cookie and slides (refreshes) it", async () => {
const auth = new JwtCookieAuthController({ secret: SECRET });
const { cookie } = await loginAs(auth, "u2", UserRoles.USER);
expect(cookie).toContain("session=");
const res = await buildApp(auth).request("/private", {
headers: { Cookie: cookie },
});
expect(res.status).toBe(200);
const body = (await res.json()) as {
identity: { userId: string; role: string };
};
expect(body.identity.userId).toBe("u2");
expect(body.identity.role).toBe(UserRoles.USER);
// authMiddleware re-signs and refreshes the cookie on every request.
expect(res.headers.get("set-cookie")).toContain("session=");
});

DoniLite added 5 commits July 8, 2026 10:25
…eware

export const authMiddleware = auth; exported the JwtCookieAuthController
instance itself instead of auth.authMiddleware — any code doing
app.use("/admin/*", authMiddleware) would receive an object instead of
a function and fail at runtime. Regression from the AuthController
rewrite, caught by Sourcery's review on PR #1.
…umeration

Object.keys(arg.req).includes(provider) only matches when the property
is own-enumerable. testContext()'s req methods happen to be, but a
custom test double using getters, Object.defineProperty, or a
differently-shaped mock wouldn't be detected. Checking
typeof arg.req[provider] === "function" verifies the same thing
(the double can actually produce a body) without depending on how the
property happens to be defined. Flagged by Sourcery's review on PR #1.
new Date(cursor) silently produces an Invalid Date on a malformed
string, which would then feed a comparison the driver may throw on or
silently mis-evaluate — findChangedSince and applyBatch both now fail
fast with a clear error instead. applyBatch also processed its changes
as separate, independent statements: an exception partway through
(e.g. a constraint violation on change N) left every change already
applied in the batch committed, with the rest never running — a
partially-applied sync batch. The whole batch now runs inside
withTransaction(), so a failure rolls back everything applied so far
in that call; conflicts don't throw, so they still land in the same
commit as whatever else in the batch succeeded. Both flagged by
Sourcery's review on PR #1.

Co-Auth
…allback chain

Lets an app mix strategies instead of picking exactly one: e.g. cookie
sessions for browser requests, falling back to a legacy/API-key
controller for clients that can't store cookies.
AuthController.combine(...controllers) returns a controller whose
authenticate() tries each delegate in turn and returns the first
non-null identity; revoke() runs on every delegate (best-effort, same
no-op-when-inapplicable contract every preset's own revoke() already
needs); openApiSecurityScheme() merges every delegate's schemes.
Because combining only needs the public AuthController contract, the
delegates can come from entirely separate packages — this is meant to
be the seam a community of auth-paradigm packages hangs off of.

Merging security schemes from a dynamic set of delegates is exactly
why openApiSecurityScheme() moves from static to an instance method: a
static method has no way to reach a specific combined instance's list
of wrapped controllers, only the class. SwaggerGenerator simplifies
along with it — it now just holds the AuthController instance directly
instead of extracting and re-typing its constructor (the
AuthControllerClass structural type this replaces existed solely to
work around the static method).

Also swaps Wrap.with()'s `instanceof AuthController` for a branded
`isAuthController()` check (Sourcery flagged this on PR #1): instanceof
breaks if two copies of this module ever end up loaded (mismatched
nested dependency versions, separate bundles) since each copy's class
reference is a different object. The brand uses Symbol.for (the global
symbol registry) instead of a module-scoped Symbol() for the same
reason — WRAP_AUTH_MIDDLEWARE (swagger's route-tagging symbol) moves
to Symbol.for too, for consistency and the same resilience.
auth.test.ts previously only exercised requireRoles (the role-based
convenience) and never the generic guard() primitive it's built on —
added a route guarded by an arbitrary userId predicate and a test
asserting the 403/200 split independent of roles, plus identity
assertions on the cookie-session request (was only asserted for
bearer). Both gaps flagged by Sourcery's review on PR #1.

auth.combine.test.ts covers the new combine() feature end-to-end: a
hand-rolled LegacyHeaderAuthController (deliberately not part of the
framework, standing in for a third-party paradigm package) combined
with JwtCookieAuthController — authenticating via whichever delegate
resolves an identity, falling back correctly when the first can't,
rejecting when neither can, revoke() reaching every delegate, merged
security schemes, and the empty-call guard.

@DoniLite DoniLite left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍🏽

@DoniLite
DoniLite merged commit 82f3ee1 into main Jul 8, 2026
2 checks passed
@DoniLite
DoniLite deleted the feat/improvement-for-v-0-2 branch July 21, 2026 06:45
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