dynalitics 0.0.0-stage → 0.1.0

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 (139) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +261 -2
  3. package/dist/adapter-version.d.ts +3 -0
  4. package/dist/adapter-version.js +3 -0
  5. package/dist/adapter.d.ts +12 -0
  6. package/dist/adapter.js +7 -0
  7. package/dist/cli/io.d.ts +9 -0
  8. package/dist/cli/io.js +10 -0
  9. package/dist/cli/run.d.ts +4 -0
  10. package/dist/cli/run.js +39 -0
  11. package/dist/cli/sql.d.ts +5 -0
  12. package/dist/cli/sql.js +129 -0
  13. package/dist/cli.d.ts +3 -0
  14. package/dist/cli.js +5 -0
  15. package/dist/config/schema.d.ts +64 -0
  16. package/dist/config/schema.js +248 -0
  17. package/dist/config/validate.d.ts +30 -0
  18. package/dist/config/validate.js +68 -0
  19. package/dist/create.d.ts +16 -0
  20. package/dist/create.js +308 -0
  21. package/dist/dialect/common.d.ts +54 -0
  22. package/dist/dialect/common.js +197 -0
  23. package/dist/dialect/index.d.ts +5 -0
  24. package/dist/dialect/index.js +8 -0
  25. package/dist/dialect/mssql.d.ts +3 -0
  26. package/dist/dialect/mssql.js +104 -0
  27. package/dist/dialect/mysql.d.ts +3 -0
  28. package/dist/dialect/mysql.js +140 -0
  29. package/dist/dialect/postgres.d.ts +5 -0
  30. package/dist/dialect/postgres.js +90 -0
  31. package/dist/dialect/types.d.ts +79 -0
  32. package/dist/dialect/types.js +2 -0
  33. package/dist/driver/types.d.ts +74 -0
  34. package/dist/driver/types.js +66 -0
  35. package/dist/errors.d.ts +39 -0
  36. package/dist/errors.js +39 -0
  37. package/dist/index.d.ts +7 -0
  38. package/dist/index.js +3 -0
  39. package/dist/internals.d.ts +20 -0
  40. package/dist/internals.js +8 -0
  41. package/dist/logger.d.ts +10 -0
  42. package/dist/logger.js +24 -0
  43. package/dist/model/column-model.d.ts +7 -0
  44. package/dist/model/column-model.js +240 -0
  45. package/dist/model/identifiers.d.ts +19 -0
  46. package/dist/model/identifiers.js +62 -0
  47. package/dist/model/physical.d.ts +19 -0
  48. package/dist/model/physical.js +70 -0
  49. package/dist/model/types.d.ts +92 -0
  50. package/dist/model/types.js +9 -0
  51. package/dist/protocol.d.ts +222 -0
  52. package/dist/protocol.js +5 -0
  53. package/dist/queue/pipeline.d.ts +46 -0
  54. package/dist/queue/pipeline.js +282 -0
  55. package/dist/read/adapters.d.ts +7 -0
  56. package/dist/read/adapters.js +75 -0
  57. package/dist/read/buckets.d.ts +7 -0
  58. package/dist/read/buckets.js +39 -0
  59. package/dist/read/capabilities.d.ts +39 -0
  60. package/dist/read/capabilities.js +65 -0
  61. package/dist/read/handler.d.ts +48 -0
  62. package/dist/read/handler.js +63 -0
  63. package/dist/read/http.d.ts +18 -0
  64. package/dist/read/http.js +65 -0
  65. package/dist/read/index.d.ts +5 -0
  66. package/dist/read/index.js +3 -0
  67. package/dist/read/lock/authorize.d.ts +22 -0
  68. package/dist/read/lock/authorize.js +32 -0
  69. package/dist/read/lock/base-path.d.ts +11 -0
  70. package/dist/read/lock/base-path.js +12 -0
  71. package/dist/read/lock/gate.d.ts +30 -0
  72. package/dist/read/lock/gate.js +24 -0
  73. package/dist/read/lock/index.d.ts +28 -0
  74. package/dist/read/lock/index.js +46 -0
  75. package/dist/read/lock/options.d.ts +13 -0
  76. package/dist/read/lock/options.js +18 -0
  77. package/dist/read/lock/serve-html.d.ts +6 -0
  78. package/dist/read/lock/serve-html.js +38 -0
  79. package/dist/read/mask.d.ts +12 -0
  80. package/dist/read/mask.js +55 -0
  81. package/dist/read/params.d.ts +56 -0
  82. package/dist/read/params.js +150 -0
  83. package/dist/read/physical.d.ts +11 -0
  84. package/dist/read/physical.js +23 -0
  85. package/dist/read/queries.d.ts +75 -0
  86. package/dist/read/queries.js +316 -0
  87. package/dist/read/report.d.ts +21 -0
  88. package/dist/read/report.js +32 -0
  89. package/dist/read/routes.d.ts +38 -0
  90. package/dist/read/routes.js +261 -0
  91. package/dist/read/scope.d.ts +11 -0
  92. package/dist/read/scope.js +47 -0
  93. package/dist/read/search/fields.d.ts +24 -0
  94. package/dist/read/search/fields.js +67 -0
  95. package/dist/read/search/parse.d.ts +48 -0
  96. package/dist/read/search/parse.js +124 -0
  97. package/dist/read/semaphore.d.ts +6 -0
  98. package/dist/read/semaphore.js +34 -0
  99. package/dist/read/service.d.ts +42 -0
  100. package/dist/read/service.js +266 -0
  101. package/dist/read/types.d.ts +62 -0
  102. package/dist/read/types.js +2 -0
  103. package/dist/redact.d.ts +8 -0
  104. package/dist/redact.js +60 -0
  105. package/dist/report.d.ts +12 -0
  106. package/dist/report.js +39 -0
  107. package/dist/schema/generate.d.ts +46 -0
  108. package/dist/schema/generate.js +71 -0
  109. package/dist/schema/index.d.ts +5 -0
  110. package/dist/schema/index.js +3 -0
  111. package/dist/startup/compare.d.ts +22 -0
  112. package/dist/startup/compare.js +165 -0
  113. package/dist/startup/fix-sql.d.ts +15 -0
  114. package/dist/startup/fix-sql.js +94 -0
  115. package/dist/startup/privileges.d.ts +21 -0
  116. package/dist/startup/privileges.js +63 -0
  117. package/dist/startup/session-timezone.d.ts +10 -0
  118. package/dist/startup/session-timezone.js +19 -0
  119. package/dist/startup/type-families.d.ts +10 -0
  120. package/dist/startup/type-families.js +55 -0
  121. package/dist/startup/unmapped-notice.d.ts +5 -0
  122. package/dist/startup/unmapped-notice.js +28 -0
  123. package/dist/track/build-row.d.ts +50 -0
  124. package/dist/track/build-row.js +208 -0
  125. package/dist/track/custom.d.ts +31 -0
  126. package/dist/track/custom.js +83 -0
  127. package/dist/track/denied-view.d.ts +14 -0
  128. package/dist/track/denied-view.js +21 -0
  129. package/dist/track/first-visit.d.ts +19 -0
  130. package/dist/track/first-visit.js +70 -0
  131. package/dist/track/ip.d.ts +17 -0
  132. package/dist/track/ip.js +87 -0
  133. package/dist/track/limits.d.ts +7 -0
  134. package/dist/track/limits.js +26 -0
  135. package/dist/types.d.ts +136 -0
  136. package/dist/types.js +2 -0
  137. package/dist/version.d.ts +3 -0
  138. package/dist/version.js +3 -0
  139. package/package.json +71 -4
