vintasend-api 0.0.0-stage → 1.0.0-alpha6
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 +21 -0
- package/README.md +273 -2
- package/dist/app.d.ts +33 -0
- package/dist/app.js +33 -0
- package/dist/app.js.map +1 -0
- package/dist/config.d.ts +13 -0
- package/dist/config.js +39 -0
- package/dist/config.js.map +1 -0
- package/dist/contract/types.d.ts +196 -0
- package/dist/contract/types.js +12 -0
- package/dist/contract/types.js.map +1 -0
- package/dist/domain/capabilities.d.ts +13 -0
- package/dist/domain/capabilities.js +19 -0
- package/dist/domain/capabilities.js.map +1 -0
- package/dist/domain/filters.d.ts +16 -0
- package/dist/domain/filters.js +82 -0
- package/dist/domain/filters.js.map +1 -0
- package/dist/domain/pagination.d.ts +20 -0
- package/dist/domain/pagination.js +24 -0
- package/dist/domain/pagination.js.map +1 -0
- package/dist/domain/schemas.d.ts +90 -0
- package/dist/domain/schemas.js +52 -0
- package/dist/domain/schemas.js.map +1 -0
- package/dist/domain/serialize.d.ts +19 -0
- package/dist/domain/serialize.js +101 -0
- package/dist/domain/serialize.js.map +1 -0
- package/dist/errors.d.ts +45 -0
- package/dist/errors.js +94 -0
- package/dist/errors.js.map +1 -0
- package/dist/exports.d.ts +14 -0
- package/dist/exports.js +14 -0
- package/dist/exports.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/authenticate.d.ts +34 -0
- package/dist/middleware/authenticate.js +68 -0
- package/dist/middleware/authenticate.js.map +1 -0
- package/dist/middleware/error-handler.d.ts +35 -0
- package/dist/middleware/error-handler.js +89 -0
- package/dist/middleware/error-handler.js.map +1 -0
- package/dist/routes/notifications.d.ts +14 -0
- package/dist/routes/notifications.js +119 -0
- package/dist/routes/notifications.js.map +1 -0
- package/dist/routes/validation.d.ts +14 -0
- package/dist/routes/validation.js +44 -0
- package/dist/routes/validation.js.map +1 -0
- package/dist/services/github-template-client.d.ts +26 -0
- package/dist/services/github-template-client.js +148 -0
- package/dist/services/github-template-client.js.map +1 -0
- package/dist/services/github-template-preview-config.d.ts +8 -0
- package/dist/services/github-template-preview-config.js +51 -0
- package/dist/services/github-template-preview-config.js.map +1 -0
- package/dist/services/notification-preview.d.ts +22 -0
- package/dist/services/notification-preview.js +66 -0
- package/dist/services/notification-preview.js.map +1 -0
- package/dist/services/notification-service-port.d.ts +40 -0
- package/dist/services/notification-service-port.js +19 -0
- package/dist/services/notification-service-port.js.map +1 -0
- package/dist/services/paged-notification-reader.d.ts +22 -0
- package/dist/services/paged-notification-reader.js +36 -0
- package/dist/services/paged-notification-reader.js.map +1 -0
- package/dist/services/service-loader.d.ts +15 -0
- package/dist/services/service-loader.js +56 -0
- package/dist/services/service-loader.js.map +1 -0
- package/dist/services/template-path-resolver.d.ts +4 -0
- package/dist/services/template-path-resolver.js +17 -0
- package/dist/services/template-path-resolver.js.map +1 -0
- package/openapi.yaml +666 -0
- package/package.json +66 -4
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Server entrypoint: wires environment configuration into the app and listens.
|
|
4
|
+
*
|
|
5
|
+
* Installed as the package's `vintasend-api` command, for running the API on its own. A host that
|
|
6
|
+
* already has a server mounts `createApp` instead and never loads this file.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Server entrypoint: wires environment configuration into the app and listens.
|
|
4
|
+
*
|
|
5
|
+
* Installed as the package's `vintasend-api` command, for running the API on its own. A host that
|
|
6
|
+
* already has a server mounts `createApp` instead and never loads this file.
|
|
7
|
+
*/
|
|
8
|
+
import { serve } from '@hono/node-server';
|
|
9
|
+
import { createApp } from './app.js';
|
|
10
|
+
import { loadServerConfig } from './config.js';
|
|
11
|
+
import { apiKeyAuthenticator } from './middleware/authenticate.js';
|
|
12
|
+
import { createGitHubTemplateClientFromEnv } from './services/github-template-client.js';
|
|
13
|
+
import { createServiceProvider } from './services/service-loader.js';
|
|
14
|
+
async function main() {
|
|
15
|
+
const config = loadServerConfig();
|
|
16
|
+
const getService = createServiceProvider(config.serviceModule);
|
|
17
|
+
const app = createApp({
|
|
18
|
+
authenticate: apiKeyAuthenticator(config.apiKey),
|
|
19
|
+
getService,
|
|
20
|
+
getTemplateClient: () => createGitHubTemplateClientFromEnv(),
|
|
21
|
+
backendIdentifier: config.backendIdentifier,
|
|
22
|
+
corsOrigins: config.corsOrigins,
|
|
23
|
+
});
|
|
24
|
+
// Fail fast on a broken service module instead of surfacing it per request.
|
|
25
|
+
await getService();
|
|
26
|
+
serve({ fetch: app.fetch, port: config.port, hostname: config.host }, (info) => {
|
|
27
|
+
console.info(`[vintasend-api] listening on http://${config.host}:${info.port}`);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
main().catch((error) => {
|
|
31
|
+
console.error('[vintasend-api] failed to start', error);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
});
|
|
34
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA;;;;;GAKG;AAEH,OAAO,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAE1C,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AACrC,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,EAAE,iCAAiC,EAAE,MAAM,sCAAsC,CAAC;AACzF,OAAO,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AAErE,KAAK,UAAU,IAAI;IACjB,MAAM,MAAM,GAAG,gBAAgB,EAAE,CAAC;IAClC,MAAM,UAAU,GAAG,qBAAqB,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC;IAE/D,MAAM,GAAG,GAAG,SAAS,CAAC;QACpB,YAAY,EAAE,mBAAmB,CAAC,MAAM,CAAC,MAAM,CAAC;QAChD,UAAU;QACV,iBAAiB,EAAE,GAAG,EAAE,CAAC,iCAAiC,EAAE;QAC5D,iBAAiB,EAAE,MAAM,CAAC,iBAAiB;QAC3C,WAAW,EAAE,MAAM,CAAC,WAAW;KAChC,CAAC,CAAC;IAEH,4EAA4E;IAC5E,MAAM,UAAU,EAAE,CAAC;IAEnB,KAAK,CAAC,EAAE,KAAK,EAAE,GAAG,CAAC,KAAK,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,EAAE;QAC7E,OAAO,CAAC,IAAI,CAAC,uCAAuC,MAAM,CAAC,IAAI,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;IAClF,CAAC,CAAC,CAAC;AACL,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,KAAK,EAAE,EAAE;IACrB,OAAO,CAAC,KAAK,CAAC,iCAAiC,EAAE,KAAK,CAAC,CAAC;IACxD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who is calling, and whether they may.
|
|
3
|
+
*
|
|
4
|
+
* Every `/api/v1` request goes through one `Authenticator` before it reaches a route. It refuses a
|
|
5
|
+
* caller by throwing — `ApiError.unauthorized` when no valid credential was presented,
|
|
6
|
+
* `ApiError.forbidden` when it knows who the caller is and they may not do this.
|
|
7
|
+
*
|
|
8
|
+
* The type is the one `vintasend-templates-management-api` takes, so a host mounting both APIs
|
|
9
|
+
* passes them the same function. This API records no attribution, so an `actor` it returns is
|
|
10
|
+
* available to the routes through `authenticated(c)` but nothing here writes it anywhere.
|
|
11
|
+
*
|
|
12
|
+
* A host that authenticates its own users passes an authenticator reading its session. A
|
|
13
|
+
* deployment with one shared secret passes `apiKeyAuthenticator(key)`.
|
|
14
|
+
*/
|
|
15
|
+
import type { Context, MiddlewareHandler } from 'hono';
|
|
16
|
+
/** What an authenticator learned about the caller. */
|
|
17
|
+
export type Authenticated = {
|
|
18
|
+
/**
|
|
19
|
+
* Who is calling, when the credential says. The templates management API records it on status
|
|
20
|
+
* changes; this one keeps no audit trail, so it is accepted and unused.
|
|
21
|
+
*/
|
|
22
|
+
actor?: string | null;
|
|
23
|
+
};
|
|
24
|
+
/** Refuses the caller by throwing an `ApiError`, or says who they are. */
|
|
25
|
+
export type Authenticator = (c: Context) => Authenticated | Promise<Authenticated>;
|
|
26
|
+
/** Runs `authenticate` on every request it guards, and keeps what it learned for the routes. */
|
|
27
|
+
export declare function authenticateWith(authenticate: Authenticator): MiddlewareHandler;
|
|
28
|
+
/** What the authenticator learned about this request's caller. */
|
|
29
|
+
export declare function authenticated(c: Context): Authenticated;
|
|
30
|
+
/**
|
|
31
|
+
* The shared-secret authenticator: every request must carry `Authorization: Bearer <apiKey>`.
|
|
32
|
+
* It says nothing about who is calling.
|
|
33
|
+
*/
|
|
34
|
+
export declare function apiKeyAuthenticator(apiKey: string): Authenticator;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who is calling, and whether they may.
|
|
3
|
+
*
|
|
4
|
+
* Every `/api/v1` request goes through one `Authenticator` before it reaches a route. It refuses a
|
|
5
|
+
* caller by throwing — `ApiError.unauthorized` when no valid credential was presented,
|
|
6
|
+
* `ApiError.forbidden` when it knows who the caller is and they may not do this.
|
|
7
|
+
*
|
|
8
|
+
* The type is the one `vintasend-templates-management-api` takes, so a host mounting both APIs
|
|
9
|
+
* passes them the same function. This API records no attribution, so an `actor` it returns is
|
|
10
|
+
* available to the routes through `authenticated(c)` but nothing here writes it anywhere.
|
|
11
|
+
*
|
|
12
|
+
* A host that authenticates its own users passes an authenticator reading its session. A
|
|
13
|
+
* deployment with one shared secret passes `apiKeyAuthenticator(key)`.
|
|
14
|
+
*/
|
|
15
|
+
import { ApiError } from '../errors.js';
|
|
16
|
+
const AUTHENTICATED = 'vintasend-templates-management-api.authenticated';
|
|
17
|
+
/** Runs `authenticate` on every request it guards, and keeps what it learned for the routes. */
|
|
18
|
+
export function authenticateWith(authenticate) {
|
|
19
|
+
return async (c, next) => {
|
|
20
|
+
c.set(AUTHENTICATED, await authenticate(c));
|
|
21
|
+
await next();
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** What the authenticator learned about this request's caller. */
|
|
25
|
+
export function authenticated(c) {
|
|
26
|
+
return c.get(AUTHENTICATED) ?? {};
|
|
27
|
+
}
|
|
28
|
+
const encoder = new TextEncoder();
|
|
29
|
+
async function sha256(value) {
|
|
30
|
+
return new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(value)));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Compare two secrets in time that depends on neither, so a wrong key cannot be found one
|
|
34
|
+
* character at a time.
|
|
35
|
+
*
|
|
36
|
+
* Both are hashed first, so the comparison is always of two 32-byte digests and the length of the
|
|
37
|
+
* expected key does not leak either. Web Crypto rather than `node:crypto`, so the app runs
|
|
38
|
+
* wherever `fetch` does.
|
|
39
|
+
*/
|
|
40
|
+
async function safeEquals(a, b) {
|
|
41
|
+
const [left, right] = await Promise.all([sha256(a), sha256(b)]);
|
|
42
|
+
let difference = 0;
|
|
43
|
+
left.forEach((byte, index) => {
|
|
44
|
+
difference |= byte ^ (right[index] ?? 0);
|
|
45
|
+
});
|
|
46
|
+
return difference === 0;
|
|
47
|
+
}
|
|
48
|
+
function extractToken(header) {
|
|
49
|
+
if (!header) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
const match = header.match(/^Bearer\s+(.+)$/i);
|
|
53
|
+
return match?.[1]?.trim();
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The shared-secret authenticator: every request must carry `Authorization: Bearer <apiKey>`.
|
|
57
|
+
* It says nothing about who is calling.
|
|
58
|
+
*/
|
|
59
|
+
export function apiKeyAuthenticator(apiKey) {
|
|
60
|
+
return async (c) => {
|
|
61
|
+
const token = extractToken(c.req.header('authorization'));
|
|
62
|
+
if (!token || !(await safeEquals(token, apiKey))) {
|
|
63
|
+
throw ApiError.unauthorized('A valid API key is required.');
|
|
64
|
+
}
|
|
65
|
+
return {};
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=authenticate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"authenticate.js","sourceRoot":"","sources":["../../src/middleware/authenticate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH,OAAO,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAcxC,MAAM,aAAa,GAAG,kDAAkD,CAAC;AAEzE,gGAAgG;AAChG,MAAM,UAAU,gBAAgB,CAAC,YAA2B;IAC1D,OAAO,KAAK,EAAE,CAAC,EAAE,IAAI,EAAE,EAAE;QACvB,CAAC,CAAC,GAAG,CAAC,aAAa,EAAE,MAAM,YAAY,CAAC,CAAC,CAAC,CAAC,CAAC;QAC5C,MAAM,IAAI,EAAE,CAAC;IACf,CAAC,CAAC;AACJ,CAAC;AAED,kEAAkE;AAClE,MAAM,UAAU,aAAa,CAAC,CAAU;IACtC,OAAQ,CAAC,CAAC,GAAG,CAAC,aAAa,CAA+B,IAAI,EAAE,CAAC;AACnE,CAAC;AAED,MAAM,OAAO,GAAG,IAAI,WAAW,EAAE,CAAC;AAElC,KAAK,UAAU,MAAM,CAAC,KAAa;IACjC,OAAO,IAAI,UAAU,CAAC,MAAM,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,EAAE,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;AACtF,CAAC;AAED;;;;;;;GAOG;AACH,KAAK,UAAU,UAAU,CAAC,CAAS,EAAE,CAAS;IAC5C,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAChE,IAAI,UAAU,GAAG,CAAC,CAAC;IACnB,IAAI,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE;QAC3B,UAAU,IAAI,IAAI,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC;IAC3C,CAAC,CAAC,CAAC;IACH,OAAO,UAAU,KAAK,CAAC,CAAC;AAC1B,CAAC;AAED,SAAS,YAAY,CAAC,MAA0B;IAC9C,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,OAAO,SAAS,CAAC;IACnB,CAAC;IAED,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,kBAAkB,CAAC,CAAC;IAC/C,OAAO,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC;AAC5B,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,mBAAmB,CAAC,MAAc;IAChD,OAAO,KAAK,EAAE,CAAC,EAAE,EAAE;QACjB,MAAM,KAAK,GAAG,YAAY,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC,CAAC;QAE1D,IAAI,CAAC,KAAK,IAAI,CAAC,CAAC,MAAM,UAAU,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,EAAE,CAAC;YACjD,MAAM,QAAQ,CAAC,YAAY,CAAC,8BAA8B,CAAC,CAAC;QAC9D,CAAC;QAED,OAAO,EAAE,CAAC;IACZ,CAAC,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps thrown errors to the contract's error envelope.
|
|
3
|
+
*
|
|
4
|
+
* Unexpected errors are reported generically, so backend internals never leak to a client. They
|
|
5
|
+
* are not logged whole either: an error from a notification backend or provider can quote
|
|
6
|
+
* notification content, recipients or context values, and the applications this API serves
|
|
7
|
+
* handle health data. The default log line names the error, a request id and the route, nothing
|
|
8
|
+
* else. A host that wants more — an error tracker with its own scrubbing, say — injects
|
|
9
|
+
* `onUnhandledError`.
|
|
10
|
+
*/
|
|
11
|
+
import type { Context, ErrorHandler } from 'hono';
|
|
12
|
+
/**
|
|
13
|
+
* Receives every error the API does not map to a contract error, before the generic 500 is sent.
|
|
14
|
+
*
|
|
15
|
+
* It is handed the error object itself, so whatever it does with it is the host's call — and the
|
|
16
|
+
* host's responsibility to keep health data out of its logs. `requestId` is the id the 500
|
|
17
|
+
* response carries in its `X-Request-Id` header, for matching a client's report to the log.
|
|
18
|
+
*/
|
|
19
|
+
export type UnhandledErrorHandler = (error: Error, c: Context, details: {
|
|
20
|
+
requestId: string;
|
|
21
|
+
}) => void | Promise<void>;
|
|
22
|
+
export declare const REQUEST_ID_HEADER = "x-request-id";
|
|
23
|
+
/** The caller's `X-Request-Id` when it is safe to log, otherwise a fresh one. */
|
|
24
|
+
export declare function requestIdFor(c: Context): string;
|
|
25
|
+
/**
|
|
26
|
+
* The default `onUnhandledError`: one line with the error's name, the request id and the matched
|
|
27
|
+
* route pattern — never the error's message, its stack, or the request.
|
|
28
|
+
*
|
|
29
|
+
* `name` rather than the constructor's name: some backends' errors come from minified bundles.
|
|
30
|
+
*/
|
|
31
|
+
export declare function logUnhandledError(error: Error, c: Context, { requestId }: {
|
|
32
|
+
requestId: string;
|
|
33
|
+
}): void;
|
|
34
|
+
export declare function createErrorHandler(onUnhandledError?: UnhandledErrorHandler): ErrorHandler;
|
|
35
|
+
export declare function handleNotFound(c: Context): Response;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps thrown errors to the contract's error envelope.
|
|
3
|
+
*
|
|
4
|
+
* Unexpected errors are reported generically, so backend internals never leak to a client. They
|
|
5
|
+
* are not logged whole either: an error from a notification backend or provider can quote
|
|
6
|
+
* notification content, recipients or context values, and the applications this API serves
|
|
7
|
+
* handle health data. The default log line names the error, a request id and the route, nothing
|
|
8
|
+
* else. A host that wants more — an error tracker with its own scrubbing, say — injects
|
|
9
|
+
* `onUnhandledError`.
|
|
10
|
+
*/
|
|
11
|
+
import { HTTPException } from 'hono/http-exception';
|
|
12
|
+
import { asApiError, invalidRequest } from '../errors.js';
|
|
13
|
+
export const REQUEST_ID_HEADER = 'x-request-id';
|
|
14
|
+
/** Only a request id that cannot break a log line out of its field is taken from the client. */
|
|
15
|
+
const SAFE_REQUEST_ID = /^[A-Za-z0-9._-]{1,128}$/;
|
|
16
|
+
/** The caller's `X-Request-Id` when it is safe to log, otherwise a fresh one. */
|
|
17
|
+
export function requestIdFor(c) {
|
|
18
|
+
const supplied = c.req.header(REQUEST_ID_HEADER);
|
|
19
|
+
return supplied !== undefined && SAFE_REQUEST_ID.test(supplied) ? supplied : crypto.randomUUID();
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The default `onUnhandledError`: one line with the error's name, the request id and the matched
|
|
23
|
+
* route pattern — never the error's message, its stack, or the request.
|
|
24
|
+
*
|
|
25
|
+
* `name` rather than the constructor's name: some backends' errors come from minified bundles.
|
|
26
|
+
*/
|
|
27
|
+
export function logUnhandledError(error, c, { requestId }) {
|
|
28
|
+
console.error(`[vintasend-api] unhandled ${error.name || 'Error'} (request ${requestId}) on ` +
|
|
29
|
+
`${c.req.method} ${c.req.routePath}`);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Hand `error` to the configured handler, falling back to `logUnhandledError` if it throws.
|
|
33
|
+
*
|
|
34
|
+
* What the handler threw is never logged — it is no safer than the error it was handling — and a
|
|
35
|
+
* broken logging setup must not turn a 500 into a crash.
|
|
36
|
+
*/
|
|
37
|
+
async function reportUnhandledError(handler, error, c, requestId) {
|
|
38
|
+
try {
|
|
39
|
+
await handler(error, c, { requestId });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Fall through to the default line.
|
|
44
|
+
}
|
|
45
|
+
if (handler === logUnhandledError) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
logUnhandledError(error, c, { requestId });
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Nothing left to report to.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function createErrorHandler(onUnhandledError = logUnhandledError) {
|
|
56
|
+
return async (error, c) => {
|
|
57
|
+
const contractError = asApiError(error);
|
|
58
|
+
if (contractError !== undefined) {
|
|
59
|
+
return c.json(contractError.toResponseBody(), contractError.status);
|
|
60
|
+
}
|
|
61
|
+
// Hono's validator refusing a body that declares JSON and is not: empty, or malformed. It is
|
|
62
|
+
// invalid input like any other, so it gets the same `details.issues`.
|
|
63
|
+
if (error instanceof HTTPException && error.status === 400) {
|
|
64
|
+
const refusal = invalidRequest([{ path: [], message: error.message }]);
|
|
65
|
+
return c.json(refusal.toResponseBody(), 400);
|
|
66
|
+
}
|
|
67
|
+
if (error instanceof HTTPException) {
|
|
68
|
+
return c.json({ error: { code: 'BAD_REQUEST', message: error.message } }, error.status);
|
|
69
|
+
}
|
|
70
|
+
const requestId = requestIdFor(c);
|
|
71
|
+
await reportUnhandledError(onUnhandledError, error, c, requestId);
|
|
72
|
+
c.header(REQUEST_ID_HEADER, requestId);
|
|
73
|
+
return c.json({
|
|
74
|
+
error: {
|
|
75
|
+
code: 'INTERNAL_ERROR',
|
|
76
|
+
message: 'An unexpected error occurred while handling the request.',
|
|
77
|
+
},
|
|
78
|
+
}, 500);
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export function handleNotFound(c) {
|
|
82
|
+
return c.json({
|
|
83
|
+
error: {
|
|
84
|
+
code: 'NOT_FOUND',
|
|
85
|
+
message: `No route matches ${c.req.method} ${new URL(c.req.url).pathname}.`,
|
|
86
|
+
},
|
|
87
|
+
}, 404);
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=error-handler.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"error-handler.js","sourceRoot":"","sources":["../../src/middleware/error-handler.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification endpoints. Each handler maps HTTP input to a VintaSend service
|
|
3
|
+
* call and the result back to the wire contract — no business logic beyond the
|
|
4
|
+
* translation itself.
|
|
5
|
+
*/
|
|
6
|
+
import { Hono } from 'hono';
|
|
7
|
+
import type { TemplateSourceClient } from '../services/notification-preview.js';
|
|
8
|
+
import type { NotificationServicePort } from '../services/notification-service-port.js';
|
|
9
|
+
export type NotificationRoutesDependencies = {
|
|
10
|
+
getService: () => Promise<NotificationServicePort>;
|
|
11
|
+
getTemplateClient: () => TemplateSourceClient;
|
|
12
|
+
backendIdentifier?: string | undefined;
|
|
13
|
+
};
|
|
14
|
+
export declare function createNotificationRoutes(deps: NotificationRoutesDependencies): Hono;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification endpoints. Each handler maps HTTP input to a VintaSend service
|
|
3
|
+
* call and the result back to the wire contract — no business logic beyond the
|
|
4
|
+
* translation itself.
|
|
5
|
+
*/
|
|
6
|
+
import { Hono } from 'hono';
|
|
7
|
+
import { toWireCapabilities } from '../domain/capabilities.js';
|
|
8
|
+
import { buildBackendFilter, buildOrderBy } from '../domain/filters.js';
|
|
9
|
+
import { notificationListQuerySchema, paginationQuerySchema, resendBodySchema, } from '../domain/schemas.js';
|
|
10
|
+
import { serializeNotification, serializeNotificationDetail, serializeUserNotification, } from '../domain/serialize.js';
|
|
11
|
+
import { ApiError } from '../errors.js';
|
|
12
|
+
import { buildNotificationPreview } from '../services/notification-preview.js';
|
|
13
|
+
import { createPagedNotificationReader } from '../services/paged-notification-reader.js';
|
|
14
|
+
import { validate } from './validation.js';
|
|
15
|
+
/**
|
|
16
|
+
* One page of a listing, and whether the next page has a row.
|
|
17
|
+
*
|
|
18
|
+
* Backends are not required to count, so that is asked directly: a full page is followed by a
|
|
19
|
+
* one-row read of the first row after it, which is page `page * pageSize + 1` of one-row pages. A
|
|
20
|
+
* short page is the last one without asking. `read` takes the contract's 1-indexed pages.
|
|
21
|
+
*/
|
|
22
|
+
async function paginate(read, page, pageSize) {
|
|
23
|
+
const notifications = await read(page, pageSize);
|
|
24
|
+
const hasMore = notifications.length === pageSize && (await read(page * pageSize + 1, 1)).length > 0;
|
|
25
|
+
return {
|
|
26
|
+
data: notifications.map(serializeNotification),
|
|
27
|
+
page,
|
|
28
|
+
pageSize,
|
|
29
|
+
hasMore,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Looks a notification up as a user notification first, then as a one-off one.
|
|
34
|
+
*/
|
|
35
|
+
async function findNotification(service, id, backendIdentifier) {
|
|
36
|
+
const notification = (await service.getNotification(id, false, backendIdentifier)) ??
|
|
37
|
+
(await service.getOneOffNotification(id, false, backendIdentifier));
|
|
38
|
+
if (!notification) {
|
|
39
|
+
throw ApiError.notFound(`Notification with ID ${id} was not found.`);
|
|
40
|
+
}
|
|
41
|
+
return notification;
|
|
42
|
+
}
|
|
43
|
+
export function createNotificationRoutes(deps) {
|
|
44
|
+
const routes = new Hono();
|
|
45
|
+
const { backendIdentifier } = deps;
|
|
46
|
+
/**
|
|
47
|
+
* Reads pages the way the contract does: 1-indexed, translated for the backend.
|
|
48
|
+
*/
|
|
49
|
+
const readerFor = async () => createPagedNotificationReader(await deps.getService(), backendIdentifier);
|
|
50
|
+
routes.get('/capabilities', async (c) => {
|
|
51
|
+
const reader = await readerFor();
|
|
52
|
+
return c.json({
|
|
53
|
+
data: toWireCapabilities(await reader.capabilities()),
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
routes.get('/notifications', validate('query', notificationListQuerySchema), async (c) => {
|
|
57
|
+
const query = c.req.valid('query');
|
|
58
|
+
const reader = await readerFor();
|
|
59
|
+
const capabilities = await reader.capabilities();
|
|
60
|
+
const filter = buildBackendFilter(query, capabilities);
|
|
61
|
+
const orderBy = buildOrderBy(query, capabilities);
|
|
62
|
+
return c.json(await paginate((page, pageSize) => reader.filterNotifications(filter, page, pageSize, orderBy), query.page, query.pageSize));
|
|
63
|
+
});
|
|
64
|
+
routes.get('/notifications/pending', validate('query', paginationQuerySchema), async (c) => {
|
|
65
|
+
const { page, pageSize } = c.req.valid('query');
|
|
66
|
+
const reader = await readerFor();
|
|
67
|
+
return c.json(await paginate(reader.getPendingNotifications, page, pageSize));
|
|
68
|
+
});
|
|
69
|
+
routes.get('/notifications/future', validate('query', paginationQuerySchema), async (c) => {
|
|
70
|
+
const { page, pageSize } = c.req.valid('query');
|
|
71
|
+
const reader = await readerFor();
|
|
72
|
+
return c.json(await paginate(reader.getFutureNotifications, page, pageSize));
|
|
73
|
+
});
|
|
74
|
+
routes.get('/notifications/one-off', validate('query', paginationQuerySchema), async (c) => {
|
|
75
|
+
const { page, pageSize } = c.req.valid('query');
|
|
76
|
+
const reader = await readerFor();
|
|
77
|
+
return c.json(await paginate(reader.getOneOffNotifications, page, pageSize));
|
|
78
|
+
});
|
|
79
|
+
routes.get('/notifications/:id', async (c) => {
|
|
80
|
+
const service = await deps.getService();
|
|
81
|
+
const notification = await findNotification(service, c.req.param('id'), backendIdentifier);
|
|
82
|
+
return c.json({
|
|
83
|
+
data: serializeNotificationDetail(notification),
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
routes.get('/notifications/:id/preview', async (c) => {
|
|
87
|
+
const service = await deps.getService();
|
|
88
|
+
const notification = await findNotification(service, c.req.param('id'), backendIdentifier);
|
|
89
|
+
const preview = await buildNotificationPreview({
|
|
90
|
+
service,
|
|
91
|
+
templateClient: deps.getTemplateClient(),
|
|
92
|
+
notification,
|
|
93
|
+
});
|
|
94
|
+
return c.json({ data: preview });
|
|
95
|
+
});
|
|
96
|
+
routes.post('/notifications/:id/resend', validate('json', resendBodySchema), async (c) => {
|
|
97
|
+
const { useStoredContext } = c.req.valid('json');
|
|
98
|
+
const service = await deps.getService();
|
|
99
|
+
const resent = await service.resendNotification(c.req.param('id'), useStoredContext);
|
|
100
|
+
if (!resent) {
|
|
101
|
+
throw ApiError.conflict('The notification could not be resent. It may not exist, may be a one-off notification, or may be scheduled for the future.');
|
|
102
|
+
}
|
|
103
|
+
return c.json({ data: serializeUserNotification(resent) }, 201);
|
|
104
|
+
});
|
|
105
|
+
routes.post('/notifications/:id/cancel', async (c) => {
|
|
106
|
+
const id = c.req.param('id');
|
|
107
|
+
const service = await deps.getService();
|
|
108
|
+
const notification = await findNotification(service, id, backendIdentifier);
|
|
109
|
+
if (notification.status !== 'PENDING_SEND') {
|
|
110
|
+
throw ApiError.conflict('Only notifications in PENDING_SEND status can be cancelled.');
|
|
111
|
+
}
|
|
112
|
+
await service.cancelNotification(id);
|
|
113
|
+
return c.json({
|
|
114
|
+
data: { id, status: 'CANCELLED' },
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
return routes;
|
|
118
|
+
}
|
|
119
|
+
//# sourceMappingURL=notifications.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notifications.js","sourceRoot":"","sources":["../../src/routes/notifications.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ZodType } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* `zValidator` with the contract's error envelope: invalid input always comes back as a 400
|
|
4
|
+
* `BAD_REQUEST` listing the offending fields in `details.issues`.
|
|
5
|
+
*
|
|
6
|
+
* A `json` target also applies the contract's media-type rule. A request declaring a JSON media
|
|
7
|
+
* type must carry valid JSON (Hono refuses an empty or malformed one, and the error handler gives
|
|
8
|
+
* that refusal the same envelope). Any other request is read as `{}` when its body is empty, and
|
|
9
|
+
* refused when it is not.
|
|
10
|
+
*/
|
|
11
|
+
export declare function validate<Target extends 'query' | 'json' | 'param', Schema extends ZodType>(target: Target, schema: Schema): import("hono").MiddlewareHandler<import("hono").Env, string, {
|
|
12
|
+
in: (undefined extends import("zod").input<Schema> ? true : false) extends true ? { [K in Target]?: (import("zod").input<Schema> extends infer T ? T extends import("zod").input<Schema> ? T extends import("hono").ValidationTargets[K] ? T : { [K2 in keyof T]?: import("hono").ValidationTargets[K][K2] | undefined; } : never : never) | undefined; } : { [K_1 in Target]: import("zod").input<Schema> extends infer T ? T extends import("zod").input<Schema> ? T extends import("hono").ValidationTargets[K_1] ? T : { [K2_1 in keyof T]: import("hono").ValidationTargets[K_1][K2_1]; } : never : never; };
|
|
13
|
+
out: { [K_2 in Target]: import("zod").output<Schema>; };
|
|
14
|
+
}>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { zValidator } from '@hono/zod-validator';
|
|
2
|
+
import { invalidRequest } from '../errors.js';
|
|
3
|
+
/** The media types Hono's validator parses as JSON: the same pattern as Hono's own `jsonRegex`. */
|
|
4
|
+
const JSON_CONTENT_TYPE = /^application\/([a-z-.]+\+)?json(;\s*[a-zA-Z0-9-]+=([^;]+))*$/i;
|
|
5
|
+
/**
|
|
6
|
+
* Whether the request carries a body Hono's validator would not read as JSON.
|
|
7
|
+
*
|
|
8
|
+
* Hono validates a request that declares no JSON media type as `{}`. That is right for an empty
|
|
9
|
+
* body — it is how an all-optional body is omitted — and wrong for any other: `curl -d
|
|
10
|
+
* '{"useStoredContext":true}'` is form-encoded unless told otherwise, and read as `{}` it would
|
|
11
|
+
* resend the notification with a context regenerated from current data instead of the one it was
|
|
12
|
+
* sent with. The templates management API applies the same rule.
|
|
13
|
+
*
|
|
14
|
+
* Decided by reading the body rather than from `content-length` or `transfer-encoding`: a body
|
|
15
|
+
* with no `Content-Type` (a `Uint8Array` handed to `fetch`) or a streamed HTTP/2 body carries
|
|
16
|
+
* neither header.
|
|
17
|
+
*/
|
|
18
|
+
async function hasNonJsonBody(c) {
|
|
19
|
+
const contentType = c.req.header('content-type');
|
|
20
|
+
if (contentType !== undefined && JSON_CONTENT_TYPE.test(contentType)) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
return (await c.req.text()).length > 0;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* `zValidator` with the contract's error envelope: invalid input always comes back as a 400
|
|
27
|
+
* `BAD_REQUEST` listing the offending fields in `details.issues`.
|
|
28
|
+
*
|
|
29
|
+
* A `json` target also applies the contract's media-type rule. A request declaring a JSON media
|
|
30
|
+
* type must carry valid JSON (Hono refuses an empty or malformed one, and the error handler gives
|
|
31
|
+
* that refusal the same envelope). Any other request is read as `{}` when its body is empty, and
|
|
32
|
+
* refused when it is not.
|
|
33
|
+
*/
|
|
34
|
+
export function validate(target, schema) {
|
|
35
|
+
return zValidator(target, schema, async (result, c) => {
|
|
36
|
+
if (target === 'json' && (await hasNonJsonBody(c))) {
|
|
37
|
+
throw invalidRequest([{ path: [], message: 'Send the request body as application/json.' }]);
|
|
38
|
+
}
|
|
39
|
+
if (!result.success) {
|
|
40
|
+
throw invalidRequest(result.error.issues);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=validation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validation.js","sourceRoot":"","sources":["../../src/routes/validation.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAIjD,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAE9C,mGAAmG;AACnG,MAAM,iBAAiB,GAAG,+DAA+D,CAAC;AAE1F;;;;;;;;;;;;GAYG;AACH,KAAK,UAAU,cAAc,CAAC,CAAU;IACtC,MAAM,WAAW,GAAG,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,cAAc,CAAC,CAAC;IACjD,IAAI,WAAW,KAAK,SAAS,IAAI,iBAAiB,CAAC,IAAI,CAAC,WAAW,CAAC,EAAE,CAAC;QACrE,OAAO,KAAK,CAAC;IACf,CAAC;IACD,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC;AACzC,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,QAAQ,CACtB,MAAc,EACd,MAAc;IAEd,OAAO,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,EAAE,EAAE;QACpD,IAAI,MAAM,KAAK,MAAM,IAAI,CAAC,MAAM,cAAc,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;YACnD,MAAM,cAAc,CAAC,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,OAAO,EAAE,4CAA4C,EAAE,CAAC,CAAC,CAAC;QAC9F,CAAC;QACD,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;YACpB,MAAM,cAAc,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;QAC5C,CAAC;IACH,CAAC,CAAC,CAAC;AACL,CAAC"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type GitHubTemplatePreviewConfig } from './github-template-preview-config.js';
|
|
2
|
+
type GitHubTemplateClientOptions = {
|
|
3
|
+
cacheMaxEntries?: number;
|
|
4
|
+
};
|
|
5
|
+
type Fetcher = typeof fetch;
|
|
6
|
+
export type TemplateContentRequest = {
|
|
7
|
+
templatePath: string;
|
|
8
|
+
gitCommitSha: string;
|
|
9
|
+
};
|
|
10
|
+
export declare class GitHubTemplateClient {
|
|
11
|
+
private readonly config;
|
|
12
|
+
private readonly fetcher;
|
|
13
|
+
private readonly cacheMaxEntries;
|
|
14
|
+
private readonly cache;
|
|
15
|
+
constructor(config: GitHubTemplatePreviewConfig, fetcher?: Fetcher, options?: GitHubTemplateClientOptions);
|
|
16
|
+
getTemplateContentByCommit(request: TemplateContentRequest): Promise<string>;
|
|
17
|
+
getLatestMainCommitSha(): Promise<string>;
|
|
18
|
+
private buildApiUrl;
|
|
19
|
+
private buildCacheKey;
|
|
20
|
+
private buildHeaders;
|
|
21
|
+
private setCache;
|
|
22
|
+
private buildHttpError;
|
|
23
|
+
private buildCommitLookupHttpError;
|
|
24
|
+
}
|
|
25
|
+
export declare function createGitHubTemplateClientFromEnv(options?: GitHubTemplateClientOptions): GitHubTemplateClient;
|
|
26
|
+
export {};
|