@zeyolabs/kit 2026.11.7 → 2026.11.9

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/eslint-config.js CHANGED
@@ -113,3 +113,71 @@ export const drizzleRules = {
113
113
  'drizzle/enforce-update-with-where': ['error', { drizzleObjectName }],
114
114
  },
115
115
  };
116
+
117
+ /**
118
+ * Layer boundaries (see `standards/codex`): edges (remote functions, endpoints, hooks) reach the db
119
+ * only through managers; services never import managers; clients and libraries import nothing
120
+ * above them. Files are matched by name, so features keep `manager.server.ts`,
121
+ * `service.server.ts` and `client.server.ts` (or a `clients/` folder) and libraries live in `utils/`.
122
+ *
123
+ * - Starts as a warning so existing code can migrate; pass `severity: 'error'` once it is clean.
124
+ * - A file belongs to the first group it matches, in the order edge, service, client, library, so
125
+ * `utils/clients/x.ts` is a client and gets only the client rules.
126
+ * - Flat config does not merge `no-restricted-imports` across entries. Put these rules LAST, and
127
+ * pass your own restricted-import patterns as `extraPatterns` (added to every group).
128
+ * - Matching is by import specifier, so a barrel re-export bypasses it. An app with another layout
129
+ * (e.g. `services/*.ts`) passes its own `globs` (which files are which layer) and `imports`
130
+ * (which specifiers each layer is imported by). A green lint is not proof the boundaries hold.
131
+ * @param {{
132
+ * severity?: 'error' | 'warn',
133
+ * globs?: { client?: string[], edge?: string[], library?: string[], service?: string[] },
134
+ * imports?: { client?: string[], manager?: string[], service?: string[] },
135
+ * extraPatterns?: { group: string[], message?: string }[],
136
+ * }} [options]
137
+ */
138
+ export function layerBoundaryRules(options = {}) {
139
+ const severity = options.severity ?? 'warn';
140
+ const extra = options.extraPatterns ?? [];
141
+ const globs = {
142
+ client: ['**/client.server.ts', '**/clients/**/*.ts'],
143
+ edge: ['**/*.remote.ts', '**/+server.ts', '**/+page.server.ts', '**/hooks.server.ts'],
144
+ library: ['**/utils/**/*.ts'],
145
+ service: ['**/service.server.ts'],
146
+ ...options.globs,
147
+ };
148
+ const imports = {
149
+ client: ['**/client.server', '**/client.server.ts', '**/clients/**'],
150
+ manager: ['**/manager.server', '**/manager.server.ts'],
151
+ service: ['**/service.server', '**/service.server.ts'],
152
+ ...options.imports,
153
+ };
154
+ const forbid = (group, message) => ({ group, message });
155
+ // First match wins: each group ignores the files of the groups before it.
156
+ const groups = [
157
+ [globs.edge, [forbid(imports.service, 'Edges call managers, not services (layer boundary).')]],
158
+ [
159
+ globs.service,
160
+ [forbid(imports.manager, 'Services must not import managers (layer boundary).')],
161
+ ],
162
+ [
163
+ globs.client,
164
+ [
165
+ forbid(imports.manager, 'Clients must not import managers (layer boundary).'),
166
+ forbid(imports.service, 'Clients must not import services (layer boundary).'),
167
+ ],
168
+ ],
169
+ [
170
+ globs.library,
171
+ [
172
+ forbid(imports.manager, 'Libraries must not import managers (layer boundary).'),
173
+ forbid(imports.service, 'Libraries must not import services (layer boundary).'),
174
+ forbid(imports.client, 'Libraries must not import clients (layer boundary).'),
175
+ ],
176
+ ],
177
+ ];
178
+ return groups.map(([files, patterns], index) => ({
179
+ files,
180
+ ignores: groups.slice(0, index).flatMap(([earlier]) => earlier),
181
+ rules: { 'no-restricted-imports': [severity, { patterns: [...patterns, ...extra] }] },
182
+ }));
183
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeyolabs/kit",
3
- "version": "2026.11.7",
3
+ "version": "2026.11.9",
4
4
  "description": "Shared SvelteKit-on-Cloudflare server toolkit: remote-function wrappers, passwordless auth (magic link + passkeys), email over a send_email binding or SMTP, Cloudflare context, monitoring, storage clients, utils and the ESLint config.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -0,0 +1,53 @@
1
+ import { waitUntil } from '../cloudflare/wait-until';
2
+
3
+ // Shared shapes for the four layers every app is built from: libraries (static functions), clients
4
+ // (talk to the outside world), services (talk to the db) and managers (permission, service call,
5
+ // events). These are types and tiny helpers only: no base classes, so apps keep plain constructors.
6
+
7
+ /**
8
+ * Who a service acts for. A service is built as `new XService(ctx, db)` and every query it runs is
9
+ * scoped by `ctx.accountId`; `memberId` and `kind` identify the actor for audit and events.
10
+ */
11
+ export type ServiceCtx<Kind extends string = string> = {
12
+ accountId: number;
13
+ kind: Kind;
14
+ memberId: number;
15
+ };
16
+
17
+ /** What a service mutation returns: the result for the caller plus the events it caused. */
18
+ export type Mutation<T, E> = { events: E[]; result: T };
19
+
20
+ /** Builds a `Mutation`. `events` defaults to none. */
21
+ export function mutation<T, E>(result: T, events: E[] = []): Mutation<T, E> {
22
+ return { events, result };
23
+ }
24
+
25
+ /**
26
+ * Where a manager sends the events a service returned (a realtime hub, a queue, a log). `publish`
27
+ * never blocks the caller and never throws: a failed publish must not fail the mutation that was
28
+ * already committed.
29
+ */
30
+ export type EventPublisher<E> = { publish(events: E[]): void };
31
+
32
+ /**
33
+ * An `EventPublisher` that hands `send` the events in the background (`waitUntil` in the Workers
34
+ * runtime, fire-and-forget elsewhere) and swallows its errors, passing them to `onError` if given.
35
+ */
36
+ export function backgroundPublisher<E>(
37
+ send: (events: E[]) => Promise<unknown>,
38
+ onError?: (error: unknown) => void
39
+ ): EventPublisher<E> {
40
+ return {
41
+ publish(events) {
42
+ if (events.length === 0) return;
43
+ const run = (async () => {
44
+ try {
45
+ await send(events);
46
+ } catch (error) {
47
+ onError?.(error);
48
+ }
49
+ })();
50
+ void waitUntil(run);
51
+ },
52
+ };
53
+ }
@@ -0,0 +1,29 @@
1
+ import type { Membership } from '../auth/accounts/accounts';
2
+
3
+ import { Accounts } from '../auth/accounts/accounts';
4
+ import { Error403 } from '../utils/errors';
5
+
6
+ // The manager gate. A manager is permission check, then a service call, then events. Each edge (a
7
+ // SvelteKit hook, an MCP session, a Worker) resolves the principal its own way and passes the
8
+ // membership in, so managers need no request and are testable without one.
9
+
10
+ /** Throws a 403 unless `membership` grants `permission`. A missing membership fails closed. */
11
+ export function requirePermission(membership: Membership | null, permission: string): Membership {
12
+ if (!membership || !Accounts.can(membership, permission)) {
13
+ throw Error403('You do not have permission to do that');
14
+ }
15
+ return membership;
16
+ }
17
+
18
+ /**
19
+ * Runs `fn` with the principal (membership now non-null), or throws a 403 before `fn` is called.
20
+ * Anything else the edge put on the principal (member id, actor kind, account id) comes through.
21
+ */
22
+ export async function withPermission<P extends { membership: Membership | null }, T>(
23
+ principal: P,
24
+ permission: string,
25
+ fn: (principal: P & { membership: Membership }) => Promise<T> | T
26
+ ): Promise<T> {
27
+ const membership = requirePermission(principal.membership, permission);
28
+ return await fn({ ...principal, membership });
29
+ }