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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +273 -2
  3. package/dist/app.d.ts +33 -0
  4. package/dist/app.js +33 -0
  5. package/dist/app.js.map +1 -0
  6. package/dist/config.d.ts +13 -0
  7. package/dist/config.js +39 -0
  8. package/dist/config.js.map +1 -0
  9. package/dist/contract/types.d.ts +196 -0
  10. package/dist/contract/types.js +12 -0
  11. package/dist/contract/types.js.map +1 -0
  12. package/dist/domain/capabilities.d.ts +13 -0
  13. package/dist/domain/capabilities.js +19 -0
  14. package/dist/domain/capabilities.js.map +1 -0
  15. package/dist/domain/filters.d.ts +16 -0
  16. package/dist/domain/filters.js +82 -0
  17. package/dist/domain/filters.js.map +1 -0
  18. package/dist/domain/pagination.d.ts +20 -0
  19. package/dist/domain/pagination.js +24 -0
  20. package/dist/domain/pagination.js.map +1 -0
  21. package/dist/domain/schemas.d.ts +90 -0
  22. package/dist/domain/schemas.js +52 -0
  23. package/dist/domain/schemas.js.map +1 -0
  24. package/dist/domain/serialize.d.ts +19 -0
  25. package/dist/domain/serialize.js +101 -0
  26. package/dist/domain/serialize.js.map +1 -0
  27. package/dist/errors.d.ts +45 -0
  28. package/dist/errors.js +94 -0
  29. package/dist/errors.js.map +1 -0
  30. package/dist/exports.d.ts +14 -0
  31. package/dist/exports.js +14 -0
  32. package/dist/exports.js.map +1 -0
  33. package/dist/index.d.ts +8 -0
  34. package/dist/index.js +34 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/middleware/authenticate.d.ts +34 -0
  37. package/dist/middleware/authenticate.js +68 -0
  38. package/dist/middleware/authenticate.js.map +1 -0
  39. package/dist/middleware/error-handler.d.ts +35 -0
  40. package/dist/middleware/error-handler.js +89 -0
  41. package/dist/middleware/error-handler.js.map +1 -0
  42. package/dist/routes/notifications.d.ts +14 -0
  43. package/dist/routes/notifications.js +119 -0
  44. package/dist/routes/notifications.js.map +1 -0
  45. package/dist/routes/validation.d.ts +14 -0
  46. package/dist/routes/validation.js +44 -0
  47. package/dist/routes/validation.js.map +1 -0
  48. package/dist/services/github-template-client.d.ts +26 -0
  49. package/dist/services/github-template-client.js +148 -0
  50. package/dist/services/github-template-client.js.map +1 -0
  51. package/dist/services/github-template-preview-config.d.ts +8 -0
  52. package/dist/services/github-template-preview-config.js +51 -0
  53. package/dist/services/github-template-preview-config.js.map +1 -0
  54. package/dist/services/notification-preview.d.ts +22 -0
  55. package/dist/services/notification-preview.js +66 -0
  56. package/dist/services/notification-preview.js.map +1 -0
  57. package/dist/services/notification-service-port.d.ts +40 -0
  58. package/dist/services/notification-service-port.js +19 -0
  59. package/dist/services/notification-service-port.js.map +1 -0
  60. package/dist/services/paged-notification-reader.d.ts +22 -0
  61. package/dist/services/paged-notification-reader.js +36 -0
  62. package/dist/services/paged-notification-reader.js.map +1 -0
  63. package/dist/services/service-loader.d.ts +15 -0
  64. package/dist/services/service-loader.js +56 -0
  65. package/dist/services/service-loader.js.map +1 -0
  66. package/dist/services/template-path-resolver.d.ts +4 -0
  67. package/dist/services/template-path-resolver.js +17 -0
  68. package/dist/services/template-path-resolver.js.map +1 -0
  69. package/openapi.yaml +666 -0
  70. package/package.json +66 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2017 Vinta Serviços e Soluções Tecnológicas Ltda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,274 @@
