From e43494e316d7b5bf4900d4b968c02b23a858a7f0 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 1 Sep 2026 15:41:28 -0600 Subject: [PATCH 1/3] docs(resources): await the body and context in static verb examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four v5 reference examples taught patterns that fail silently on Harper 5. Overriding a static verb replaces the transactional() wrapper, so the record or body arrives as an unresolved promise and every field reads undefined instead of raising (harper resources/DESIGN.md -> Conventions). - database/schema.md blob error handling: `super.get(target)` was not awaited, so `record.data` was undefined and `blob.on('error', ...)` threw on every read. The equivalent example in database/api.md already awaits it. - database/api.md base64-in-JSON: the `if (record.data)` branch never ran, so `super.post` stored the raw base64 string. Await the body once and forward it. - resources/overview.md extending a table: `this.create(...)` was neither awaited nor returned, so POST responded before the commit and a rejected create became an unhandled rejection. - resources/resource-api.md sign-in/sign-out and getCurrentUser: instance verbs written with the static argument order. An unflagged instance `post` is dispatched as `post(data, query)` (harper resources/Resource.ts), so `data.username` was always undefined. Converted to the static form the rest of the page prescribes, taking the context as a parameter — `getContext()` and `getCurrentUser()` are instance methods with no static counterpart, so the context arrives as `(target, context)` / `(target, data, context)`, matching core's own login.ts and security/jwt-authentication.md. Refs HarperFast/skills#79 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019drMJUhfEQfeNVDcU22oNe --- reference/database/api.md | 11 ++++++----- reference/database/schema.md | 2 +- reference/resources/overview.md | 4 ++-- reference/resources/resource-api.md | 19 ++++++++++--------- 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/reference/database/api.md b/reference/database/api.md index 16d373e30..72e376a3c 100644 --- a/reference/database/api.md +++ b/reference/database/api.md @@ -234,19 +234,20 @@ await Photo.put({ id, data: blob }); ### Accepting Binary in JSON Requests -REST clients that can't post raw binary typically send base64 inside JSON. Decode in the resource override and wrap with `createBlob`, recording the MIME type so it round-trips on read: +REST clients that can't post raw binary typically send base64 inside JSON. Decode in the resource override and wrap with `createBlob`, recording the MIME type so it round-trips on read. The `record` parameter is a promise for the request body, so `await` it once before inspecting it — reading fields off the unresolved promise silently yields `undefined` and stores the raw base64 string: ```typescript import { type RequestTargetOrId, tables, createBlob } from 'harper'; export class Photo extends tables.Photo { static async post(target: RequestTargetOrId, record: any) { - if (record.data) { - record.data = createBlob(Buffer.from(record.data, record.encoding || 'base64'), { - type: record.contentType || 'application/octet-stream', + const body = await record; + if (body.data) { + body.data = createBlob(Buffer.from(body.data, body.encoding || 'base64'), { + type: body.contentType || 'application/octet-stream', }); } - return super.post(target, record); + return super.post(target, body); } } ``` diff --git a/reference/database/schema.md b/reference/database/schema.md index ffadf06e3..fa0fab7cd 100644 --- a/reference/database/schema.md +++ b/reference/database/schema.md @@ -866,7 +866,7 @@ When returning a blob via REST, register an error handler to handle interrupted ```javascript export class MyEndpoint extends MyTable { static async get(target) { - const record = super.get(target); + const record = await super.get(target); let blob = record.data; blob.on('error', () => { MyTable.invalidate(target); diff --git a/reference/resources/overview.md b/reference/resources/overview.md index d21b74d6d..d0574af6b 100644 --- a/reference/resources/overview.md +++ b/reference/resources/overview.md @@ -55,8 +55,8 @@ export class MyTable extends tables.MyTable { } static async post(target, data) { - // custom action on POST - this.create({ ...(await data), status: 'pending' }); + // custom action on POST; return the write so the response waits for the commit + return this.create({ ...(await data), status: 'pending' }); } } ``` diff --git a/reference/resources/resource-api.md b/reference/resources/resource-api.md index a52683332..03441da2d 100644 --- a/reference/resources/resource-api.md +++ b/reference/resources/resource-api.md @@ -707,6 +707,8 @@ Returns the current context, which includes: - `user` — User object with username, role, and authorization information - `transaction` — The current transaction +`getContext()` is a Resource instance method. A static verb is passed the same context as an argument instead — `(target, context)` for `get`/`delete`, `(target, data, context)` for `put`/`patch`/`post` — or it can use the [`getContext()` export](#getcontext-context-1) from the `harper` module. + When triggered by HTTP, the context is the `Request` object with these additional properties: - `url` — Full local path including query string @@ -731,11 +733,11 @@ Executes a Harper operations API call using this table as the target. Set `autho ### `getCurrentUser(): User | undefined` -Returns the user associated with the current request, or `undefined` if no user is authenticated. The returned object exposes the username, role, and `role.permission` flags. +Returns the user associated with the current request, or `undefined` if no user is authenticated. The returned object exposes the username, role, and `role.permission` flags. This is a Resource **instance** method; a static verb reads the same user from the context it is passed, as `context.user`: ```javascript -async get(target) { - const user = this.getCurrentUser(); +static async get(_target, context) { + const user = context.user; if (!user) return new Response(null, { status: 401 }); return { username: user.username, role: user.role }; } @@ -745,14 +747,14 @@ async get(target) { ### Session and Login from a Resource -The context returned by `getContext()` exposes `login` and `session` for handling sign-in/out flows in a custom Resource. Sessions require `authentication.enableSessions: true` in `harperdb-config.yaml`. +The request context exposes `login` and `session` for handling sign-in/out flows in a custom Resource. A static verb receives it as its trailing argument — `(target, data, context)` for `post`. Sessions require `authentication.enableSessions: true` in `harperdb-config.yaml`. ```typescript export class SignIn extends Resource { - async post(_target, data) { - const context = this.getContext(); + static async post(_target, data, context) { + const { username, password } = await data; try { - await context.login(data.username, data.password); + await context.login(username, password); } catch { return new Response('Invalid credentials', { status: 403 }); } @@ -761,8 +763,7 @@ export class SignIn extends Resource { } export class SignOut extends Resource { - async post() { - const context = this.getContext(); + static async post(_target, _data, context) { if (!context.session) return new Response(null, { status: 401 }); await context.session.delete(context.session.id); return new Response('Logged out', { status: 200 }); From 8db0609ec47f000c51a2271db762fbad4e03889e Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 1 Sep 2026 15:56:28 -0600 Subject: [PATCH 2/3] docs(resources): correct the sign-out example's session API Independent pre-push review found a second defect in the same block. `request.session` carries only `update` (harper security/auth.ts:279); there is no runtime `delete`, and core's own logout ends a session with `session.update({ user: null })` (security/auth.ts:444). The guard was also dead: `request.session` is set to `{}` rather than left undefined when sessions are enabled and no cookie is present (security/auth.ts:126), so `!context.session` never fired and an unauthenticated POST reached the nonexistent `delete` for a 500 instead of the documented 401. Also narrows the static-context note: a direct server-side call supplies a context argument only if the caller passes one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019drMJUhfEQfeNVDcU22oNe --- reference/resources/resource-api.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/reference/resources/resource-api.md b/reference/resources/resource-api.md index 03441da2d..81c367222 100644 --- a/reference/resources/resource-api.md +++ b/reference/resources/resource-api.md @@ -707,7 +707,7 @@ Returns the current context, which includes: - `user` — User object with username, role, and authorization information - `transaction` — The current transaction -`getContext()` is a Resource instance method. A static verb is passed the same context as an argument instead — `(target, context)` for `get`/`delete`, `(target, data, context)` for `put`/`patch`/`post` — or it can use the [`getContext()` export](#getcontext-context-1) from the `harper` module. +`getContext()` is a Resource instance method. A static verb is not an instance, so it reads the context from the trailing argument the request path passes it — `(target, context)` for `get`/`delete`, `(target, data, context)` for `put`/`patch`/`post`. A direct server-side call supplies that argument only if the caller passes one, so use the [`getContext()` export](#getcontext-context-1) from the `harper` module when a static method must work on both paths. When triggered by HTTP, the context is the `Request` object with these additional properties: @@ -764,14 +764,14 @@ export class SignIn extends Resource { export class SignOut extends Resource { static async post(_target, _data, context) { - if (!context.session) return new Response(null, { status: 401 }); - await context.session.delete(context.session.id); + if (!context.session?.user) return new Response(null, { status: 401 }); + await context.session.update({ user: null }); return new Response('Logged out', { status: 200 }); } } ``` -`context.login(username, password)` verifies credentials and establishes the session cookie on success. To end a session, delete it via `context.session.delete(context.session.id)`. +`context.login(username, password)` verifies credentials and establishes the session cookie on success. To end a session, clear its user with `context.session.update({ user: null })` — [`update`](../http/api.md#properties) is the session's only mutator. `context.session` is an empty object rather than `undefined` when sessions are enabled and the request carries no session cookie, so test `context.session?.user` to detect an established session. Cookie-based sessions are intended for browser clients. For non-browser clients (CLI tools, mobile apps, service-to-service), use JWT issuance — see [JWT Authentication](../security/jwt-authentication.md). From a15776bad6259252a130bbaf68f5eb8484e211e9 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 1 Sep 2026 22:53:57 -0600 Subject: [PATCH 3/3] docs(resources): guard nullish bodies and contexts in the static verb examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on the five examples this PR already touches. Each guard covers a state the documented dispatch path can actually reach — `server/REST.ts:215` assigns `request.data` only when the request carries `content-length` or `transfer-encoding`, so a body-less POST really does await to `undefined`. - api.md answers a missing body with a 400 rather than optional-chaining past it. Forwarding a nullish body to `super.post(target, body)` would land in `transactional()`'s single-argument branch (`resources/Resource.ts:589-603`) and store the `RequestTarget` as the record — the same silent failure this PR exists to remove. - schema.md guards the missing record and returns it early. Optional chaining alone would answer a missing record with a 200 and an empty body; a nullish GET return is what REST turns into a 404 (`server/REST.ts:283`). The sibling "Serving Binary from a Resource" example in api.md already reads this way. - `(await data) ?? {}` (resource-api.md) is verbatim what core's own static sign-in does (`resources/login.ts`), and without it an empty POST to the sign-in endpoint is a destructuring TypeError instead of a 403. - `context?.` in the `getCurrentUser` and `SignOut` examples — a static verb called from server-side code gets a context only if the caller passes one, as the `getContext()` entry above them already explains. Both guards were already returning 401 for "no authenticated user", which is the honest answer when there is no context either. Co-Authored-By: Claude Opus --- reference/database/api.md | 1 + reference/database/schema.md | 3 ++- reference/resources/resource-api.md | 8 ++++---- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/reference/database/api.md b/reference/database/api.md index 72e376a3c..6b15e3f4d 100644 --- a/reference/database/api.md +++ b/reference/database/api.md @@ -242,6 +242,7 @@ import { type RequestTargetOrId, tables, createBlob } from 'harper'; export class Photo extends tables.Photo { static async post(target: RequestTargetOrId, record: any) { const body = await record; + if (!body) return new Response('A JSON body is required', { status: 400 }); if (body.data) { body.data = createBlob(Buffer.from(body.data, body.encoding || 'base64'), { type: body.contentType || 'application/octet-stream', diff --git a/reference/database/schema.md b/reference/database/schema.md index fa0fab7cd..ef96ecd9d 100644 --- a/reference/database/schema.md +++ b/reference/database/schema.md @@ -867,7 +867,8 @@ When returning a blob via REST, register an error handler to handle interrupted export class MyEndpoint extends MyTable { static async get(target) { const record = await super.get(target); - let blob = record.data; + let blob = record?.data; + if (!blob) return record; blob.on('error', () => { MyTable.invalidate(target); }); diff --git a/reference/resources/resource-api.md b/reference/resources/resource-api.md index 81c367222..b64c6e7df 100644 --- a/reference/resources/resource-api.md +++ b/reference/resources/resource-api.md @@ -737,7 +737,7 @@ Returns the user associated with the current request, or `undefined` if no user ```javascript static async get(_target, context) { - const user = context.user; + const user = context?.user; if (!user) return new Response(null, { status: 401 }); return { username: user.username, role: user.role }; } @@ -752,7 +752,7 @@ The request context exposes `login` and `session` for handling sign-in/out flows ```typescript export class SignIn extends Resource { static async post(_target, data, context) { - const { username, password } = await data; + const { username, password } = (await data) ?? {}; try { await context.login(username, password); } catch { @@ -764,14 +764,14 @@ export class SignIn extends Resource { export class SignOut extends Resource { static async post(_target, _data, context) { - if (!context.session?.user) return new Response(null, { status: 401 }); + if (!context?.session?.user) return new Response(null, { status: 401 }); await context.session.update({ user: null }); return new Response('Logged out', { status: 200 }); } } ``` -`context.login(username, password)` verifies credentials and establishes the session cookie on success. To end a session, clear its user with `context.session.update({ user: null })` — [`update`](../http/api.md#properties) is the session's only mutator. `context.session` is an empty object rather than `undefined` when sessions are enabled and the request carries no session cookie, so test `context.session?.user` to detect an established session. +`context.login(username, password)` verifies credentials and establishes the session cookie on success. To end a session, clear its user with `context.session.update({ user: null })` — [`update`](../http/api.md#properties) is the session's only mutator. `context.session` is an empty object rather than `undefined` when sessions are enabled and the request carries no session cookie, so test `context?.session?.user` to detect an established session. Cookie-based sessions are intended for browser clients. For non-browser clients (CLI tools, mobile apps, service-to-service), use JWT issuance — see [JWT Authentication](../security/jwt-authentication.md).