server/src/mock/index.ts was 1,545 lines, the largest file in the repo.
It had carried `/* ---------- data ---------- */` style markers for a
while, so the seams were already drawn; this turns six of them into
files.
mock/config.ts 51 env-derived constants, `account`, `state`
mock/data.ts 410 fixtures and the builders that make them
mock/engine.ts 442 the generic JMAP machinery -- get/set, filters,
patches, refs, limits, recurrence plumbing
mock/handlers.ts 455 the `Method/name` dispatch table
mock/events.ts 26 SSE fan-out and the Email/changes ring buffer
mock/auth.ts 11 checkOtp
mock/index.ts 195 HTTP routing, the session document, listen
TWO THINGS THAT COULD NOT JUST MOVE:
`counter` and `vacation` were module-level `let`s written from both the
fixture builders and the handlers. An ES module can export a `let` and
importers see it update, but they cannot assign to it, so both became
containers: `seq.counter` and `vacationBox.current`. Seven call sites.
`recordEmailChange`, `broadcast`, `sseClients` and `checkOtp` lived in
the HTTP section, but the handlers call them -- and index.ts imports the
handlers. Leaving them there is a cycle, so they became events.ts and
auth.ts rather than being dragged into data.ts, which is fixtures.
`account` is still exported from index.ts, because account.test.ts and
login-guard.test.ts reach for `mock.account` and `mock.server`.
Verified by running it, not only by compiling it: `npm run mock` boots
and listens, `/.well-known/jmap` returns a session, and a POST to
`/jmap/` answers Mailbox/get with the nine seeded folders and
Email/query with the seeded messages.
443 lines
20 KiB
TypeScript
443 lines
20 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { eventGetView, expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
|
|
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
|
|
import { createDirectory, mockRole } from "./directory.js";
|
|
import { ACCOUNT, MOCK_LOCALE, Obj, USER, account, nextState, state } from "./config.js";
|
|
import { NO_SCHEDULING_SEND, SCHEDULING_FORBIDDEN, blobs, events, mailboxes } from "./data.js";
|
|
|
|
/* ---------- helpers ---------- */
|
|
export function pick(o: Obj, props?: string[] | null): Obj {
|
|
if (!props) return o;
|
|
const out: Obj = { id: o.id };
|
|
for (const p of props) if (p in o) out[p] = o[p];
|
|
else if (p.startsWith("header:")) out[p] = null;
|
|
return out;
|
|
}
|
|
export function resolveRefs(args: Obj, responses: [string, Obj, string][], creations: Record<string, string>): Obj {
|
|
const out: Obj = {};
|
|
for (const [k, v] of Object.entries(args)) {
|
|
if (k.startsWith("#")) {
|
|
const r = v as { resultOf: string; name: string; path: string };
|
|
const resp = responses.find((x) => x[2] === r.resultOf && x[0] === r.name);
|
|
out[k.slice(1)] = resp ? jsonPointer(resp[1], r.path) : [];
|
|
} else out[k] = resolveCreationIds(v, creations, k);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Creation references (RFC 8620 5.3): a `#creationId` anywhere a real id would
|
|
* go, pointing at something created earlier in the same request. Sending a
|
|
* message uses one -- `EmailSubmission/set` names the email as `#m` -- so
|
|
* without this the mock quietly declines to create any submission at all.
|
|
*
|
|
* `onSuccessUpdateEmail` is left alone: its keys are creation ids by design and
|
|
* the method that receives them resolves them itself.
|
|
*/
|
|
export function resolveCreationIds(value: unknown, creations: Record<string, string>, key?: string): unknown {
|
|
if (key === "onSuccessUpdateEmail") return value;
|
|
if (typeof value === "string") {
|
|
return value.startsWith("#") && creations[value.slice(1)] ? creations[value.slice(1)]! : value;
|
|
}
|
|
if (Array.isArray(value)) return value.map((v) => resolveCreationIds(v, creations));
|
|
if (value && typeof value === "object") {
|
|
const out: Obj = {};
|
|
for (const [k, v] of Object.entries(value as Obj)) {
|
|
const nk = k.startsWith("#") && creations[k.slice(1)] ? creations[k.slice(1)]! : k;
|
|
out[nk] = resolveCreationIds(v, creations, k);
|
|
}
|
|
return out;
|
|
}
|
|
return value;
|
|
}
|
|
export function jsonPointer(obj: unknown, path: string): unknown {
|
|
const parts = path.split("/").filter(Boolean);
|
|
let cur: unknown = obj;
|
|
for (let i = 0; i < parts.length; i++) {
|
|
const p = parts[i]!;
|
|
if (p === "*") {
|
|
const rest = parts.slice(i + 1).join("/");
|
|
const arr = (cur as unknown[]).flatMap((x) => { const v = jsonPointer(x, "/" + rest); return Array.isArray(v) ? v : [v]; });
|
|
return arr;
|
|
}
|
|
cur = (cur as Obj)?.[p];
|
|
}
|
|
return cur;
|
|
}
|
|
export function matchFilter(e: Obj, f: Obj | undefined): boolean {
|
|
if (!f) return true;
|
|
if (f.operator) {
|
|
const conds = (f.conditions as Obj[]).map((c) => matchFilter(e, c));
|
|
return f.operator === "AND" ? conds.every(Boolean) : f.operator === "OR" ? conds.some(Boolean) : !conds.some(Boolean);
|
|
}
|
|
const kw = e.keywords as Obj;
|
|
if (f.inMailbox && !(e.mailboxIds as Obj)[f.inMailbox as string]) return false;
|
|
if (f.hasKeyword && !kw[f.hasKeyword as string]) return false;
|
|
if (f.notKeyword && kw[f.notKeyword as string]) return false;
|
|
if (f.hasAttachment !== undefined && Boolean(e.hasAttachment) !== f.hasAttachment) return false;
|
|
const hay = `${e.subject} ${JSON.stringify(e.from)} ${JSON.stringify(e.to)} ${e.preview}`.toLowerCase();
|
|
for (const k of ["text", "subject", "from", "to", "body"]) if (f[k] && !hay.includes(String(f[k]).toLowerCase())) return false;
|
|
if (f.before && String(e.receivedAt) >= String(f.before)) return false;
|
|
if (f.after && String(e.receivedAt) < String(f.after)) return false;
|
|
if (f.minSize && Number(e.size) < Number(f.minSize)) return false;
|
|
if (f.maxSize && Number(e.size) > Number(f.maxSize)) return false;
|
|
return true;
|
|
}
|
|
export function applyPatch(obj: Obj, patch: Obj) {
|
|
for (const [k, v] of Object.entries(patch)) {
|
|
if (k.includes("/")) {
|
|
const [root, ...rest] = k.split("/");
|
|
const key = rest.join("/");
|
|
const target = (obj[root!] as Obj) ?? {};
|
|
if (v === null) delete target[key];
|
|
else target[key] = v;
|
|
obj[root!] = target;
|
|
} else obj[k] = v;
|
|
}
|
|
}
|
|
|
|
/* ---------- method handlers ---------- */
|
|
export type Handler = (args: Obj) => Obj | [string, Obj][];
|
|
/** A method-level failure, surfaced as ["error", {type, description}, id]. */
|
|
export class MethodError extends Error {
|
|
constructor(
|
|
public readonly type: string,
|
|
description?: string,
|
|
) {
|
|
super(description ?? type);
|
|
}
|
|
}
|
|
|
|
export const MAX_OBJECTS = 500;
|
|
|
|
/**
|
|
* Stalwart refuses a whole method call that carries more objects than it will
|
|
* process at once - it does not quietly handle the first 500. Enforce the same
|
|
* ceiling the session advertises, so an unbatched client fails here too.
|
|
*/
|
|
export function enforceLimits(name: string, args: Obj): void {
|
|
const tooLarge = () => {
|
|
throw new MethodError("requestTooLarge", "The number of ids requested by the client exceeds the maximum number the server is willing to process in a single method call.");
|
|
};
|
|
if (name.endsWith("/get")) {
|
|
const ids = args.ids as unknown[] | null | undefined;
|
|
if (Array.isArray(ids) && ids.length > MAX_OBJECTS) tooLarge();
|
|
}
|
|
if (name.endsWith("/set")) {
|
|
const n =
|
|
Object.keys((args.create as Obj) ?? {}).length +
|
|
Object.keys((args.update as Obj) ?? {}).length +
|
|
((args.destroy as unknown[] | undefined)?.length ?? 0);
|
|
if (n > MAX_OBJECTS) tooLarge();
|
|
}
|
|
}
|
|
|
|
export const setResp = (extra: Obj = {}): Obj => ({ accountId: ACCOUNT, oldState: "1", newState: nextState(), created: {}, updated: {}, destroyed: [], ...extra });
|
|
|
|
/*
|
|
* `Mailbox/get` does not return `shareWith` unless a client asks for it by
|
|
* name: a `/get` with no `properties` comes back without the field at all.
|
|
* Confirmed on 0.16.19 (2026-08-27) against a mailbox that really was shared.
|
|
* The mock handing it over unasked meant a client that never asked still saw
|
|
* every share, and the one place that did not -- the real server -- showed
|
|
* nothing shared at all.
|
|
*
|
|
* Calendars and address books used to behave the same way and no longer do.
|
|
* 0.16.21 fixed `Calendar/get` and `AddressBook/get` to return every property
|
|
* when `properties` is omitted or null, `shareWith` included. **Confirmed live
|
|
* on 0.16.21 (2026-09-06):** both come back with the full set, while
|
|
* `Mailbox/get` on the same server still omits it — so this stays, and it
|
|
* stays applied to mailboxes alone.
|
|
*/
|
|
export function hideShareWithUnlessAsked(a: Obj, res: { list: Obj[] }): { list: Obj[] } {
|
|
if (a.properties) return res;
|
|
return { ...res, list: res.list.map(({ shareWith: _drop, ...rest }) => rest) };
|
|
}
|
|
|
|
export function genericGet(list: Obj[]) {
|
|
return (a: Obj) => {
|
|
const ids = a.ids as string[] | null | undefined;
|
|
const found = ids ? ids.map((id) => list.find((x) => x.id === id)).filter(Boolean) as Obj[] : list;
|
|
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound: ids ? ids.filter((id) => !list.some((x) => x.id === id)) : [] };
|
|
};
|
|
}
|
|
/**
|
|
* An id, as either a stored event or one occurrence of one.
|
|
*
|
|
* A synthetic id whose base is gone, or whose date the rule no longer
|
|
* generates (excluded, or past a `count`), resolves to nothing — `notFound`,
|
|
* the way the server answers for an occurrence that is not there any more.
|
|
*/
|
|
export function resolveEvent(list: Obj[], id: string): { base: Obj; occ?: Occurrence } | null {
|
|
const direct = list.find((x) => x.id === id);
|
|
if (direct) return { base: direct };
|
|
const parsed = parseSyntheticId(id);
|
|
if (!parsed) return null;
|
|
const base = list.find((x) => x.id === parsed.baseId);
|
|
if (!base) return null;
|
|
const occ = occurrenceAt(base, parsed.recurrenceId);
|
|
return occ ? { base, occ } : null;
|
|
}
|
|
|
|
/** Thrown from an onCreate hook to refuse a create the way a real server would. */
|
|
export class SetError extends Error {
|
|
constructor(readonly type: string, readonly description: string, readonly properties?: string[]) { super(description); }
|
|
toJSON(): Obj { return { type: this.type, description: this.description, ...(this.properties ? { properties: this.properties } : {}) }; }
|
|
}
|
|
|
|
export function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
|
|
return (a: Obj) => {
|
|
const created: Obj = {};
|
|
const updated: Obj = {};
|
|
const destroyed: string[] = [];
|
|
const notCreated: Obj = {};
|
|
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
|
|
const id = `${prefix}${randomUUID().slice(0, 6)}`;
|
|
const o = { ...(obj as Obj), id };
|
|
try {
|
|
onCreate?.(o);
|
|
} catch (err) {
|
|
if (!(err instanceof SetError)) throw err;
|
|
notCreated[cid] = err.toJSON();
|
|
continue;
|
|
}
|
|
list.push(o);
|
|
created[cid] = { id };
|
|
}
|
|
for (const [id, patch] of Object.entries((a.update as Obj) ?? {})) {
|
|
const o = list.find((x) => x.id === id);
|
|
if (o) { applyPatch(o, patch as Obj); updated[id] = null; }
|
|
}
|
|
for (const id of (a.destroy as string[]) ?? []) {
|
|
const i = list.findIndex((x) => x.id === id);
|
|
if (i >= 0) { list.splice(i, 1); destroyed.push(id); }
|
|
}
|
|
return setResp({ created, updated, destroyed, ...(Object.keys(notCreated).length ? { notCreated } : {}) });
|
|
};
|
|
}
|
|
|
|
/* ---------- calendar events ---------- */
|
|
|
|
/**
|
|
* `CalendarEvent/set`, including the synthetic-id handling 0.16.20 added.
|
|
*
|
|
* An update or destroy aimed at an occurrence does not touch the series: it
|
|
* writes a `recurrenceOverrides` entry keyed by that date, exactly as Stalwart
|
|
* does — `{ excluded: true }` for a destroy, the patch merged in for an update.
|
|
*
|
|
* The refusals are the point of reproducing this at all:
|
|
*
|
|
* - a base event and one of its instances in the same request is refused, both
|
|
* ids at once, because the server cannot apply them in a defined order;
|
|
* - the same id twice is "Duplicate event id.";
|
|
* - the ten event-level properties are refused with `invalidProperties`;
|
|
* - and the twelve inherited ones are dropped in silence, with the response
|
|
* still saying the update succeeded. A mock that applied them would let a
|
|
* client that sends them look correct everywhere except a real server.
|
|
*/
|
|
/**
|
|
* Enough of an iCalendar reader to stand in for Stalwart's.
|
|
*
|
|
* It reads per VEVENT rather than across the whole file, because a file is the
|
|
* case an emailed invitation never was: an export carries a year of them, and a
|
|
* regex over the whole text would find the first DTSTART and call that the
|
|
* answer. One event still comes back as a bare object, the shape this returned
|
|
* when an invitation was all it had to handle.
|
|
*
|
|
* The synthetic organizer and attendee only go on events that arrived with a
|
|
* METHOD. Those are scheduling messages, which is what the invitation fixtures
|
|
* are; a plain export is not addressed to anyone, and inventing participants
|
|
* for it would make imported events look like invitations nobody sent.
|
|
*/
|
|
export function calendarEventParse(a: Obj) {
|
|
const parsed: Obj = {};
|
|
const notParsable: string[] = [];
|
|
for (const b of a.blobIds as string[]) {
|
|
const blob = blobs.get(b);
|
|
if (!blob) { notParsable.push(b); continue; }
|
|
const text = blob.data.toString();
|
|
const field = (src: string, k: string) => new RegExp(`^${k}[^:\r\n]*:(.*)$`, "m").exec(src)?.[1]?.trim();
|
|
const method = field(text, "METHOD");
|
|
const bodies = text.match(/BEGIN:VEVENT[\s\S]*?END:VEVENT/g) ?? [];
|
|
const events = bodies.map((body) => {
|
|
const g = (k: string) => field(body, k);
|
|
const ds = g("DTSTART") ?? "20260101T000000Z";
|
|
const de = g("DTEND") ?? ds;
|
|
const toLocal = (s: string) => `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(9, 11)}:${s.slice(11, 13)}:00`;
|
|
const start = new Date(`${toLocal(ds)}Z`);
|
|
const end = new Date(`${toLocal(de)}Z`);
|
|
return {
|
|
"@type": "Event",
|
|
uid: g("UID"),
|
|
title: g("SUMMARY"),
|
|
start: toLocal(ds),
|
|
timeZone: "Etc/UTC",
|
|
duration: `PT${Math.round((end.getTime() - start.getTime()) / 60000)}M`,
|
|
method,
|
|
locations: g("LOCATION") ? { l: { name: g("LOCATION") } } : undefined,
|
|
participants: method
|
|
? {
|
|
org: { name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { owner: true } },
|
|
me: { name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { attendee: true, required: true }, participationStatus: "needs-action" },
|
|
}
|
|
: undefined,
|
|
};
|
|
});
|
|
if (!events.length) { notParsable.push(b); continue; }
|
|
parsed[b] = events.length === 1 ? events[0] : events;
|
|
}
|
|
return { accountId: ACCOUNT, parsed, notParsable };
|
|
}
|
|
|
|
export function calendarEventSet(a: Obj) {
|
|
const created: Obj = {};
|
|
const updated: Obj = {};
|
|
const destroyed: string[] = [];
|
|
const notCreated: Obj = {};
|
|
const notUpdated: Obj = {};
|
|
const notDestroyed: Obj = {};
|
|
|
|
/*
|
|
* An account that may not send invitations refuses the whole request the
|
|
* moment it asks for them, and refuses it per object rather than as a method
|
|
* error. Confirmed live on 0.16.21 for all three of create, update and
|
|
* destroy; the same requests with the flag absent or false went through.
|
|
* The flag alone decides it — the server does not first check whether the
|
|
* event has anyone to notify.
|
|
*/
|
|
if (NO_SCHEDULING_SEND && a.sendSchedulingMessages === true) {
|
|
const denied = () => new SetError("forbidden", SCHEDULING_FORBIDDEN).toJSON();
|
|
for (const cid of Object.keys((a.create as Obj) ?? {})) notCreated[cid] = denied();
|
|
for (const id of Object.keys((a.update as Obj) ?? {})) notUpdated[id] = denied();
|
|
for (const id of ((a.destroy as string[]) ?? [])) notDestroyed[id] = denied();
|
|
return setResp({
|
|
created, updated, destroyed,
|
|
...(Object.keys(notCreated).length ? { notCreated } : {}),
|
|
...(Object.keys(notUpdated).length ? { notUpdated } : {}),
|
|
...(Object.keys(notDestroyed).length ? { notDestroyed } : {}),
|
|
});
|
|
}
|
|
|
|
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
|
|
const o: Obj = { ...(obj as Obj), id: `ev${randomUUID().slice(0, 6)}` };
|
|
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
|
|
// participants addressed the RFC 8984 way. The mock did neither, which is
|
|
// how #26 and #30 reached a live server unnoticed — so it does both.
|
|
if (o.recurrenceRules) { notCreated[cid] = new SetError("invalidProperties", "Invalid property.", ["recurrenceRules"]).toJSON(); continue; }
|
|
const parts = o.participants as Record<string, Obj> | undefined;
|
|
if (parts && Object.values(parts).some((p) => !p.calendarAddress)) delete o.participants;
|
|
if (o.replyTo && !o.organizerCalendarAddress) delete o.replyTo;
|
|
o.uid = o.uid ?? randomUUID();
|
|
events.push(o);
|
|
created[cid] = { id: o.id };
|
|
}
|
|
|
|
const updates = Object.entries((a.update as Obj) ?? {});
|
|
const destroys = ((a.destroy as string[]) ?? []).slice();
|
|
const seen = new Set<string>();
|
|
|
|
/* A base and one of its instances cannot be settled in the same request. */
|
|
const baseOf = (id: string): string | null => {
|
|
const r = resolveEvent(events, id);
|
|
return r ? (r.base.id as string) : null;
|
|
};
|
|
const touched = new Map<string, { base: string[]; instance: string[] }>();
|
|
for (const id of [...updates.map(([id]) => id), ...destroys]) {
|
|
const b = baseOf(id);
|
|
if (!b) continue;
|
|
const entry = touched.get(b) ?? { base: [], instance: [] };
|
|
(parseSyntheticId(id) ? entry.instance : entry.base).push(id);
|
|
touched.set(b, entry);
|
|
}
|
|
const conflicted = new Set<string>();
|
|
for (const [, e] of touched) {
|
|
if (e.base.length && e.instance.length) for (const id of [...e.base, ...e.instance]) conflicted.add(id);
|
|
}
|
|
const conflict = () => new SetError("invalidProperties", "A base event and its instances cannot be modified in the same request.", ["id"]).toJSON();
|
|
|
|
for (const [id, patch] of updates) {
|
|
if (conflicted.has(id)) { notUpdated[id] = conflict(); continue; }
|
|
if (seen.has(id)) { notUpdated[id] = new SetError("invalidProperties", "Duplicate event id.", ["id"]).toJSON(); continue; }
|
|
seen.add(id);
|
|
const resolved = resolveEvent(events, id);
|
|
if (!resolved) { notUpdated[id] = { type: "notFound" }; continue; }
|
|
if (!resolved.occ) { applyPatch(resolved.base, patch as Obj); updated[id] = null; continue; }
|
|
const { rejected, applied } = splitOccurrencePatch(patch as Obj);
|
|
if (rejected) { notUpdated[id] = new SetError("invalidProperties", "This property cannot be modified on a single occurrence.", [rejected]).toJSON(); continue; }
|
|
writeOverride(resolved.base, resolved.occ, applied);
|
|
updated[id] = null;
|
|
}
|
|
|
|
for (const id of destroys) {
|
|
if (conflicted.has(id)) { notDestroyed[id] = conflict(); continue; }
|
|
const resolved = resolveEvent(events, id);
|
|
if (!resolved) { notDestroyed[id] = { type: "notFound" }; continue; }
|
|
if (resolved.occ) {
|
|
// One date off a series, which is an override rather than a deletion.
|
|
writeOverride(resolved.base, resolved.occ, { excluded: true }, true);
|
|
destroyed.push(id);
|
|
continue;
|
|
}
|
|
const i = events.findIndex((x) => x.id === id);
|
|
if (i >= 0) { events.splice(i, 1); destroyed.push(id); }
|
|
}
|
|
|
|
return setResp({
|
|
created, updated, destroyed,
|
|
...(Object.keys(notCreated).length ? { notCreated } : {}),
|
|
...(Object.keys(notUpdated).length ? { notUpdated } : {}),
|
|
...(Object.keys(notDestroyed).length ? { notDestroyed } : {}),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Merge a patch into the override for one date.
|
|
*
|
|
* Stalwart fills `start` and `duration` in when the patch leaves them out, so
|
|
* an override always carries its own timing; the mock does the same, or a
|
|
* client could depend on inheriting them and be right only here.
|
|
*/
|
|
export function writeOverride(base: Obj, occ: Occurrence, patch: Obj, replace = false) {
|
|
const overrides = (base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {};
|
|
const existing = replace ? {} : (overrides[occ.recurrenceId] ?? {});
|
|
const next: Obj = { ...existing };
|
|
if (!replace) {
|
|
if (!("start" in next)) next.start = occ.start;
|
|
if (!("duration" in next) && base.duration) next.duration = base.duration;
|
|
}
|
|
applyPatch(next, patch);
|
|
overrides[occ.recurrenceId] = next;
|
|
base.recurrenceOverrides = overrides;
|
|
}
|
|
|
|
/* ---------- submissions ---------- */
|
|
/**
|
|
* Held messages, the way Stalwart models them: `sendAt` is derived from the
|
|
* envelope's FUTURERELEASE parameter rather than set by the client, and
|
|
* `undoStatus` reports whether the message is still in the queue.
|
|
*/
|
|
export const submissions: Obj[] = [];
|
|
|
|
export function submissionView(sub: Obj): Obj {
|
|
return { ...sub, undoStatus: undoStatusOf(sub, Date.now()) };
|
|
}
|
|
|
|
export function matchSubmissionFilter(sub: Obj, f: Obj | undefined): boolean {
|
|
if (!f) return true;
|
|
if (f.undoStatus && undoStatusOf(sub, Date.now()) !== f.undoStatus) return false;
|
|
if (Array.isArray(f.emailIds) && !(f.emailIds as string[]).includes(sub.emailId as string)) return false;
|
|
if (Array.isArray(f.identityIds) && !(f.identityIds as string[]).includes(sub.identityId as string)) return false;
|
|
return true;
|
|
}
|
|
|
|
/** Who the demo user is, for administration. See mock/directory.ts. */
|
|
export const directory = createDirectory({
|
|
accountId: ACCOUNT,
|
|
user: USER,
|
|
locale: MOCK_LOCALE,
|
|
role: mockRole(process.env.MOCK_ROLE),
|
|
metricsOff: process.env.MOCK_METRICS === "off",
|
|
fail: (type, description) => new MethodError(type, description),
|
|
});
|
|
|