@@ -0,0 +1,65 @@
1
+ /** Sent on every response, JSON and HTML alike. */
2
+ export const SECURITY_HEADERS = {
3
+ 'Cache-Control': 'no-store',
4
+ 'X-Content-Type-Options': 'nosniff',
5
+ 'Referrer-Policy': 'no-referrer',
6
+ };
7
+ export const ERROR_STATUS = {
8
+ forbidden: 403,
9
+ not_found: 404,
10
+ method_not_allowed: 405,
11
+ invalid_parameter: 400,
12
+ range_too_large: 400,
13
+ invalid_query: 400,
14
+ timeout: 504,
15
+ unavailable: 503,
16
+ internal: 500,
17
+ };
18
+ /** Fixed, generic sentences. Never SQL, driver text, table names or connection details. */
19
+ const ERROR_MESSAGE = {
20
+ forbidden: 'Not authorised.',
21
+ not_found: 'Unknown route.',
22
+ method_not_allowed: 'Only GET is supported.',
23
+ invalid_parameter: 'Invalid parameter.',
24
+ range_too_large: 'The requested time range is too large.',
25
+ invalid_query: 'Invalid query.',
26
+ timeout: 'The query took too long. Narrow the time range.',
27
+ unavailable: 'The analytics database is not available right now.',
28
+ internal: 'Internal error.',
29
+ };
30
+ export function json(status, body, extra = {}) {
31
+ return {
32
+ status,
33
+ headers: { ...SECURITY_HEADERS, 'Content-Type': 'application/json; charset=utf-8', ...extra },
34
+ body: JSON.stringify(body),
35
+ };
36
+ }
37
+ /** Spec 18.4: one fixed sentence per reason. The query text itself is never echoed. */
38
+ export const QUERY_ERROR_MESSAGE = {
39
+ too_long: 'The query is longer than 512 characters.',
40
+ too_many_clauses: 'The query has more than 12 parts.',
41
+ unbalanced_quote: 'A quote is not closed.',
42
+ empty_value: 'A field or term has no value.',
43
+ value_too_long: 'A value is longer than 128 characters.',
44
+ unknown_field: 'Unknown field, or a field that cannot be searched.',
45
+ bad_value: "The value does not fit the field's type.",
46
+ };
47
+ export function errorResponse(code, param, query) {
48
+ const message = query !== undefined
49
+ ? QUERY_ERROR_MESSAGE[query.reason]
50
+ : param === undefined
51
+ ? ERROR_MESSAGE[code]
52
+ : `${ERROR_MESSAGE[code]} (${param})`;
53
+ return json(ERROR_STATUS[code], {
54
+ success: false,
55
+ data: null,
56
+ unavailable: null,
57
+ error: {
58
+ code,
59
+ message,
60
+ ...(param === undefined ? {} : { param }),
61
+ ...(query === undefined ? {} : { reason: query.reason, position: query.position }),
62
+ },
63
+ }, code === 'method_not_allowed' ? { Allow: 'GET' } : {});
64
+ }
65
+ //# sourceMappingURL=http.js.map
@@ -0,0 +1,5 @@
1
+ export { createHandler } from './handler.js';
2
+ export type { HandlerOptions } from './handler.js';
3
+ export { toFetchHandler, toNodeListener } from './adapters.js';
4
+ export type { AuthResult, Authorize, DynaliticsHandler, DynaliticsRequest, DynaliticsResponse, } from './types.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,3 @@
1
+ export { createHandler } from './handler.js';
2
+ export { toFetchHandler, toNodeListener } from './adapters.js';
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,22 @@
1
+ import type { Authorize, DynaliticsRequest } from '../types.js';
2
+ /** What authorize() said, read strictly. */
3
+ export interface Auth {
4
+ readonly allowed: boolean;
5
+ /** `revealPii === true` on an allowing result. Only one of the three reveal conditions (see gate.ts). */
6
+ readonly revealPii: boolean;
7
+ /** The host returned something other than true, false or { allowed: boolean }. Refused and reported. */
8
+ readonly invalid: boolean;
9
+ }
10
+ /** Strict: only the boolean true or { allowed: true } grants access, only revealPii === true reveals. */
11
+ export declare function normalizeAuth(result: unknown): Auth;
12
+ export type AuthOutcome = {
13
+ readonly kind: 'decided';
14
+ readonly auth: Auth;
15
+ }
16
+ /** authorize() threw, rejected or did not settle before the deadline. Always refused with 500, never 200. */
17
+ | {
18
+ readonly kind: 'failed';
19
+ };
20
+ /** Always first, even for unknown paths. A hung authorize() must not hold the socket open forever. */
21
+ export declare function authorizeWithDeadline(authorize: Authorize, request: DynaliticsRequest, timeoutMs: number): Promise<AuthOutcome>;
22
+ //# sourceMappingURL=authorize.d.ts.map
@@ -0,0 +1,32 @@
1
+ /** Strict: only the boolean true or { allowed: true } grants access, only revealPii === true reveals. */
2
+ export function normalizeAuth(result) {
3
+ if (typeof result === 'boolean')
4
+ return { allowed: result, revealPii: false, invalid: false };
5
+ if (typeof result === 'object' && result !== null && !Array.isArray(result)) {
6
+ const r = result;
7
+ if (typeof r.allowed === 'boolean') {
8
+ return { allowed: r.allowed, revealPii: r.allowed && r.revealPii === true, invalid: false };
9
+ }
10
+ }
11
+ return { allowed: false, revealPii: false, invalid: true };
12
+ }
13
+ /** Always first, even for unknown paths. A hung authorize() must not hold the socket open forever. */
14
+ export async function authorizeWithDeadline(authorize, request, timeoutMs) {
15
+ let timer;
16
+ try {
17
+ const deadline = new Promise((_resolve, reject) => {
18
+ timer = setTimeout(() => reject(new Error('authorize timed out')), timeoutMs);
19
+ timer.unref?.();
20
+ });
21
+ const result = await Promise.race([Promise.resolve().then(() => authorize(request)), deadline]);
22
+ return { kind: 'decided', auth: normalizeAuth(result) };
23
+ }
24
+ catch {
25
+ return { kind: 'failed' };
26
+ }
27
+ finally {
28
+ if (timer !== undefined)
29
+ clearTimeout(timer);
30
+ }
31
+ }
32
+ //# sourceMappingURL=authorize.js.map
@@ -0,0 +1,11 @@
1
+ /** '/' or one or more non-empty segments, optionally with ONE trailing slash. No '//' (a protocol-relative URL). */
2
+ export declare const BASE_PATH_PATTERN: RegExp;
3
+ export declare const isValidBasePath: (p: string) => boolean;
4
+ /** What the client prepends to '/api/...': '' for the root, no trailing slash, so it never builds //api/meta. */
5
+ export declare const normalizeBasePath: (p: string) => string;
6
+ /**
7
+ * Routes are exact matches on canonical paths only (F-17): no '//', '.', '%', ';', '?' or trailing slash. Letter case
8
+ * is not decided here: '/API/meta' is canonical but is not a route, so the database side answers not_found.
9
+ */
10
+ export declare const CANONICAL_PATH: RegExp;
11
+ //# sourceMappingURL=base-path.d.ts.map
@@ -0,0 +1,12 @@
1
+ // basePath rules (QA F-07, F-17). The lock layer owns them: they decide what the page is told to call.
2
+ /** '/' or one or more non-empty segments, optionally with ONE trailing slash. No '//' (a protocol-relative URL). */
3
+ export const BASE_PATH_PATTERN = /^(\/|(\/[A-Za-z0-9_-]+)+\/?)$/;
4
+ export const isValidBasePath = (p) => BASE_PATH_PATTERN.test(p);
5
+ /** What the client prepends to '/api/...': '' for the root, no trailing slash, so it never builds //api/meta. */
6
+ export const normalizeBasePath = (p) => (p === '/' ? '' : p.replace(/\/$/, ''));
7
+ /**
8
+ * Routes are exact matches on canonical paths only (F-17): no '//', '.', '%', ';', '?' or trailing slash. Letter case
9
+ * is not decided here: '/API/meta' is canonical but is not a route, so the database side answers not_found.
10
+ */
11
+ export const CANONICAL_PATH = /^(\/|(\/[A-Za-z0-9_-]+)+)$/;
12
+ //# sourceMappingURL=base-path.js.map
@@ -0,0 +1,30 @@
1
+ import type { ErrorCode } from '../../protocol.js';
2
+ import type { DynaliticsRequest, Grant } from '../types.js';
3
+ import type { Auth } from './authorize.js';
4
+ export interface GateConfig {
5
+ /** The `pii.reveal` option. */
6
+ readonly revealOption: boolean;
7
+ }
8
+ export type Decision = {
9
+ readonly kind: 'refuse';
10
+ readonly code: ErrorCode;
11
+ }
12
+ /** GET '/': the dashboard page (the caller answers 404 when no page was supplied). */
13
+ | {
14
+ readonly kind: 'page';
15
+ } | {
16
+ readonly kind: 'run';
17
+ readonly route: string;
18
+ readonly grant: Grant;
19
+ };
20
+ /**
21
+ * The reveal conditions, computed once, here: `pii.reveal` option AND authorize `revealPii === true` AND `?pii=reveal`.
22
+ * `?pii` is only read for equality; the service still validates it strictly and answers 400 for anything else.
23
+ */
24
+ export declare function computeGrant(auth: Auth, query: DynaliticsRequest['query'], cfg: GateConfig): Grant;
25
+ /**
26
+ * Called only for a request authorize() allowed. Order matters and is part of the contract: method, then canonical
27
+ * path (F-17), then the page, and only then a route is handed to the database side.
28
+ */
29
+ export declare function gate(request: DynaliticsRequest, auth: Auth, cfg: GateConfig): Decision;
30
+ //# sourceMappingURL=gate.d.ts.map
@@ -0,0 +1,24 @@
1
+ import { CANONICAL_PATH } from './base-path.js';
2
+ /**
3
+ * The reveal conditions, computed once, here: `pii.reveal` option AND authorize `revealPii === true` AND `?pii=reveal`.
4
+ * `?pii` is only read for equality; the service still validates it strictly and answers 400 for anything else.
5
+ */
6
+ export function computeGrant(auth, query, cfg) {
7
+ const revealable = cfg.revealOption && auth.revealPii;
8
+ return { revealable, reveal: revealable && query['pii'] === 'reveal' };
9
+ }
10
+ /**
11
+ * Called only for a request authorize() allowed. Order matters and is part of the contract: method, then canonical
12
+ * path (F-17), then the page, and only then a route is handed to the database side.
13
+ */
14
+ export function gate(request, auth, cfg) {
15
+ if (request.method !== 'GET')
16
+ return { kind: 'refuse', code: 'method_not_allowed' };
17
+ // Exact matching on canonical paths only: '//', '..', '%', ';', trailing slashes and the like never route.
18
+ if (!CANONICAL_PATH.test(request.path))
19
+ return { kind: 'refuse', code: 'not_found' };
20
+ if (request.path === '/')
21
+ return { kind: 'page' };
22
+ return { kind: 'run', route: request.path, grant: computeGrant(auth, request.query, cfg) };
23
+ }
24
+ //# sourceMappingURL=gate.js.map
@@ -0,0 +1,28 @@
1
+ import type { ErrorCode } from '../../protocol.js';
2
+ import type { Authorize, DynaliticsRequest, DynaliticsResponse, Grant, ReadOutcome } from '../types.js';
3
+ /**
4
+ * The lock layer (docs/SPEC.md 15, 16). It decides whether and how a request may run and never sees SQL. The database
5
+ * side is reached through one callback, `next(route, grant)`, and ONLY after a strict authorize() success, a GET, and a
6
+ * canonical path. This module must not import the service, queries, drivers or the database (a test enforces it).
7
+ */
8
+ export interface LockOptions {
9
+ /** REQUIRED. Read strictly: only `true` or `{ allowed: true }` grants. */
10
+ readonly authorize: Authorize;
11
+ /** '/' or '/segment/...'; validated by the caller (options rule) and normalised here. */
12
+ readonly basePath: string;
13
+ readonly dashboardHtml: string | undefined;
14
+ /** The `pii.reveal` option. */
15
+ readonly reveal: boolean;
16
+ /** authorize() has this long to settle, then the request is refused with 500. */
17
+ readonly authorizeTimeoutMs: number;
18
+ }
19
+ /** Reports a failure of the lock itself. The route is whatever the host's reporter chooses to echo; no request data. */
20
+ export type LockReport = (route: string, code: Extract<ErrorCode, 'internal'> | 'authorize_invalid_result', message: string) => void;
21
+ export type Next = (route: string, grant: Grant) => Promise<ReadOutcome>;
22
+ export interface Lock {
23
+ /** Normalised: '' for the root, otherwise '/segment/...' without a trailing slash. */
24
+ readonly basePath: string;
25
+ handle(request: DynaliticsRequest, next: Next): Promise<DynaliticsResponse>;
26
+ }
27
+ export declare function createLock(options: LockOptions, report: LockReport): Lock;
28
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,46 @@
1
+ import { errorResponse, json } from '../http.js';
2
+ import { authorizeWithDeadline } from './authorize.js';
3
+ import { normalizeBasePath } from './base-path.js';
4
+ import { gate } from './gate.js';
5
+ import { htmlResponse } from './serve-html.js';
6
+ export function createLock(options, report) {
7
+ const basePath = normalizeBasePath(options.basePath);
8
+ const cfg = { revealOption: options.reveal };
9
+ return {
10
+ basePath,
11
+ async handle(request, next) {
12
+ const outcome = await authorizeWithDeadline(options.authorize, request, options.authorizeTimeoutMs);
13
+ if (outcome.kind === 'failed') {
14
+ report(request.path, 'internal', 'authorize() threw or timed out; the request was refused');
15
+ return errorResponse('internal');
16
+ }
17
+ const { auth } = outcome;
18
+ if (auth.invalid) {
19
+ report(request.path, 'authorize_invalid_result', 'authorize() returned something other than true, false or { allowed: boolean }; the request was refused');
20
+ return errorResponse('forbidden');
21
+ }
22
+ if (!auth.allowed)
23
+ return errorResponse('forbidden');
24
+ const decision = gate(request, auth, cfg);
25
+ if (decision.kind === 'refuse')
26
+ return errorResponse(decision.code);
27
+ if (decision.kind === 'page') {
28
+ return options.dashboardHtml === undefined
29
+ ? errorResponse('not_found')
30
+ : htmlResponse(options.dashboardHtml, basePath);
31
+ }
32
+ try {
33
+ const result = await next(decision.route, decision.grant);
34
+ return result.kind === 'ok'
35
+ ? json(200, result.body)
36
+ : errorResponse(result.code, result.param, result.query);
37
+ }
38
+ catch {
39
+ // The service promises never to throw. If it does anyway, the request fails closed.
40
+ report(request.path, 'internal', 'unexpected error in the read handler');
41
+ return errorResponse('internal');
42
+ }
43
+ },
44
+ };
45
+ }
46
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Option rules owned by the lock layer. handler.ts spreads them into the one options rule (same key order, same
3
+ * messages as before the extraction); `pii.reveal` is the lock's, `pii.maskUserId` is the database side's.
4
+ */
5
+ export declare const lockOptionRules: {
6
+ authorize: import("../../config/validate.js").Rule & {
7
+ required: true;
8
+ };
9
+ basePath: import("../../config/validate.js").Rule;
10
+ authorizeTimeoutMs: import("../../config/validate.js").Rule;
11
+ dashboardHtml: import("../../config/validate.js").Rule;
12
+ };
13
+ //# sourceMappingURL=options.d.ts.map
@@ -0,0 +1,18 @@
1
+ import { fn, int, req, str } from '../../config/validate.js';
2
+ import { BASE_PATH_PATTERN } from './base-path.js';
3
+ /**
4
+ * Option rules owned by the lock layer. handler.ts spreads them into the one options rule (same key order, same
5
+ * messages as before the extraction); `pii.reveal` is the lock's, `pii.maskUserId` is the database side's.
6
+ */
7
+ export const lockOptionRules = {
8
+ authorize: req(fn),
9
+ basePath: str({
10
+ min: 1,
11
+ max: 200,
12
+ pattern: BASE_PATH_PATTERN,
13
+ hint: "must be '/' or segments like /admin/analytics (no '//', no '.', no query)",
14
+ }),
15
+ authorizeTimeoutMs: int({ min: 100, max: 60_000 }),
16
+ dashboardHtml: str({ min: 1 }),
17
+ };
18
+ //# sourceMappingURL=options.js.map
@@ -0,0 +1,6 @@
1
+ import type { DynaliticsResponse } from '../types.js';
2
+ export declare const newNonce: () => string;
3
+ export declare function csp(nonce: string): string;
4
+ /** Fills the two placeholders of the dashboard page. Nonce is base64url; the base path is pre-validated and normalised. */
5
+ export declare function htmlResponse(html: string, basePath: string, nonce?: string): DynaliticsResponse;
6
+ //# sourceMappingURL=serve-html.d.ts.map
@@ -0,0 +1,38 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { SECURITY_HEADERS } from '../http.js';
3
+ // Serving the dashboard page: per-request nonce, strict CSP, the two placeholders (docs/SPEC.md 10.8).
4
+ // CORS and CSRF posture, stated here because this layer owns it: the handler emits NO CORS headers, sets NO cookies,
5
+ // accepts only GET and never changes state, so there is nothing for a cross-site request to forge. A cross-origin
6
+ // deployment needs the host's own CORS layer in front. There is no CSRF code on purpose (SECURITY.md non-guarantee).
7
+ export const newNonce = () => randomBytes(16).toString('base64url');
8
+ export function csp(nonce) {
9
+ return [
10
+ "default-src 'none'",
11
+ `script-src 'nonce-${nonce}'`,
12
+ `style-src 'nonce-${nonce}'`,
13
+ "connect-src 'self'",
14
+ "img-src 'self' data:",
15
+ "base-uri 'none'",
16
+ "form-action 'none'",
17
+ "frame-ancestors 'none'",
18
+ ].join('; ');
19
+ }
20
+ /** Fills the two placeholders of the dashboard page. Nonce is base64url; the base path is pre-validated and normalised. */
21
+ export function htmlResponse(html, basePath, nonce = newNonce()) {
22
+ const body = html
23
+ .split('%%DYNALITICS_NONCE%%')
24
+ .join(nonce)
25
+ .split('%%DYNALITICS_BASE%%')
26
+ .join(basePath);
27
+ return {
28
+ status: 200,
29
+ headers: {
30
+ ...SECURITY_HEADERS,
31
+ 'Content-Type': 'text/html; charset=utf-8',
32
+ 'Content-Security-Policy': csp(nonce),
33
+ 'X-Frame-Options': 'DENY',
34
+ },
35
+ body,
36
+ };
37
+ }
38
+ //# sourceMappingURL=serve-html.js.map
@@ -0,0 +1,12 @@
1
+ /** IPv4 keeps two octets, IPv6 two groups, hashed values 8 hex characters. */
2
+ export declare function maskIp(ip: string | null): string | null;
3
+ /** First character of the local part and the domain: `j***@example.com`. Anything that is not exactly one address: `***`. */
4
+ export declare function maskEmail(email: string | null): string | null;
5
+ export declare function maskUsername(name: string | null): string | null;
6
+ /**
7
+ * `u_90042` -> `u_***42`: the first two and last two characters of ids of at least 6 characters, `***` for shorter ones
8
+ * (so at most 4 characters are ever shown). An id that looks like an email address is masked as an email.
9
+ */
10
+ export declare function maskUserId(id: string | null): string | null;
11
+ export declare const MASKED_CUSTOM = "***";
12
+ //# sourceMappingURL=mask.d.ts.map
@@ -0,0 +1,55 @@
1
+ /**
2
+ * PII masking (spec 10.6). Every function is TOTAL and FAILS CLOSED: a value that does not match the expected shape
3
+ * exactly comes out as a fixed "***", never as a partial copy of unparseable input.
4
+ */
5
+ const first = (s) => [...s][0] ?? '';
6
+ const FIXED = '***';
7
+ const IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}$/;
8
+ const HASH = /^[0-9a-f]{64}$/i;
9
+ const IPV6 = /^[0-9a-f]{0,4}(?::[0-9a-f]{0,4}){2,7}$/i;
10
+ const EMAIL = /^[^@\s]+@[^@\s]+$/u;
11
+ const MIN_USER_ID_LENGTH = 6;
12
+ /** IPv4 keeps two octets, IPv6 two groups, hashed values 8 hex characters. */
13
+ export function maskIp(ip) {
14
+ if (ip === null || ip === '')
15
+ return null;
16
+ if (IPV4.test(ip))
17
+ return `${ip.split('.').slice(0, 2).join('.')}.x.x`;
18
+ if (HASH.test(ip))
19
+ return `${ip.slice(0, 8)}…`;
20
+ if (IPV6.test(ip)) {
21
+ if (ip.startsWith('::'))
22
+ return '::x';
23
+ const [a = '', b = ''] = ip.split(':');
24
+ return b === '' ? `${a}::x` : `${a}:${b}::x`;
25
+ }
26
+ return FIXED; // ports, lists ("1.2.3.4, 5.6.7.8"), zone ids, words: never partially echoed
27
+ }
28
+ /** First character of the local part and the domain: `j***@example.com`. Anything that is not exactly one address: `***`. */
29
+ export function maskEmail(email) {
30
+ if (email === null)
31
+ return null;
32
+ if (!EMAIL.test(email))
33
+ return FIXED;
34
+ const at = email.indexOf('@');
35
+ return `${first(email.slice(0, at))}***${email.slice(at)}`;
36
+ }
37
+ export function maskUsername(name) {
38
+ return name === null ? null : `${first(name)}***`;
39
+ }
40
+ /**
41
+ * `u_90042` -> `u_***42`: the first two and last two characters of ids of at least 6 characters, `***` for shorter ones
42
+ * (so at most 4 characters are ever shown). An id that looks like an email address is masked as an email.
43
+ */
44
+ export function maskUserId(id) {
45
+ if (id === null)
46
+ return null;
47
+ if (id.includes('@'))
48
+ return maskEmail(id);
49
+ const chars = [...id];
50
+ if (chars.length < MIN_USER_ID_LENGTH)
51
+ return FIXED;
52
+ return `${chars.slice(0, 2).join('')}${FIXED}${chars.slice(-2).join('')}`;
53
+ }
54
+ export const MASKED_CUSTOM = FIXED;
55
+ //# sourceMappingURL=mask.js.map
@@ -0,0 +1,56 @@
1
+ import type { Interval } from '../protocol.js';
2
+ export type RouteName = 'meta' | 'visits-over-time' | 'top-pages' | 'new-vs-returning' | 'consent-rate' | 'recent-visits' | 'search' | 'search-histogram' | 'search-fields';
3
+ export interface ReadLimits {
4
+ readonly maxRangeDays: number;
5
+ readonly maxBuckets: number;
6
+ readonly topPagesMax: number;
7
+ readonly recentMax: number;
8
+ readonly newVsReturningMaxRangeDays: number;
9
+ /** Explorer routes (spec 18.3): contains-searches only use the timestamp index, so the range is capped lower. */
10
+ readonly explorerMaxRangeDays: number;
11
+ }
12
+ export interface ParamContext {
13
+ readonly now: Date;
14
+ readonly limits: ReadLimits;
15
+ readonly defaults: {
16
+ readonly rangeDays: number;
17
+ readonly interval: Interval;
18
+ readonly topPagesLimit: number;
19
+ readonly recentLimit: number;
20
+ };
21
+ /** Dimension names offered for `breakdown`. The request can only pick one of these names. */
22
+ readonly breakdowns: ReadonlySet<string>;
23
+ /** Explorer facet field names offered for `split`. */
24
+ readonly splits: ReadonlySet<string>;
25
+ readonly hasCursor: boolean;
26
+ }
27
+ export interface Cursor {
28
+ readonly ts: Date;
29
+ readonly id: string;
30
+ }
31
+ export interface ParsedParams {
32
+ readonly from: Date;
33
+ readonly to: Date;
34
+ readonly interval: Interval;
35
+ readonly limit: number;
36
+ readonly breakdown: string | undefined;
37
+ readonly cursor: Cursor | undefined;
38
+ readonly pii: boolean;
39
+ /** Explorer query text, unparsed (the search parser validates it against the allow-list). '' when absent. */
40
+ readonly q: string;
41
+ readonly split: string | undefined;
42
+ }
43
+ export type ParseResult = {
44
+ readonly ok: true;
45
+ readonly params: ParsedParams;
46
+ } | {
47
+ readonly ok: false;
48
+ readonly code: 'invalid_parameter' | 'range_too_large';
49
+ readonly param?: string;
50
+ };
51
+ export declare function encodeCursor(ts: Date, id: string): string;
52
+ /** Strict: anchored patterns only; the result is typed and later bound as parameters. */
53
+ export declare function decodeCursor(token: string): Cursor | null;
54
+ /** Parses and validates every query parameter of a route. Anything not on the route's allow-list is an error. */
55
+ export declare function parseParams(route: RouteName, query: Readonly<Record<string, string | readonly string[]>>, ctx: ParamContext): ParseResult;
56
+ //# sourceMappingURL=params.d.ts.map
@@ -0,0 +1,150 @@
1
+ import { bucketStarts } from './buckets.js';
2
+ const SEARCH_ROUTES = new Set([
3
+ 'search',
4
+ 'search-histogram',
5
+ 'search-fields',
6
+ ]);
7
+ const DAY_MS = 86_400_000;
8
+ const MAX_QUERY_CHARS = 2048;
9
+ const INTERVALS = new Set(['hour', 'day', 'week', 'month']);
10
+ const ALLOWED = {
11
+ meta: new Set(),
12
+ 'visits-over-time': new Set(['from', 'to', 'interval', 'breakdown']),
13
+ 'top-pages': new Set(['from', 'to', 'limit']),
14
+ 'new-vs-returning': new Set(['from', 'to', 'interval']),
15
+ 'consent-rate': new Set(['from', 'to', 'interval']),
16
+ 'recent-visits': new Set(['from', 'to', 'limit', 'cursor', 'pii']),
17
+ search: new Set(['from', 'to', 'limit', 'cursor', 'pii', 'q']),
18
+ 'search-histogram': new Set(['from', 'to', 'interval', 'q', 'split']),
19
+ 'search-fields': new Set(['from', 'to', 'q']),
20
+ };
21
+ const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
22
+ const INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
23
+ const CURSOR_TOKEN = /^[A-Za-z0-9_-]{1,200}$/;
24
+ const CURSOR_BODY = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)\|(\d{1,19})$/;
25
+ function parseInstant(v) {
26
+ let iso;
27
+ if (DATE_ONLY.test(v))
28
+ iso = `${v}T00:00:00.000Z`;
29
+ else if (INSTANT.test(v)) {
30
+ // pad a short fraction to milliseconds: ".5Z" -> ".500Z"; no fraction -> ".000Z"
31
+ iso = v.includes('.')
32
+ ? v.replace(/\.(\d{1,3})Z$/, (_m, f) => `.${f.padEnd(3, '0')}Z`)
33
+ : v.replace('Z', '.000Z');
34
+ }
35
+ else
36
+ return null;
37
+ const d = new Date(iso);
38
+ // Reject roll-overs such as 2026-02-30: the round trip must reproduce the input.
39
+ return Number.isNaN(d.getTime()) || d.toISOString().slice(0, 19) !== iso.slice(0, 19) ? null : d;
40
+ }
41
+ export function encodeCursor(ts, id) {
42
+ return Buffer.from(`${ts.toISOString()}|${id}`, 'utf8').toString('base64url');
43
+ }
44
+ /** Strict: anchored patterns only; the result is typed and later bound as parameters. */
45
+ export function decodeCursor(token) {
46
+ if (!CURSOR_TOKEN.test(token))
47
+ return null;
48
+ const m = CURSOR_BODY.exec(Buffer.from(token, 'base64url').toString('utf8'));
49
+ if (m?.[1] === undefined || m[2] === undefined)
50
+ return null;
51
+ const ts = parseInstant(m[1]);
52
+ return ts === null ? null : { ts, id: m[2] };
53
+ }
54
+ const invalid = (param) => param === undefined
55
+ ? { ok: false, code: 'invalid_parameter' }
56
+ : { ok: false, code: 'invalid_parameter', param };
57
+ /** Parses and validates every query parameter of a route. Anything not on the route's allow-list is an error. */
58
+ export function parseParams(route, query, ctx) {
59
+ const allowed = ALLOWED[route];
60
+ let size = 0;
61
+ const single = {};
62
+ for (const key of Object.keys(query)) {
63
+ if (!allowed.has(key))
64
+ return invalid(key);
65
+ const value = query[key];
66
+ if (typeof value !== 'string')
67
+ return invalid(key);
68
+ size += key.length + value.length;
69
+ single[key] = value;
70
+ }
71
+ if (size > MAX_QUERY_CHARS)
72
+ return invalid();
73
+ const has = (k) => Object.hasOwn(single, k);
74
+ let to = ctx.now;
75
+ if (has('to')) {
76
+ const t = parseInstant(single['to'] ?? '');
77
+ if (t === null)
78
+ return invalid('to');
79
+ to = t;
80
+ }
81
+ let from = new Date(to.getTime() - ctx.defaults.rangeDays * DAY_MS);
82
+ if (has('from')) {
83
+ const f = parseInstant(single['from'] ?? '');
84
+ if (f === null)
85
+ return invalid('from');
86
+ from = f;
87
+ }
88
+ if (route !== 'meta' && to.getTime() <= from.getTime())
89
+ return invalid('to');
90
+ let interval = ctx.defaults.interval;
91
+ if (has('interval')) {
92
+ const i = single['interval'] ?? '';
93
+ if (!INTERVALS.has(i))
94
+ return invalid('interval');
95
+ interval = i;
96
+ }
97
+ let limit = route === 'top-pages' ? ctx.defaults.topPagesLimit : ctx.defaults.recentLimit;
98
+ if (has('limit')) {
99
+ const raw = single['limit'] ?? '';
100
+ const max = route === 'top-pages' ? ctx.limits.topPagesMax : ctx.limits.recentMax;
101
+ if (!/^\d{1,4}$/.test(raw) || Number(raw) < 1 || Number(raw) > max)
102
+ return invalid('limit');
103
+ limit = Number(raw);
104
+ }
105
+ let breakdown;
106
+ if (has('breakdown')) {
107
+ const b = single['breakdown'] ?? '';
108
+ if (!ctx.breakdowns.has(b))
109
+ return invalid('breakdown');
110
+ breakdown = b;
111
+ }
112
+ let cursor;
113
+ if (has('cursor')) {
114
+ const c = decodeCursor(single['cursor'] ?? '');
115
+ if (c === null || !ctx.hasCursor)
116
+ return invalid('cursor');
117
+ cursor = c;
118
+ }
119
+ let split;
120
+ if (has('split')) {
121
+ const s = single['split'] ?? '';
122
+ if (!ctx.splits.has(s))
123
+ return invalid('split');
124
+ split = s;
125
+ }
126
+ const q = single['q'] ?? '';
127
+ let pii = false;
128
+ if (has('pii')) {
129
+ if (single['pii'] !== 'reveal')
130
+ return invalid('pii');
131
+ pii = true;
132
+ }
133
+ const rangeDays = (to.getTime() - from.getTime()) / DAY_MS;
134
+ const cap = route === 'new-vs-returning'
135
+ ? ctx.limits.newVsReturningMaxRangeDays
136
+ : SEARCH_ROUTES.has(route)
137
+ ? Math.min(ctx.limits.explorerMaxRangeDays, ctx.limits.maxRangeDays)
138
+ : ctx.limits.maxRangeDays;
139
+ if (route !== 'meta' && rangeDays > cap)
140
+ return { ok: false, code: 'range_too_large' };
141
+ const bucketed = route === 'visits-over-time' ||
142
+ route === 'new-vs-returning' ||
143
+ route === 'consent-rate' ||
144
+ route === 'search-histogram';
145
+ if (bucketed && bucketStarts(from, to, interval).length > ctx.limits.maxBuckets) {
146
+ return { ok: false, code: 'range_too_large' };
147
+ }
148
+ return { ok: true, params: { from, to, interval, limit, breakdown, cursor, pii, q, split } };
149
+ }
150
+ //# sourceMappingURL=params.js.map