1
- # Temporary Holding Version
1
+ # VintaSend API
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ REST API that exposes a [VintaSend](https://github.com/vintasoftware/vintasend-ts)
4
+ notification service over HTTP.
5
+
6
+ It exists so the [VintaSend dashboard](https://github.com/vintasoftware/vintasend-ts-dashboard)
7
+ no longer has to embed a notification service: the dashboard is now a pure API
8
+ client, and any implementation of this contract can serve it — including a
9
+ future one built on the Python `vintasend` package.
10
+
11
+ **[`openapi.yaml`](./openapi.yaml) is the contract.** This repository is the
12
+ TypeScript reference implementation of it, published to npm so a project can
13
+ mount it rather than copy it.
14
+
15
+ ## Two ways to run it
16
+
17
+ **Mounted in your own server** — the usual case for an app that already has
18
+ one. `createApp` returns a [Hono](https://hono.dev) app, which takes a standard
19
+ `Request` and returns a `Response`, so it mounts in a Next.js route handler, in
20
+ TanStack Start, behind Express, or anywhere else that speaks `fetch`. You hand it
21
+ the service you already built to send notifications, and your own check of who
22
+ is calling. See [Mounting it](#mounting-it).
23
+
24
+ **On its own** — the `vintasend-api` command runs a server configured from
25
+ environment variables, behind one shared API key. See
26
+ [Running it on its own](#running-it-on-its-own).
27
+
28
+ Either way, the API ships no notification backend: database, adapters and
29
+ template renderer are yours.
30
+
31
+ ## Installing
32
+
33
+ ```bash
34
+ npm install vintasend-api vintasend
35
+ ```
36
+
37
+ `vintasend` is a peer dependency, so your service and the API share one copy.
38
+ Install the same release line for both: the API is released together with
39
+ `vintasend`, and its version matches.
40
+
41
+ ## Architecture
42
+
43
+ ```
44
+ ┌─────────────────────┐ HTTPS + API key ┌──────────────────┐
45
+ │ Dashboard (Next) │ ───────────────────▶ │ vintasend-api │
46
+ │ server-side only │ ◀─────────────────── │ (this repo) │
47
+ └─────────────────────┘ JSON contract └────────┬─────────┘
48
+ │
49
+ ┌──────────────┴──────────────┐
50
+ │ Your VintaSend service │
51
+ │ backend + adapters + │
52
+ │ template renderer │
53
+ └─────────────────────────────┘
54
+ ```
55
+
56
+ The API owns everything that needs backend credentials — database access,
57
+ template rendering, GitHub template lookups. The UI owns presentation and user
58
+ authentication.
59
+
60
+ ## Endpoints
61
+
62
+ | Method | Path | Purpose |
63
+ | --- | --- | --- |
64
+ | GET | `/health` | Liveness probe (unauthenticated) |
65
+ | GET | `/api/v1/capabilities` | Filter/order capabilities of the configured backend |
66
+ | GET | `/api/v1/notifications` | List notifications with filters, ordering and pagination |
67
+ | GET | `/api/v1/notifications/pending` | Notifications awaiting send |
68
+ | GET | `/api/v1/notifications/future` | Notifications scheduled for the future |
69
+ | GET | `/api/v1/notifications/one-off` | One-off notifications |
70
+ | GET | `/api/v1/notifications/{id}` | One notification, including context payloads |
71
+ | GET | `/api/v1/notifications/{id}/preview` | Templates rendered at the notification's commit |
72
+ | POST | `/api/v1/notifications/{id}/resend` | Resend a notification |
73
+ | POST | `/api/v1/notifications/{id}/cancel` | Cancel a pending notification |
74
+
75
+ Conventions worth knowing when implementing this contract elsewhere:
76
+
77
+ - `page` is **1-indexed** in the API, in every implementation, and clients never
78
+ convert. What the backend wants is a separate question: the TypeScript
79
+ VintaSend backends are 0-indexed, the Python ones are 1-indexed. The offset
80
+ comes from the backend's `pagination.oneIndexed` capability — porting this
81
+ server's `page - 1` literally into a 1-indexed language is an off-by-one. The
82
+ capability is backend-facing and is not published by `/api/v1/capabilities`.
83
+ - `hasMore` is `true` when the next page has at least one row, so a list that
84
+ exactly fills its last page never offers an empty one. Backends are not
85
+ required to produce a total count: after a full page, the server reads the one
86
+ row that would follow it.
87
+ - List rows carry a `kind` field (`user` or `one-off`) so clients can
88
+ discriminate without sniffing for the presence of fields.
89
+ - Timestamps are ISO-8601 UTC strings, `null` when unset — never `undefined`.
90
+ - Errors always use the envelope `{ "error": { "code", "message", "details"? } }`.
91
+ Failures that come from the template source are `UPSTREAM_ERROR` (502), not a
92
+ generic 500.
93
+ - Every 400 carries `details.issues: [{ path, message }]`, whatever the mistake
94
+ was. `path` names the field, and is empty for the body as a whole.
95
+ - `FORBIDDEN` (403) is for a caller who is authenticated and not allowed — what a
96
+ host's own authentication answers. The API key alone never produces it.
97
+ - Request bodies are JSON. A request declaring `application/json` (or any
98
+ `application/*+json`) must carry valid JSON. A request declaring no media type,
99
+ or another one, counts as an omitted body when it is empty and is a 400
100
+ otherwise — `curl -d` sends form encoding unless told otherwise, and reading
101
+ its body as `{}` would resend with a regenerated context instead of the stored
102
+ one.
103
+
104
+ ## Mounting it
105
+
106
+ ```ts
107
+ // app/api/v1/[...path]/route.ts — a Next.js app router route handler
108
+ import {
109
+ ApiError,
110
+ asNotificationServicePort,
111
+ createApp,
112
+ createGitHubTemplateClientFromEnv,
113
+ } from 'vintasend-api';
114
+ import { notificationService } from '@/lib/notifications';
115
+ import { getSession } from '@/lib/auth';
116
+
117
+ const app = createApp({
118
+ // The service your app already sends with. VintaSend services are generic over your
119
+ // notification config, so the port takes it through a cast confined to this one call.
120
+ getService: async () => asNotificationServicePort(notificationService),
121
+ // Runs before every /api/v1 route. Throw to refuse.
122
+ authenticate: async (c) => {
123
+ const user = await getSession(c.req.raw);
124
+ if (!user) throw ApiError.unauthorized('Sign in first.');
125
+ if (!user.canManageNotifications) throw ApiError.forbidden('Not allowed.');
126
+ return {};
127
+ },
128
+ // Where previews read templates from, at the commit a notification was sent with.
129
+ getTemplateClient: () => createGitHubTemplateClientFromEnv(),
130
+ // Every error not mapped to a contract error. Defaults to one redacted log line.
131
+ onUnhandledError: (error, _c, { requestId }) => errorTracker.capture(error, { requestId }),
132
+ });
133
+
134
+ const handler = (request: Request) => app.fetch(request);
135
+ export { handler as GET, handler as POST };
136
+ ```
137
+
138
+ Behind Express, hand `getRequestListener(app.fetch)` from `@hono/node-server` to a
139
+ route that keeps the path whole — `server.all(...)`, not `server.use('/api/v1', ...)`,
140
+ which strips the prefix the API's routes include.
141
+
142
+ `authenticate` has the same shape in
143
+ [`vintasend-templates-management-api`](https://github.com/vintasoftware/vintasend-ts-templates-management-api),
144
+ so an app mounting both passes them one function. It may throw the `ApiError` of
145
+ either package: both recognise an error by its name and code, not by its class.
146
+ Throw `ApiError.unauthorized` for a caller with no valid credential and
147
+ `ApiError.forbidden` for one you know and refuse: a 401 would tell a signed-in
148
+ user to sign in again. For one shared secret, pass
149
+ `authenticate: apiKeyAuthenticator(key)`, which compares in constant time.
150
+
151
+ The app uses Web APIs only — no Node built-ins — so it runs wherever `fetch`
152
+ does. The standalone server and the module-path service loader are the Node-only
153
+ parts, and `createApp` loads neither.
154
+
155
+ An unexpected error is reported to the client as a generic 500 with an
156
+ `X-Request-Id` header. By default it is logged as one line — the error's name,
157
+ the request id and the route pattern — and never with its message, its stack or
158
+ the request: errors from a notification backend or provider can quote
159
+ notification content and context values, which in the applications this API
160
+ serves can be health data. `onUnhandledError` hands the error to your own
161
+ tracker instead; keeping health data out of it is then your call. If it throws,
162
+ the default line is logged in its place.
163
+
164
+ ## Running it on its own
165
+
166
+ Every `/api/v1` request must carry the shared secret:
167
+
168
+ ```
169
+ Authorization: Bearer $VINTASEND_API_KEY
170
+ ```
171
+
172
+ The dashboard calls this API only from its own server side, so the key never
173
+ reaches a browser. If you do need to call the API from a browser, set
174
+ `VINTASEND_API_CORS_ORIGINS` to the allowed origins — and put a per-user auth
175
+ layer in front of it first.
176
+
177
+ ```bash
178
+ VINTASEND_API_KEY=… VINTASEND_SERVICE_MODULE=./vintasend.config.js npx vintasend-api
179
+ ```
180
+
181
+ To work on this repository instead:
182
+
183
+ ```bash
184
+ npm install
185
+ cp .env.example .env
186
+ npm run dev
187
+ ```
188
+
189
+ ## Configuring your VintaSend service
190
+
191
+ The standalone server ships no backend of its own: which database, adapters and
192
+ template renderer to use is a deployment decision. Point
193
+ `VINTASEND_SERVICE_MODULE` at a module that default-exports a factory returning a
194
+ configured VintaSend service:
195
+
196
+ ```ts
197
+ // src/vintasend.config.ts
198
+ import { VintaSendFactory } from 'vintasend';
199
+
200
+ export default async function createVintaSendService() {
201
+ const backend = /* your backend */;
202
+ const renderer = /* your template renderer */;
203
+ const adapter = /* your notification adapter */;
204
+
205
+ return new VintaSendFactory<Config>().create(backend, [adapter], contextGenerators);
206
+ }
207
+ ```
208
+
209
+ Start from [`src/vintasend.config.example.ts`](./src/vintasend.config.example.ts),
210
+ copying it to `src/vintasend.config.ts` (gitignored) so it is compiled along with
211
+ the rest of `src`. The factory is called once at startup, and a failure there
212
+ stops the server rather than surfacing on the first request.
213
+
214
+ `VINTASEND_SERVICE_MODULE` accepts a path relative to the working directory or a
215
+ bare package specifier. Use `./dist/vintasend.config.js` with `npm start`, and
216
+ `./src/vintasend.config.ts` with `npm run dev`, which runs TypeScript directly.
217
+
218
+ ## Environment variables
219
+
220
+ | Variable | Required | Description |
221
+ | --- | --- | --- |
222
+ | `VINTASEND_API_KEY` | yes | Shared secret clients must send as a bearer token. |
223
+ | `VINTASEND_SERVICE_MODULE` | no | Module building your VintaSend service. Defaults to `./dist/vintasend.config.js`. |
224
+ | `VINTASEND_BACKEND_IDENTIFIER` | no | Read from a non-primary backend registered in your service. |
225
+ | `VINTASEND_API_CORS_ORIGINS` | no | Comma-separated browser origins allowed to call the API. |
226
+ | `PORT` / `HOST` | no | Listen address. Defaults to `3333` / `0.0.0.0`. |
227
+ | `GITHUB_REPO` | preview only | Repository holding the templates, as `owner/repo` or a full URL. |
228
+ | `GITHUB_API_KEY` | preview only | Token with read access to that repository. |
229
+ | `GITHUB_API_BASE_URL` | no | Defaults to `https://api.github.com`. |
230
+ | `GITHUB_TEMPLATES_BASE_PATH` | no | Prefix added to template paths before the GitHub lookup. |
231
+
232
+ The `GITHUB_*` variables are only read when `/preview` is called, so the API
233
+ runs fine without them if you do not use template previews.
234
+
235
+ ## Development
236
+
237
+ ```bash
238
+ npm run dev # watch mode
239
+ npm test # vitest
240
+ npm run typecheck # tsc --noEmit
241
+ npm run lint # biome
242
+ npm run build # compile to dist/
243
+ npm start # run the compiled server
244
+ ```
245
+
246
+ Tests drive the real Hono app through `app.request()` with an injected fake
247
+ service, so they cover routing, auth, validation, filter negotiation and
248
+ serialization without needing a database.
249
+
250
+ ## Implementing this contract in another language
251
+
252
+ 1. Read `openapi.yaml` — it is normative, including status codes and error codes.
253
+ 2. Mirror the `hasMore` rule (another page has a row) and the `kind`
254
+ discriminator exactly; the dashboard depends on both.
255
+ 3. Take the page offset from your backend's `pagination.oneIndexed` capability,
256
+ not from this implementation. The wire stays 1-indexed either way, so a
257
+ 1-indexed backend passes the page straight through — no `- 1`. Apply it to
258
+ every paginated read, and keep the capability out of the `/capabilities`
259
+ response so no client converts on top of you.
260
+ 4. Treat `stringLookups.caseSensitive` and `stringLookups.caseInsensitive` as
261
+ independent: a backend can be incapable of either one, and deriving one from
262
+ the other declines the single lookup such a backend actually supports.
263
+ 5. Negotiate string lookups and ordering against your backend's capabilities,
264
+ and report what you support from `/api/v1/capabilities`. Dropping an
265
+ unsupported ordering is correct; failing the request is not.
266
+ 6. Report template-source failures as `UPSTREAM_ERROR` (502) rather than a
267
+ generic 500: a rate-limited or unreachable template host is not a fault of
268
+ the API, and the dashboard shows the message to the operator.
269
+ 7. Keep the error envelope identical — the dashboard branches on `error.code` —
270
+ including `details.issues` on every 400 and the request-body rule.
271
+
272
+ ## License
273
+
274
+ MIT
package/dist/app.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Builds the HTTP application.
3
+ *
4
+ * Everything the API needs from the outside world — who the caller is, the VintaSend service and
5
+ * the template source — is injected, so the app can be exercised in tests without a database, a
6
+ * mail provider, or GitHub, and mounted inside a host's own server behind its own authentication.
7
+ */
8
+ import { Hono } from 'hono';
9
+ import { type Authenticator } from './middleware/authenticate.js';
10
+ import { type UnhandledErrorHandler } from './middleware/error-handler.js';
11
+ import type { TemplateSourceClient } from './services/notification-preview.js';
12
+ import type { NotificationServicePort } from './services/notification-service-port.js';
13
+ export type AppDependencies = {
14
+ /**
15
+ * Runs before every `/api/v1` route. It refuses a caller by throwing `ApiError.unauthorized` or
16
+ * `ApiError.forbidden`. `apiKeyAuthenticator(key)` is the shared-secret case.
17
+ */
18
+ authenticate: Authenticator;
19
+ getService: () => Promise<NotificationServicePort>;
20
+ /** Where previews read a notification's templates from, at the commit it was sent with. */
21
+ getTemplateClient: () => TemplateSourceClient;
22
+ backendIdentifier?: string | undefined;
23
+ corsOrigins?: string[];
24
+ /**
25
+ * Receives every error the API does not map to a contract error. Defaults to a single log line
26
+ * with the error's name, a request id and the route — never the error object or the request.
27
+ * If the handler throws, that default line is logged instead. The client gets the generic 500
28
+ * either way.
29
+ */
30
+ onUnhandledError?: UnhandledErrorHandler;
31
+ };
32
+ export declare const API_BASE_PATH = "/api/v1";
33
+ export declare function createApp(deps: AppDependencies): Hono;
package/dist/app.js ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Builds the HTTP application.
3
+ *
4
+ * Everything the API needs from the outside world — who the caller is, the VintaSend service and
5
+ * the template source — is injected, so the app can be exercised in tests without a database, a
6
+ * mail provider, or GitHub, and mounted inside a host's own server behind its own authentication.
7
+ */
8
+ import { Hono } from 'hono';
9
+ import { cors } from 'hono/cors';
10
+ import { API_VERSION } from './contract/types.js';
11
+ import { authenticateWith } from './middleware/authenticate.js';
12
+ import { createErrorHandler, handleNotFound, } from './middleware/error-handler.js';
13
+ import { createNotificationRoutes } from './routes/notifications.js';
14
+ export const API_BASE_PATH = `/api/${API_VERSION}`;
15
+ export function createApp(deps) {
16
+ const app = new Hono();
17
+ app.onError(createErrorHandler(deps.onUnhandledError));
18
+ app.notFound(handleNotFound);
19
+ if (deps.corsOrigins && deps.corsOrigins.length > 0) {
20
+ const allowedOrigins = deps.corsOrigins;
21
+ app.use(`${API_BASE_PATH}/*`, cors({
22
+ origin: (origin) => (allowedOrigins.includes(origin) ? origin : null),
23
+ allowHeaders: ['Authorization', 'Content-Type'],
24
+ allowMethods: ['GET', 'POST', 'OPTIONS'],
25
+ }));
26
+ }
27
+ // Unauthenticated: used by load balancers and container health checks.
28
+ app.get('/health', (c) => c.json({ status: 'ok', apiVersion: API_VERSION }));
29
+ app.use(`${API_BASE_PATH}/*`, authenticateWith(deps.authenticate));
30
+ app.route(API_BASE_PATH, createNotificationRoutes(deps));
31
+ return app;
32
+ }
33
+ //# sourceMappingURL=app.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC5B,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,OAAO,EAAE,WAAW,EAAuB,MAAM,qBAAqB,CAAC;AACvE,OAAO,EAAsB,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AACpF,OAAO,EACL,kBAAkB,EAClB,cAAc,GAEf,MAAM,+BAA+B,CAAC;AACvC,OAAO,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAwBrE,MAAM,CAAC,MAAM,aAAa,GAAG,QAAQ,WAAW,EAAE,CAAC;AAEnD,MAAM,UAAU,SAAS,CAAC,IAAqB;IAC7C,MAAM,GAAG,GAAG,IAAI,IAAI,EAAE,CAAC;IAEvB,GAAG,CAAC,OAAO,CAAC,kBAAkB,CAAC,IAAI,CAAC,gBAAgB,CAAC,CAAC,CAAC;IACvD,GAAG,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAC;IAE7B,IAAI,IAAI,CAAC,WAAW,IAAI,IAAI,CAAC,WAAW,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACpD,MAAM,cAAc,GAAG,IAAI,CAAC,WAAW,CAAC;QACxC,GAAG,CAAC,GAAG,CACL,GAAG,aAAa,IAAI,EACpB,IAAI,CAAC;YACH,MAAM,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,cAAc,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC;YACrE,YAAY,EAAE,CAAC,eAAe,EAAE,cAAc,CAAC;YAC/C,YAAY,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,CAAC;SACzC,CAAC,CACH,CAAC;IACJ,CAAC;IAED,uEAAuE;IACvE,GAAG,CAAC,GAAG,CAAC,SAAS,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAiB,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,EAAE,WAAW,EAAE,CAAC,CAAC,CAAC;IAE7F,GAAG,CAAC,GAAG,CAAC,GAAG,aAAa,IAAI,EAAE,gBAAgB,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,CAAC;IACnE,GAAG,CAAC,KAAK,CAAC,aAAa,EAAE,wBAAwB,CAAC,IAAI,CAAC,CAAC,CAAC;IAEzD,OAAO,GAAG,CAAC;AACb,CAAC"}
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Server configuration read from the environment. Validated once at startup so
3
+ * a misconfigured deployment fails immediately instead of on the first request.
4
+ */
5
+ export type ServerConfig = {
6
+ port: number;
7
+ host: string;
8
+ apiKey: string;
9
+ corsOrigins: string[];
10
+ serviceModule: string;
11
+ backendIdentifier: string | undefined;
12
+ };
13
+ export declare function loadServerConfig(env?: NodeJS.ProcessEnv): ServerConfig;
package/dist/config.js ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Server configuration read from the environment. Validated once at startup so
3
+ * a misconfigured deployment fails immediately instead of on the first request.
4
+ */
5
+ const DEFAULT_PORT = 3333;
6
+ const DEFAULT_HOST = '0.0.0.0';
7
+ const DEFAULT_SERVICE_MODULE = './dist/vintasend.config.js';
8
+ function splitList(value) {
9
+ if (!value) {
10
+ return [];
11
+ }
12
+ return value
13
+ .split(',')
14
+ .map((entry) => entry.trim())
15
+ .filter(Boolean);
16
+ }
17
+ export function loadServerConfig(env = process.env) {
18
+ const errors = [];
19
+ const apiKey = env.VINTASEND_API_KEY?.trim();
20
+ if (!apiKey) {
21
+ errors.push('VINTASEND_API_KEY is required');
22
+ }
23
+ const port = Number(env.PORT ?? DEFAULT_PORT);
24
+ if (!Number.isInteger(port) || port <= 0) {
25
+ errors.push('PORT must be a positive integer');
26
+ }
27
+ if (errors.length > 0) {
28
+ throw new Error(`Invalid API configuration:\n${errors.map((e) => ` - ${e}`).join('\n')}`);
29
+ }
30
+ return {
31
+ port,
32
+ host: env.HOST?.trim() || DEFAULT_HOST,
33
+ apiKey: apiKey,
34
+ corsOrigins: splitList(env.VINTASEND_API_CORS_ORIGINS),
35
+ serviceModule: env.VINTASEND_SERVICE_MODULE?.trim() || DEFAULT_SERVICE_MODULE,
36
+ backendIdentifier: env.VINTASEND_BACKEND_IDENTIFIER?.trim() || undefined,
37
+ };
38
+ }
39
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAWH,MAAM,YAAY,GAAG,IAAI,CAAC;AAC1B,MAAM,YAAY,GAAG,SAAS,CAAC;AAC/B,MAAM,sBAAsB,GAAG,4BAA4B,CAAC;AAE5D,SAAS,SAAS,CAAC,KAAyB;IAC1C,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,OAAO,EAAE,CAAC;IACZ,CAAC;IACD,OAAO,KAAK;SACT,KAAK,CAAC,GAAG,CAAC;SACV,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;SAC5B,MAAM,CAAC,OAAO,CAAC,CAAC;AACrB,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,MAAyB,OAAO,CAAC,GAAG;IACnE,MAAM,MAAM,GAAa,EAAE,CAAC;IAE5B,MAAM,MAAM,GAAG,GAAG,CAAC,iBAAiB,EAAE,IAAI,EAAE,CAAC;IAC7C,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,MAAM,CAAC,IAAI,CAAC,+BAA+B,CAAC,CAAC;IAC/C,CAAC;IAED,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,IAAI,YAAY,CAAC,CAAC;IAC9C,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,EAAE,CAAC;QACzC,MAAM,CAAC,IAAI,CAAC,iCAAiC,CAAC,CAAC;IACjD,CAAC;IAED,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACtB,MAAM,IAAI,KAAK,CAAC,+BAA+B,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAC7F,CAAC;IAED,OAAO;QACL,IAAI;QACJ,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,YAAY;QACtC,MAAM,EAAE,MAAgB;QACxB,WAAW,EAAE,SAAS,CAAC,GAAG,CAAC,0BAA0B,CAAC;QACtD,aAAa,EAAE,GAAG,CAAC,wBAAwB,EAAE,IAAI,EAAE,IAAI,sBAAsB;QAC7E,iBAAiB,EAAE,GAAG,CAAC,4BAA4B,EAAE,IAAI,EAAE,IAAI,SAAS;KACzE,CAAC;AACJ,CAAC"}
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Wire contract for the VintaSend dashboard API.
3
+ *
4
+ * These types describe the JSON payloads exchanged over HTTP. They intentionally
5
+ * avoid importing anything from `vintasend`: any implementation of this contract
6
+ * (the TypeScript one in this repo, a future Python one) must produce exactly
7
+ * these shapes, and any UI consuming the API only needs these definitions.
8
+ *
9
+ * All timestamps are ISO-8601 strings in UTC.
10
+ */
11
+ export declare const API_VERSION = "v1";
12
+ export type NotificationStatus = 'PENDING_SEND' | 'SENT' | 'FAILED' | 'READ' | 'CANCELLED';
13
+ export type NotificationType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP';
14
+ export type JsonValue = string | number | boolean | null | JsonValue[] | {
15
+ [key: string]: JsonValue;
16
+ };
17
+ /**
18
+ * Attachment metadata exposed on notification details.
19
+ */
20
+ export type NotificationAttachment = {
21
+ id: string;
22
+ filename: string;
23
+ contentType: string;
24
+ size: number;
25
+ description?: string;
26
+ };
27
+ /**
28
+ * Fields shared by regular and one-off notifications in list responses.
29
+ */
30
+ type NotificationBase = {
31
+ id: string;
32
+ notificationType: NotificationType;
33
+ title: string | null;
34
+ contextName: string;
35
+ status: NotificationStatus;
36
+ sendAfter: string | null;
37
+ sentAt: string | null;
38
+ readAt: string | null;
39
+ createdAt: string | null;
40
+ updatedAt: string | null;
41
+ adapterUsed: string | null;
42
+ bodyTemplate: string;
43
+ subjectTemplate: string | null;
44
+ gitCommitSha: string | null;
45
+ /**
46
+ * The template version this notification asked for, pinned when it was created or updated.
47
+ * `null` means it was left unpinned and renders whatever version is current at send time.
48
+ * Always `null` when the service's template renderer has no versions.
49
+ */
50
+ requestedTemplateVersion: number | null;
51
+ /**
52
+ * The template version the renderer reported after the notification was sent. `null` until it
53
+ * has been sent, and always `null` when the template renderer has no versions. On an unpinned
54
+ * notification this is the only record of what went out.
55
+ */
56
+ usedTemplateVersion: number | null;
57
+ tenant: string | null;
58
+ };
59
+ /**
60
+ * A notification addressed to a known user.
61
+ */
62
+ export type UserNotification = NotificationBase & {
63
+ kind: 'user';
64
+ userId: string;
65
+ };
66
+ /**
67
+ * A notification addressed to a raw email/phone, without a user record.
68
+ */
69
+ export type OneOffNotification = NotificationBase & {
70
+ kind: 'one-off';
71
+ emailOrPhone: string;
72
+ firstName: string | null;
73
+ lastName: string | null;
74
+ };
75
+ /**
76
+ * List-view notification. `kind` discriminates the two variants.
77
+ */
78
+ export type Notification = UserNotification | OneOffNotification;
79
+ type NotificationDetailFields = {
80
+ contextUsed: JsonValue | null;
81
+ contextParameters: JsonValue | null;
82
+ extraParams: JsonValue | null;
83
+ attachments: NotificationAttachment[];
84
+ };
85
+ export type UserNotificationDetail = UserNotification & NotificationDetailFields;
86
+ export type OneOffNotificationDetail = OneOffNotification & NotificationDetailFields;
87
+ /**
88
+ * Detail-view notification, including the (potentially large) context payloads.
89
+ */
90
+ export type NotificationDetail = UserNotificationDetail | OneOffNotificationDetail;
91
+ export type NotificationOrderByField = 'sendAfter' | 'sentAt' | 'readAt' | 'createdAt' | 'updatedAt';
92
+ export type NotificationOrderDirection = 'asc' | 'desc';
93
+ /**
94
+ * Query parameters accepted by `GET /api/v1/notifications`.
95
+ * Every field is optional; date fields are ISO-8601 strings.
96
+ */
97
+ export type NotificationListQuery = {
98
+ page?: number;
99
+ pageSize?: number;
100
+ status?: NotificationStatus;
101
+ notificationType?: NotificationType;
102
+ adapterUsed?: string;
103
+ userId?: string;
104
+ bodyTemplate?: string;
105
+ subjectTemplate?: string;
106
+ contextName?: string;
107
+ tenant?: string;
108
+ /** Notifications pinned to this template version. Unpinned notifications never match. */
109
+ requestedTemplateVersion?: number;
110
+ /** Notifications that rendered this template version. Unsent notifications never match. */
111
+ usedTemplateVersion?: number;
112
+ createdAtFrom?: string;
113
+ createdAtTo?: string;
114
+ sentAtFrom?: string;
115
+ sentAtTo?: string;
116
+ orderByField?: NotificationOrderByField;
117
+ orderByDirection?: NotificationOrderDirection;
118
+ };
119
+ /**
120
+ * Envelope returned by every paginated endpoint. `page` is 1-indexed, whatever
121
+ * numbering the backend behind the API uses.
122
+ */
123
+ export type PaginatedResponse<T> = {
124
+ data: T[];
125
+ page: number;
126
+ pageSize: number;
127
+ /**
128
+ * True when the next page has at least one row, so a list that exactly fills its last page
129
+ * never offers an empty one. There is no total: backends are not required to count.
130
+ */
131
+ hasMore: boolean;
132
+ };
133
+ /**
134
+ * Envelope returned by every single-resource endpoint.
135
+ */
136
+ export type DataResponse<T> = {
137
+ data: T;
138
+ };
139
+ /**
140
+ * Filter capabilities advertised by the configured backend. Consumers use it to
141
+ * hide sorting/filtering affordances the backend cannot honour. Keys mirror
142
+ * VintaSend's capability keys (e.g. `orderBy.sentAt`, `stringLookups.includes`).
143
+ *
144
+ * They describe the backend, not the API: `pagination.oneIndexed` reports how
145
+ * the backend numbers pages, while the API's own `page` is always 1-indexed.
146
+ */
147
+ export type FilterCapabilities = Record<string, boolean>;
148
+ /**
149
+ * Payload of `GET /api/v1/notifications/{id}/preview` on success.
150
+ */
151
+ export type NotificationPreview = {
152
+ gitCommitSha: string;
153
+ bodyTemplatePath: string;
154
+ subjectTemplatePath: string | null;
155
+ renderedBodyHtml: string;
156
+ renderedSubjectHtml: string;
157
+ };
158
+ /**
159
+ * Payload of `POST /api/v1/notifications/{id}/cancel` on success.
160
+ */
161
+ export type CancelledNotification = {
162
+ id: string;
163
+ status: NotificationStatus;
164
+ };
165
+ /**
166
+ * Machine-readable error codes. Clients should branch on these, not on messages.
167
+ */
168
+ export type ApiErrorCode = 'BAD_REQUEST' | 'UNAUTHORIZED' | 'FORBIDDEN' | 'NOT_FOUND' | 'CONFLICT' | 'PREVIEW_UNAVAILABLE' | 'UPSTREAM_ERROR' | 'INTERNAL_ERROR';
169
+ /**
170
+ * One thing wrong with a request, in a 400's `details.issues`.
171
+ *
172
+ * `path` is the dotted field, and empty for the body as a whole or for a refusal that names no
173
+ * field.
174
+ */
175
+ export type ApiErrorIssue = {
176
+ path: string;
177
+ message: string;
178
+ };
179
+ /**
180
+ * Error envelope returned with every non-2xx response.
181
+ *
182
+ * Every 400 carries `details.issues: ApiErrorIssue[]`, whatever the mistake was, and may carry
183
+ * other keys beside it.
184
+ */
185
+ export type ApiErrorResponse = {
186
+ error: {
187
+ code: ApiErrorCode;
188
+ message: string;
189
+ details?: JsonValue;
190
+ };
191
+ };
192
+ export type HealthResponse = {
193
+ status: 'ok';
194
+ apiVersion: string;
195
+ };
196
+ export {};
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Wire contract for the VintaSend dashboard API.
3
+ *
4
+ * These types describe the JSON payloads exchanged over HTTP. They intentionally
5
+ * avoid importing anything from `vintasend`: any implementation of this contract
6
+ * (the TypeScript one in this repo, a future Python one) must produce exactly
7
+ * these shapes, and any UI consuming the API only needs these definitions.
8
+ *
9
+ * All timestamps are ISO-8601 strings in UTC.
10
+ */
11
+ export const API_VERSION = 'v1';
12
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/contract/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,MAAM,CAAC,MAAM,WAAW,GAAG,IAAI,CAAC"}
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The capability map is a backend report, and only part of it is any of a
3
+ * client's business.
4
+ *
5
+ * `/api/v1/capabilities` exists so consumers can hide sorting and filtering
6
+ * affordances the backend cannot honour. Pagination conventions are not that:
7
+ * the wire contract is unconditionally 1-indexed and this server does the
8
+ * conversion, so publishing `pagination.oneIndexed` would only invite a client
9
+ * to convert a second time.
10
+ */
11
+ import type { NotificationFilterCapabilities } from 'vintasend';
12
+ import type { FilterCapabilities } from '../contract/types.js';
13
+ export declare function toWireCapabilities(capabilities: NotificationFilterCapabilities): FilterCapabilities;