@zeyolabs/kit 0.0.0-stage → 2026.11.6
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/README.md +102 -2
- package/auth/accounts/accounts.remote.ts +116 -0
- package/auth/accounts/accounts.ts +332 -0
- package/auth/accounts/handle.ts +102 -0
- package/auth/accounts/invites.ts +149 -0
- package/auth/accounts/permissions.ts +90 -0
- package/auth/accounts/schemas.ts +22 -0
- package/auth/config.ts +77 -0
- package/auth/emails.ts +57 -0
- package/auth/schema.ts +152 -0
- package/auth/sessions/handle.ts +124 -0
- package/auth/sessions/magic-link/magic-link.ts +71 -0
- package/auth/sessions/passkeys/client.ts +115 -0
- package/auth/sessions/passkeys/passkeys.ts +295 -0
- package/auth/sessions/sessions.ts +101 -0
- package/auth/sveltekit/loads.ts +59 -0
- package/auth/sveltekit/login.ts +28 -0
- package/auth/sveltekit/routes.ts +264 -0
- package/auth/users.ts +49 -0
- package/cloudflare/access.ts +134 -0
- package/cloudflare/amqp/client.ts +276 -0
- package/cloudflare/amqp/testing.ts +9 -0
- package/cloudflare/context.ts +66 -0
- package/cloudflare/dispatch/client.ts +30 -0
- package/cloudflare/email/base.ts +59 -0
- package/cloudflare/email/client.ts +51 -0
- package/cloudflare/email/dev.ts +14 -0
- package/cloudflare/email/smtp.ts +65 -0
- package/cloudflare/email/testing.ts +18 -0
- package/cloudflare/r2/client.ts +154 -0
- package/cloudflare/r2/testing.ts +43 -0
- package/cloudflare/rate-limiter.ts +65 -0
- package/cloudflare/wait-until.ts +12 -0
- package/cloudflare/workers/index.ts +311 -0
- package/cloudflare/workers/testing.ts +94 -0
- package/cloudflare/workers/ticking-do.ts +104 -0
- package/cloudflare/workers-ai/client.ts +30 -0
- package/cloudflare/workers-ai/testing.ts +17 -0
- package/cloudflare/workers-env.ts +12 -0
- package/eslint-config.js +115 -0
- package/monitoring/alerts/client.ts +48 -0
- package/monitoring/alerts/event-config.ts +33 -0
- package/monitoring/alerts/policy.ts +39 -0
- package/monitoring/alerts/registry.ts +44 -0
- package/monitoring/gates/client.ts +90 -0
- package/monitoring/gates/dev.ts +29 -0
- package/monitoring/gates/testing.ts +30 -0
- package/monitoring/logger/client.ts +66 -0
- package/monitoring/prometheus/client.ts +147 -0
- package/monitoring/prometheus/dev.ts +23 -0
- package/monitoring/prometheus/gen/remote_write_pb.ts +105 -0
- package/monitoring/prometheus/metrics.ts +180 -0
- package/monitoring/prometheus/testing.ts +19 -0
- package/monitoring/slack/api.ts +68 -0
- package/monitoring/slack/client.ts +33 -0
- package/monitoring/slack/events.ts +15 -0
- package/package.json +156 -5
- package/server/logger.ts +63 -0
- package/server/rate-limit-client.ts +88 -0
- package/server/rate-limit.ts +24 -0
- package/server/remote-functions.ts +139 -0
- package/server/result.ts +18 -0
- package/server/turnstile.ts +19 -0
- package/storage/clickhouse/client.ts +441 -0
- package/storage/clickhouse/compiler.ts +218 -0
- package/storage/clickhouse/config.ts +13 -0
- package/storage/clickhouse/dev.ts +18 -0
- package/storage/clickhouse/error.ts +61 -0
- package/storage/clickhouse/index.ts +4 -0
- package/storage/clickhouse/native-decoder.ts +192 -0
- package/storage/clickhouse/readers.ts +167 -0
- package/storage/clickhouse/retry.ts +30 -0
- package/storage/clickhouse/schema.ts +163 -0
- package/storage/clickhouse/testing.ts +81 -0
- package/storage/manticore/client.ts +165 -0
- package/storage/manticore/schema.ts +23 -0
- package/storage/postgres/client.ts +44 -0
- package/storage/postgres/testing.ts +275 -0
- package/storage/redis/client.ts +219 -0
- package/storage/redis/protocol.ts +119 -0
- package/storage/redis/schema.ts +17 -0
- package/storage/redis/testing.ts +122 -0
- package/utils/crypto.ts +36 -0
- package/utils/datetime.ts +338 -0
- package/utils/email.ts +173 -0
- package/utils/env.ts +171 -0
- package/utils/errors.ts +67 -0
- package/utils/form.ts +135 -0
- package/utils/http-status.ts +42 -0
- package/utils/money.ts +10 -0
- package/utils/public-host.ts +66 -0
- package/utils/schema.ts +176 -0
- package/utils/strings/format.ts +51 -0
- package/utils/strings/normalize.ts +129 -0
- package/utils/transport.ts +25 -0
package/utils/errors.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { HttpStatus } from './http-status';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An error carrying an HTTP status and message, with no framework types. Callers map it to their
|
|
5
|
+
* own response representation. An optional structured `body` overrides the default `{ error }`
|
|
6
|
+
* payload, e.g. a validation failure that returns field-level errors.
|
|
7
|
+
*/
|
|
8
|
+
export class ServiceError extends Error {
|
|
9
|
+
constructor(
|
|
10
|
+
readonly status: number,
|
|
11
|
+
message: string,
|
|
12
|
+
readonly body?: Record<string, unknown>
|
|
13
|
+
) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'ServiceError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const Error400 = (message: string) => new ServiceError(HttpStatus.BadRequest, message);
|
|
20
|
+
export const Error401 = (message: string) => new ServiceError(HttpStatus.Unauthorized, message);
|
|
21
|
+
export const Error402 = (message: string) => new ServiceError(HttpStatus.PaymentRequired, message);
|
|
22
|
+
export const Error403 = (message: string) => new ServiceError(HttpStatus.Forbidden, message);
|
|
23
|
+
export const Error404 = (message: string) => new ServiceError(HttpStatus.NotFound, message);
|
|
24
|
+
/** The request contradicts the resource's current state. */
|
|
25
|
+
export const Error409 = (message: string) => new ServiceError(HttpStatus.Conflict, message);
|
|
26
|
+
/** Request validation failure; `body` carries the structured (field-level) errors. */
|
|
27
|
+
export const ErrorFields = (body: Record<string, unknown>, message = 'Validation failed') =>
|
|
28
|
+
new ServiceError(HttpStatus.BadRequest, message, body);
|
|
29
|
+
/** Use for rate-limit rejections. */
|
|
30
|
+
export const Error429 = (message: string) => new ServiceError(HttpStatus.TooManyRequests, message);
|
|
31
|
+
/** A deliberate 500 (config/invariant failure). */
|
|
32
|
+
export const Error500 = (message: string) =>
|
|
33
|
+
new ServiceError(HttpStatus.InternalServerError, message);
|
|
34
|
+
/** Use when a required dependency is down (fail closed). */
|
|
35
|
+
export const Error503 = (message: string) =>
|
|
36
|
+
new ServiceError(HttpStatus.ServiceUnavailable, message);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Map any thrown value to a client-safe HTTP status + message. A `ServiceError` carries its own
|
|
40
|
+
* status/message (a deliberate, client-facing failure). Anything else is unexpected and collapses
|
|
41
|
+
* to a generic 500; its message is never surfaced. `expected` lets callers pick the log level.
|
|
42
|
+
*/
|
|
43
|
+
export function toHttpError(err: unknown): {
|
|
44
|
+
body?: Record<string, unknown>;
|
|
45
|
+
expected: boolean;
|
|
46
|
+
message: string;
|
|
47
|
+
status: number;
|
|
48
|
+
} {
|
|
49
|
+
if (err instanceof ServiceError) {
|
|
50
|
+
return { body: err.body, expected: true, message: err.message, status: err.status };
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
expected: false,
|
|
54
|
+
message: 'Internal server error',
|
|
55
|
+
status: HttpStatus.InternalServerError,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Extract an HTTP status from an unknown thrown value, or null if it has none. */
|
|
60
|
+
export function getHttpStatus(error: unknown): null | number {
|
|
61
|
+
if (typeof error !== 'object' || error === null) return null;
|
|
62
|
+
const e = error as { body?: { code?: string }; status?: number | string; statusCode?: number };
|
|
63
|
+
const status = e.status ?? e.statusCode;
|
|
64
|
+
if (typeof status === 'number') return status;
|
|
65
|
+
if (status === 'BAD_REQUEST' || e.body?.code === 'BAD_REQUEST') return 400;
|
|
66
|
+
return null;
|
|
67
|
+
}
|
package/utils/form.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export type FormResponseType = 'error' | 'ok' | 'warning';
|
|
4
|
+
export type FormResult<T> = {
|
|
5
|
+
data: T;
|
|
6
|
+
message?: string;
|
|
7
|
+
type: FormResponseType;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Utility functions to return form responses */
|
|
11
|
+
export const FormResult = {
|
|
12
|
+
/** Returns a form error with data and a message */
|
|
13
|
+
errData<T>(info: { data: T; message?: string }): FormResult<T> {
|
|
14
|
+
return {
|
|
15
|
+
...info,
|
|
16
|
+
type: 'error',
|
|
17
|
+
};
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
/** Returns a form error with a message */
|
|
21
|
+
err(message?: string): FormResult<void> {
|
|
22
|
+
return {
|
|
23
|
+
data: undefined,
|
|
24
|
+
message,
|
|
25
|
+
type: 'error',
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
/** Returns a form warning with data and a message */
|
|
30
|
+
warnData<T>(info: { data: T; message?: string }): FormResult<T> {
|
|
31
|
+
return {
|
|
32
|
+
...info,
|
|
33
|
+
type: 'warning',
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
/** Returns a form warning with a message */
|
|
38
|
+
warn(message?: string): FormResult<void> {
|
|
39
|
+
return {
|
|
40
|
+
data: undefined,
|
|
41
|
+
message,
|
|
42
|
+
type: 'warning',
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
/** Returns a form ok with data and a message */
|
|
47
|
+
okData<T>(info: { data: T; message?: string }): FormResult<T> {
|
|
48
|
+
return {
|
|
49
|
+
...info,
|
|
50
|
+
type: 'ok',
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
/** Returns a form ok with a message */
|
|
55
|
+
ok(message?: string): FormResult<void> {
|
|
56
|
+
return {
|
|
57
|
+
data: undefined,
|
|
58
|
+
message,
|
|
59
|
+
type: 'ok',
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Number field — parses string or number input to any finite number (unbounded).
|
|
66
|
+
* Accepts numbers directly because `convert_formdata` parses `n:`-prefixed fields
|
|
67
|
+
* via `parseFloat`, so preflight validation receives an already-parsed number.
|
|
68
|
+
*/
|
|
69
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
70
|
+
const formInt = z.any().transform((input: any, ctx) => {
|
|
71
|
+
if (input === undefined || (typeof input === 'string' && input.trim() === '')) {
|
|
72
|
+
ctx.issues.push({
|
|
73
|
+
code: 'invalid_type',
|
|
74
|
+
expected: 'number',
|
|
75
|
+
input,
|
|
76
|
+
message: 'number is required',
|
|
77
|
+
});
|
|
78
|
+
return z.NEVER;
|
|
79
|
+
}
|
|
80
|
+
const n = typeof input === 'number' ? input : Number(input);
|
|
81
|
+
if (!Number.isFinite(n)) {
|
|
82
|
+
ctx.issues.push({
|
|
83
|
+
code: 'invalid_type',
|
|
84
|
+
expected: 'number',
|
|
85
|
+
input,
|
|
86
|
+
message: 'invalid number',
|
|
87
|
+
});
|
|
88
|
+
return z.NEVER;
|
|
89
|
+
}
|
|
90
|
+
return n;
|
|
91
|
+
}) as z.ZodType<number, number | string>;
|
|
92
|
+
|
|
93
|
+
/** `<Form>`/remote-function field primitives that genuinely depend on SvelteKit's FormData
|
|
94
|
+
* conversion conventions (`b:`/`n:`-prefixed field coercion, `RemoteFormInput`'s empty-string
|
|
95
|
+
* handling) — a plain object of schemas/functions (a Library, same pattern as `Crypto`/`Schema`
|
|
96
|
+
* in the kit), kept here rather than in `kit` because these aren't portable facts. */
|
|
97
|
+
export const SchemaForm = {
|
|
98
|
+
/** Boolean field — SvelteKit converts `b:`-prefixed checkbox inputs to `boolean`; defaults to
|
|
99
|
+
* `false` */
|
|
100
|
+
Boolean: z
|
|
101
|
+
.boolean()
|
|
102
|
+
.optional()
|
|
103
|
+
.transform((v) => v ?? false)
|
|
104
|
+
.pipe(z.boolean()),
|
|
105
|
+
|
|
106
|
+
Int: formInt,
|
|
107
|
+
|
|
108
|
+
/** Number field — finite number bound to 0 or greater */
|
|
109
|
+
Number: formInt.pipe(z.number().min(0, 'must be 0 or greater')),
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Wraps any string-or-number schema so an empty input becomes `null`.
|
|
113
|
+
* Usage: `SchemaForm.nullable(Schema.Text.min(3))`, `SchemaForm.nullable(SchemaForm.Number)`.
|
|
114
|
+
*
|
|
115
|
+
* Transport-agnostic at runtime — parses both a `<Form>` value (FormData: empty field = `''`)
|
|
116
|
+
* and a JSON body (absent/optional field = real `null` or `undefined`). All three (`''`, `null`,
|
|
117
|
+
* `undefined`) normalise to `null`; a value passes through to the wrapped schema. So the same
|
|
118
|
+
* helper can back a `<Form>` *and* a JSON API/command body for an optional scalar field.
|
|
119
|
+
*
|
|
120
|
+
* The declared **input type stays `number | string`** on purpose: `<Form>`'s `RemoteFormInput`
|
|
121
|
+
* forbids `null`/`undefined` field values, so widening the type would break form usage. `null`/
|
|
122
|
+
* `undefined` are accepted at runtime (the union below) without being exposed in the type —
|
|
123
|
+
* JSON parsing is runtime validation anyway, so the narrower type costs the JSON path nothing.
|
|
124
|
+
*/
|
|
125
|
+
nullable<T extends z.ZodType<unknown, number | string>>(schema: T) {
|
|
126
|
+
return z
|
|
127
|
+
.union([z.string(), z.number(), z.null(), z.undefined()])
|
|
128
|
+
.transform((v): null | number | string => {
|
|
129
|
+
// Empty form field (''), absent/empty n:-field (undefined), or JSON null → null.
|
|
130
|
+
if (v == null || (typeof v === 'string' && v.trim() === '')) return null;
|
|
131
|
+
return v;
|
|
132
|
+
})
|
|
133
|
+
.pipe(schema.nullable()) as z.ZodType<null | z.infer<T>, number | string>;
|
|
134
|
+
},
|
|
135
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const SuccessCode = {
|
|
2
|
+
Accepted: 202, // Request accepted but not yet processed. Use for async/queued operations.
|
|
3
|
+
Created: 201, // Resource was created. Use for POST requests that result in a new resource.
|
|
4
|
+
NoContent: 204, // Success with no body to return. Use for DELETE or actions with no response data.
|
|
5
|
+
OK: 200, // Standard success. Use for GET, PUT, or PATCH requests that return a body.
|
|
6
|
+
} as const;
|
|
7
|
+
|
|
8
|
+
export const RedirectCode = {
|
|
9
|
+
Found: 302, // Temporary redirect. Client should continue using the original URL.
|
|
10
|
+
MovedPermanently: 301, // Resource has permanently moved. Clients should update their bookmarks.
|
|
11
|
+
PermanentRedirect: 308, // Permanent redirect preserving the original HTTP method.
|
|
12
|
+
SeeOther: 303, // Redirect to a different resource, always with GET. Use after POST to prevent re-submission.
|
|
13
|
+
TemporaryRedirect: 307, // Temporary redirect preserving the original HTTP method.
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
export const ClientErrorCode = {
|
|
17
|
+
BadRequest: 400, // Malformed request syntax or invalid parameters.
|
|
18
|
+
Conflict: 409, // Request conflicts with the current state (e.g. duplicate resource).
|
|
19
|
+
Forbidden: 403, // Authenticated but not authorised to access this resource.
|
|
20
|
+
Gone: 410, // Resource permanently deleted and no longer available.
|
|
21
|
+
MethodNotAllowed: 405, // HTTP method is not supported for this endpoint.
|
|
22
|
+
NotFound: 404, // Resource does not exist.
|
|
23
|
+
PaymentRequired: 402, // Payment is required to access this resource.
|
|
24
|
+
TooManyRequests: 429, // Rate limit exceeded.
|
|
25
|
+
Unauthorized: 401, // Authentication required or credentials are invalid.
|
|
26
|
+
UnprocessableEntity: 422, // Request is well-formed but contains semantic errors.
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
export const ServerErrorCode = {
|
|
30
|
+
BadGateway: 502, // Upstream service returned an invalid response.
|
|
31
|
+
GatewayTimeout: 504, // Upstream service did not respond in time.
|
|
32
|
+
InternalServerError: 500, // Unexpected server-side failure.
|
|
33
|
+
NotImplemented: 501, // Endpoint or feature not yet implemented.
|
|
34
|
+
ServiceUnavailable: 503, // Server is temporarily unavailable (overloaded or down for maintenance).
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
export const HttpStatus = {
|
|
38
|
+
...SuccessCode,
|
|
39
|
+
...RedirectCode,
|
|
40
|
+
...ClientErrorCode,
|
|
41
|
+
...ServerErrorCode,
|
|
42
|
+
} as const;
|
package/utils/money.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format an amount held in minor units (pence, cents, ...) for display. Store money as integer
|
|
3
|
+
* minor units and only format at the edge. The divisor follows the currency's own fraction digits,
|
|
4
|
+
* so zero-decimal currencies (JPY) and three-decimal ones (KWD) come out right.
|
|
5
|
+
*/
|
|
6
|
+
export function formatMinorUnits(amount: number, currency: string, locale: string): string {
|
|
7
|
+
const formatter = new Intl.NumberFormat(locale, { currency, style: 'currency' });
|
|
8
|
+
const digits = formatter.resolvedOptions().maximumFractionDigits ?? 2;
|
|
9
|
+
return formatter.format(amount / 10 ** digits);
|
|
10
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export function parseIPv4(ip: string): null | number[] {
|
|
2
|
+
const parts = ip.split('.').map(Number);
|
|
3
|
+
return parts.length === 4 && parts.every((n) => Number.isInteger(n) && n >= 0 && n <= 255)
|
|
4
|
+
? parts
|
|
5
|
+
: null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function parseIPv6(ip: string): null | number[] {
|
|
9
|
+
let text = ip;
|
|
10
|
+
const lastColon = text.lastIndexOf(':');
|
|
11
|
+
if (text.includes('.')) {
|
|
12
|
+
const v4 = parseIPv4(text.slice(lastColon + 1));
|
|
13
|
+
if (!v4) return null;
|
|
14
|
+
text = `${text.slice(0, lastColon + 1)}${((v4[0] << 8) | v4[1]).toString(16)}:${((v4[2] << 8) | v4[3]).toString(16)}`;
|
|
15
|
+
}
|
|
16
|
+
const halves = text.split('::');
|
|
17
|
+
if (halves.length > 2) return null;
|
|
18
|
+
const head = halves[0] ? halves[0].split(':') : [];
|
|
19
|
+
const tail = halves[1] ? halves[1].split(':') : [];
|
|
20
|
+
const fill = halves.length === 2 ? 8 - head.length - tail.length : 0;
|
|
21
|
+
if (fill < 0 || (halves.length === 1 && head.length !== 8)) return null;
|
|
22
|
+
const groups = [...head, ...Array<string>(fill).fill('0'), ...tail].map((g) =>
|
|
23
|
+
/^[0-9a-f]{1,4}$/i.test(g) ? parseInt(g, 16) : NaN
|
|
24
|
+
);
|
|
25
|
+
return groups.length === 8 && groups.every((g) => !Number.isNaN(g)) ? groups : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Not publicly routable: this-network, RFC1918, CGNAT (covers Alibaba metadata 100.100.100.200),
|
|
29
|
+
// loopback, link-local (covers AWS/GCP/Azure metadata 169.254.169.254), IETF/benchmark, multicast+reserved.
|
|
30
|
+
function isPrivateIPv4([a, b, c]: number[]): boolean {
|
|
31
|
+
return (
|
|
32
|
+
a === 0 ||
|
|
33
|
+
a === 10 ||
|
|
34
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
35
|
+
a === 127 ||
|
|
36
|
+
(a === 169 && b === 254) ||
|
|
37
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
38
|
+
(a === 192 && b === 0 && c === 0) ||
|
|
39
|
+
(a === 192 && b === 168) ||
|
|
40
|
+
(a === 198 && (b === 18 || b === 19)) ||
|
|
41
|
+
a >= 224
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Unparseable input counts as private: fail closed.
|
|
46
|
+
export function isPrivateAddress(address: string): boolean {
|
|
47
|
+
const v4 = parseIPv4(address);
|
|
48
|
+
if (v4) return isPrivateIPv4(v4);
|
|
49
|
+
const g = parseIPv6(address);
|
|
50
|
+
if (!g) return true;
|
|
51
|
+
// ::/96 (incl. :: and ::1) and ::ffff:0:0/96 embed an IPv4 address in the last 32 bits.
|
|
52
|
+
if (g.slice(0, 5).every((x) => x === 0) && (g[5] === 0 || g[5] === 0xffff)) {
|
|
53
|
+
return isPrivateIPv4([g[6] >> 8, g[6] & 0xff, g[7] >> 8, g[7] & 0xff]);
|
|
54
|
+
}
|
|
55
|
+
// fc00::/7 unique-local (covers AWS fd00:ec2::254), fe80::/10 link-local, ff00::/8 multicast.
|
|
56
|
+
return (g[0] & 0xfe00) === 0xfc00 || (g[0] & 0xffc0) === 0xfe80 || (g[0] & 0xff00) === 0xff00;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Whether a URL hostname could be a public destination, judged without DNS: an IP literal must
|
|
60
|
+
* be publicly routable and `localhost` is out. A name that resolves to a private address is
|
|
61
|
+
* caught at delivery time instead. */
|
|
62
|
+
export function isPublicHost(hostname: string): boolean {
|
|
63
|
+
const host = hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
64
|
+
if (host === 'localhost' || host.endsWith('.localhost')) return false;
|
|
65
|
+
return parseIPv4(host) || host.includes(':') ? !isPrivateAddress(host) : true;
|
|
66
|
+
}
|
package/utils/schema.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CalendarDate as calendarImport,
|
|
3
|
+
parseDate,
|
|
4
|
+
parseTime,
|
|
5
|
+
Time as timeImport,
|
|
6
|
+
ZonedDateTime,
|
|
7
|
+
} from '@internationalized/date';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
|
|
10
|
+
import { Datetime, Days } from './datetime';
|
|
11
|
+
import { Normalize, titleCase } from './strings/normalize';
|
|
12
|
+
|
|
13
|
+
const NAME_MAX = 50;
|
|
14
|
+
|
|
15
|
+
const uuid = z.uuid();
|
|
16
|
+
|
|
17
|
+
const SORT_DIRECTIONS = ['asc', 'desc'] as const;
|
|
18
|
+
export type SortDirection = (typeof SORT_DIRECTIONS)[number];
|
|
19
|
+
|
|
20
|
+
function normalizeSlug(input: string): string {
|
|
21
|
+
return input
|
|
22
|
+
.trim()
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.replace(/^\/+/, '') // remove leading slashes
|
|
25
|
+
.replace(/\/+$/, ''); // remove trailing slashes
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const slug = z
|
|
29
|
+
.string()
|
|
30
|
+
.min(1)
|
|
31
|
+
.transform(normalizeSlug)
|
|
32
|
+
.refine((s) => !s.includes('/'), { message: 'Slug must not contain slashes' })
|
|
33
|
+
.refine((s) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(s), { message: 'Invalid slug format' });
|
|
34
|
+
|
|
35
|
+
/** Reusable zod primitives — a plain object of schemas/functions (a Library, same pattern as
|
|
36
|
+
* `Crypto`), not a service: nothing here reads a binding or holds state. */
|
|
37
|
+
export const Schema = {
|
|
38
|
+
// Phone: allows "+", digits, and spaces only
|
|
39
|
+
Phone: z
|
|
40
|
+
.string()
|
|
41
|
+
.regex(/^[+0-9 ]+$/, 'invalid phone number. Only +, digits, and spaces are allowed.'),
|
|
42
|
+
|
|
43
|
+
/** 6-digit hex color string (without leading `#`) */
|
|
44
|
+
HexColor: z
|
|
45
|
+
.string({ error: 'please pick a color' })
|
|
46
|
+
.regex(/^[0-9a-fA-F]{6}$/, 'invalid hex color, expected RRGGBB'),
|
|
47
|
+
|
|
48
|
+
// Datetime types
|
|
49
|
+
|
|
50
|
+
Day: z.enum(Days),
|
|
51
|
+
|
|
52
|
+
Time: z.instanceof(timeImport, { error: 'invalid time' }),
|
|
53
|
+
|
|
54
|
+
CalendarDate: z.instanceof(calendarImport, { error: 'invalid date' }),
|
|
55
|
+
|
|
56
|
+
/** ISO 8601 date string ("YYYY-MM-DD") parsed to a `CalendarDate` — the wire-string sibling of
|
|
57
|
+
* `CalendarDate` above (which validates an already-decoded instance). */
|
|
58
|
+
DateFromString: z.string().transform((text, ctx) => {
|
|
59
|
+
try {
|
|
60
|
+
return parseDate(text);
|
|
61
|
+
} catch {
|
|
62
|
+
ctx.issues.push({
|
|
63
|
+
code: 'invalid_format',
|
|
64
|
+
format: 'date',
|
|
65
|
+
input: text,
|
|
66
|
+
message: 'invalid date format, expected YYYY-MM-DD',
|
|
67
|
+
});
|
|
68
|
+
return z.NEVER;
|
|
69
|
+
}
|
|
70
|
+
}),
|
|
71
|
+
|
|
72
|
+
/** ISO 8601 time string ("HH:mm") parsed to a `Time` — the wire-string sibling of `Time` above. */
|
|
73
|
+
TimeFromString: z.string().transform((text, ctx) => {
|
|
74
|
+
try {
|
|
75
|
+
return parseTime(text);
|
|
76
|
+
} catch {
|
|
77
|
+
ctx.issues.push({
|
|
78
|
+
code: 'invalid_format',
|
|
79
|
+
format: 'time',
|
|
80
|
+
input: text,
|
|
81
|
+
message: 'invalid time format, expected HH:mm',
|
|
82
|
+
});
|
|
83
|
+
return z.NEVER;
|
|
84
|
+
}
|
|
85
|
+
}),
|
|
86
|
+
|
|
87
|
+
/** The one domain datetime type: a value is ALWAYS a ZonedDateTime. This schema just accepts
|
|
88
|
+
* the two wire forms it can arrive in — an already-decoded ZonedDateTime (transport) or a
|
|
89
|
+
* ClickHouse `unix_timestamp` epoch (a number, or a quoted string depending on server
|
|
90
|
+
* settings) — and yields a ZonedDateTime either way. */
|
|
91
|
+
ZonedDateTime: z.union([
|
|
92
|
+
z.instanceof(ZonedDateTime),
|
|
93
|
+
z.coerce.number().transform(Datetime.zonedFromUnixSeconds),
|
|
94
|
+
]),
|
|
95
|
+
|
|
96
|
+
/** A `ZonedDateTime` that crosses a serialization boundary (a queue) as unix seconds. */
|
|
97
|
+
ZonedDateTimeWire: z.codec(z.number().int(), z.instanceof(ZonedDateTime), {
|
|
98
|
+
decode: Datetime.zonedFromUnixSeconds,
|
|
99
|
+
encode: Datetime.toUnixSeconds,
|
|
100
|
+
}),
|
|
101
|
+
|
|
102
|
+
UUID: uuid,
|
|
103
|
+
UUIDs: z.array(uuid),
|
|
104
|
+
|
|
105
|
+
// Positive integer for primary keys — mirrors auto-increment DB ids (>= 1)
|
|
106
|
+
Id: z.number().int().positive(),
|
|
107
|
+
|
|
108
|
+
// Like `Id`, but also accepts the non-positive sentinel ids a handful of reserved rows use
|
|
109
|
+
// (e.g. `ORG_ACCOUNT_ID = 0`, `MARKETING_ACCOUNT_ID = -1`) — anywhere a real
|
|
110
|
+
// auto-generated row and a reserved shared row can both appear.
|
|
111
|
+
SignedId: z.number().int(),
|
|
112
|
+
|
|
113
|
+
StringId: z.string().min(1),
|
|
114
|
+
|
|
115
|
+
// `.trim()` MUST run before the `z.email()` format check, not after: zod validates a schema's
|
|
116
|
+
// own built-in format before any transform chained onto it runs, so `z.email().trim()` would
|
|
117
|
+
// reject padded input outright instead of trimming it.
|
|
118
|
+
Email: z.string().trim().max(100).toLowerCase().pipe(z.email('invalid email address')),
|
|
119
|
+
|
|
120
|
+
Emails: z.array(z.email()),
|
|
121
|
+
|
|
122
|
+
SearchTerm: z.string().min(2, 'Search term must be at least 2 characters long'),
|
|
123
|
+
|
|
124
|
+
/** A person's first/last name — title-cased ("SHELDON" / "mark" → "Sheldon" / "Mark"). Not for
|
|
125
|
+
* free text (account/campaign/list names use `Text` instead — title-casing would mangle
|
|
126
|
+
* brand names/acronyms). */
|
|
127
|
+
Name: z.string().trim().min(1).max(NAME_MAX).transform(titleCase),
|
|
128
|
+
|
|
129
|
+
/** Non-empty text field (trimmed, min 1 character) */
|
|
130
|
+
Text: z.string().trim().min(1, 'text cannot be empty'),
|
|
131
|
+
|
|
132
|
+
Domain: z
|
|
133
|
+
.string()
|
|
134
|
+
.min(1)
|
|
135
|
+
.transform(Normalize.domain)
|
|
136
|
+
.refine((domain) => domain !== '', { message: 'Invalid domain' }),
|
|
137
|
+
|
|
138
|
+
Slug: slug,
|
|
139
|
+
|
|
140
|
+
PaginationSchema: z.object({
|
|
141
|
+
limit: z.number(),
|
|
142
|
+
offset: z.number(),
|
|
143
|
+
}),
|
|
144
|
+
|
|
145
|
+
// Direction half of a server-side column sort — pair with a per-view sort-key enum so an
|
|
146
|
+
// unknown column is rejected at the schema boundary instead of reaching the query builder.
|
|
147
|
+
SortDirection: z.enum(SORT_DIRECTIONS),
|
|
148
|
+
|
|
149
|
+
/** Requires a non-empty file upload. */
|
|
150
|
+
File: z.instanceof(File).refine((f) => f.size > 0, 'file is required'),
|
|
151
|
+
|
|
152
|
+
// Zod utilities
|
|
153
|
+
|
|
154
|
+
/** Type string which is trimmed before being validated against `typeTo`. */
|
|
155
|
+
trimTo<T extends z.ZodType<string, string>>(typeTo: T) {
|
|
156
|
+
return z.string().trim().pipe(typeTo);
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
/** A fixed-length numeric PIN string (default 6 digits). */
|
|
160
|
+
pin(length = 6) {
|
|
161
|
+
return z.string().regex(new RegExp(`^\\d{${length}}$`), `pin must be ${length} digits`);
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
/** Constrained to one of the provided string options. */
|
|
165
|
+
select<T extends readonly [string, ...string[]]>(options: T) {
|
|
166
|
+
return z.enum(options);
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export type Emails = z.infer<typeof Schema.Emails>;
|
|
171
|
+
export type Id = z.infer<typeof Schema.Id>;
|
|
172
|
+
export type PaginationSchema = z.infer<typeof Schema.PaginationSchema>;
|
|
173
|
+
export type SignedId = z.infer<typeof Schema.SignedId>;
|
|
174
|
+
export type Slug = z.infer<typeof Schema.Slug>;
|
|
175
|
+
export type UUID = z.infer<typeof Schema.UUID>;
|
|
176
|
+
export type UUIDs = z.infer<typeof Schema.UUIDs>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const HTML_ESCAPES: Record<string, string> = {
|
|
2
|
+
"'": ''',
|
|
3
|
+
'"': '"',
|
|
4
|
+
'&': '&',
|
|
5
|
+
'<': '<',
|
|
6
|
+
'>': '>',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** Turns a raw value into a display string — a plain object of functions (a Library, same pattern
|
|
10
|
+
* as `Crypto`/`Schema`/`Normalize`), not a service: pure output formatting, no I/O. Distinct from
|
|
11
|
+
* `Normalize` (canonicalizing a value down to its minimal storage form) — this goes the other
|
|
12
|
+
* way, storage/domain value → presentation string. */
|
|
13
|
+
export const Format = {
|
|
14
|
+
/** Escape a string for safe interpolation into HTML text/attribute content. */
|
|
15
|
+
escapeHtml(value: string): string {
|
|
16
|
+
return value.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]!);
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
/** USD currency, always 2 decimal places, e.g. 1234.5 → "$1,234.50". */
|
|
20
|
+
money(amount: number): string {
|
|
21
|
+
return amount.toLocaleString(undefined, {
|
|
22
|
+
currency: 'USD',
|
|
23
|
+
maximumFractionDigits: 2,
|
|
24
|
+
minimumFractionDigits: 2,
|
|
25
|
+
style: 'currency',
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
/** One CSV cell, always double-quoted (embedded `"` doubled) — `null`/`undefined` → `''`. */
|
|
30
|
+
csvField(value: null | number | string | undefined): string {
|
|
31
|
+
return value == null ? '' : `"${String(value).replace(/"/g, '""')}"`;
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
/** Abbreviated count with a trailing "+", e.g. 1_234_567 → "1.2M+", 3_400 → "3K+". Falsy/missing → "N/A". */
|
|
35
|
+
compactCount(n: null | number | undefined): string {
|
|
36
|
+
if (!n) return 'N/A';
|
|
37
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M+`;
|
|
38
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K+`;
|
|
39
|
+
return `${n}`;
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
/** Abbreviated USD amount with a T/B/M suffix, e.g. 1_500_000 → "$1.5M". `<= 0`/missing → "N/A". */
|
|
43
|
+
compactMoney(amount: null | number | undefined): string {
|
|
44
|
+
if (!amount || amount <= 0) return 'N/A';
|
|
45
|
+
if (amount >= 1_000_000_000_000) return `$${(amount / 1_000_000_000_000).toFixed(2)}T`;
|
|
46
|
+
if (amount >= 1_000_000_000)
|
|
47
|
+
return `$${(amount / 1_000_000_000).toFixed(1).replace(/\.0$/, '')}B`;
|
|
48
|
+
if (amount >= 1_000_000) return `$${(amount / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
|
|
49
|
+
return `$${amount.toLocaleString('en-US')}`;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { parse } from 'tldts';
|
|
2
|
+
|
|
3
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
4
|
+
|
|
5
|
+
export function isEmail(value: string): boolean {
|
|
6
|
+
return EMAIL_RE.test(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Cleans a raw value down to its minimal storage form — a plain object of functions (a
|
|
10
|
+
* Library, same pattern as `Crypto`/`Schema`), not a service: pure string transforms, no I/O. */
|
|
11
|
+
export const Normalize = {
|
|
12
|
+
email(value: string): string {
|
|
13
|
+
return value.trim().toLowerCase();
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The registrable (root) domain for a URL or bare hostname via the Public Suffix List, or `''`
|
|
18
|
+
* when none can be parsed. e.g. `https://www.skma.co.uk/x` → `skma.co.uk`.
|
|
19
|
+
*/
|
|
20
|
+
domain(raw: string): string {
|
|
21
|
+
const value = raw.trim().toLowerCase();
|
|
22
|
+
if (!value) return '';
|
|
23
|
+
const url = /^[a-z][a-z0-9+.-]*:\/\//.test(value) ? value : `https://${value}`;
|
|
24
|
+
return parse(url).domain ?? '';
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A URL cleaned to its minimal storage form: no protocol (`https` is always assumed at
|
|
29
|
+
* display/export — see `fullUrl` in `lists/columns.ts`), `www` dropped, any other subdomain
|
|
30
|
+
* kept, no trailing slash-only path. `''` when nothing parseable.
|
|
31
|
+
* e.g. `https://www.domain.com/` → `domain.com`; `http://blog.domain.com` → `blog.domain.com`;
|
|
32
|
+
* `domain.com/pricing` → `domain.com/pricing`.
|
|
33
|
+
*/
|
|
34
|
+
url(raw: string): string {
|
|
35
|
+
const value = raw.trim();
|
|
36
|
+
if (!value) return '';
|
|
37
|
+
const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `https://${value}`;
|
|
38
|
+
let url: URL;
|
|
39
|
+
try {
|
|
40
|
+
url = new URL(withProtocol);
|
|
41
|
+
} catch {
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
const parsed = parse(url.hostname.toLowerCase());
|
|
45
|
+
if (!parsed.domain) return '';
|
|
46
|
+
const subdomain = parsed.subdomain && parsed.subdomain !== 'www' ? `${parsed.subdomain}.` : '';
|
|
47
|
+
const path = url.pathname === '/' ? '' : url.pathname.replace(/\/+$/, '');
|
|
48
|
+
return `${subdomain}${parsed.domain}${path}${url.search}${url.hash}`;
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* An X/Twitter profile reduced to its bare handle: no `@`, no host, no protocol — storage is
|
|
53
|
+
* always the minimal form, `xFullUrl` (`lists/columns.ts`) rebuilds the clickable profile URL at
|
|
54
|
+
* display/export time. Accepts a full URL (`x.com` or the legacy `twitter.com` host), a bare
|
|
55
|
+
* `@handle`, or an already-bare handle. `''` when nothing usable is left.
|
|
56
|
+
* e.g. `https://x.com/elonmusk` → `elonmusk`; `twitter.com/elonmusk` → `elonmusk`;
|
|
57
|
+
* `@elonmusk` → `elonmusk`.
|
|
58
|
+
*/
|
|
59
|
+
xHandle(raw: string): string {
|
|
60
|
+
const trimmed = raw.trim().replace(/^\/+|\/+$/g, '');
|
|
61
|
+
if (!trimmed) return '';
|
|
62
|
+
if (!/(?:^|[./])(x|twitter)\.com/i.test(trimmed)) return trimmed.replace(/^@/, ''); // already bare
|
|
63
|
+
const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
64
|
+
try {
|
|
65
|
+
return new URL(withProtocol).pathname.replace(/^\/+|\/+$/g, '');
|
|
66
|
+
} catch {
|
|
67
|
+
return trimmed.replace(/^@/, '');
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Deterministic slug: lowercase, non-alphanumeric runs collapsed to a single `-`, no leading or
|
|
73
|
+
* trailing dash. e.g. "Q3 Growth — Final!" → "q3-growth-final". */
|
|
74
|
+
export function slugify(value: string): string {
|
|
75
|
+
return value
|
|
76
|
+
.trim()
|
|
77
|
+
.toLowerCase()
|
|
78
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
79
|
+
.replace(/^-+|-+$/g, '');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Title-cases each word ("SHELDON" / "mark" → "Sheldon" / "Mark") — for a person's name, not
|
|
83
|
+
* free text (would mangle brand names/acronyms). */
|
|
84
|
+
export function titleCase(str: string): string {
|
|
85
|
+
return str.replace(
|
|
86
|
+
/\w\S*/g,
|
|
87
|
+
(txt) => txt.charAt(0).toUpperCase() + txt.substring(1).toLowerCase()
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** e.g. "John Doe" → "JD". Falls back to "?" for a blank/whitespace-only name. */
|
|
92
|
+
export function initials(name: string, max = 2): string {
|
|
93
|
+
return (
|
|
94
|
+
name
|
|
95
|
+
.split(' ')
|
|
96
|
+
.filter(Boolean)
|
|
97
|
+
.slice(0, max)
|
|
98
|
+
.map((w) => w[0]?.toUpperCase())
|
|
99
|
+
.join('') || '?'
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Subsequence match — every character of `needle` appears in `haystack`, in order. */
|
|
104
|
+
export function fuzzySearch(needle: string, haystack: string): boolean {
|
|
105
|
+
const hlen = haystack.length;
|
|
106
|
+
const nlen = needle.length;
|
|
107
|
+
|
|
108
|
+
if (nlen > hlen) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
if (nlen === hlen) {
|
|
112
|
+
return needle.toLowerCase() === haystack.toLowerCase();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const lowerNeedle = needle.toLowerCase();
|
|
116
|
+
const lowerHaystack = haystack.toLowerCase();
|
|
117
|
+
|
|
118
|
+
outer: for (let i = 0, j = 0; i < nlen; i++) {
|
|
119
|
+
const nch = lowerNeedle.charCodeAt(i);
|
|
120
|
+
while (j < hlen) {
|
|
121
|
+
if (lowerHaystack.charCodeAt(j++) === nch) {
|
|
122
|
+
continue outer;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return true;
|
|
129
|
+
}
|