@pithy-sh/audit 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.
- package/LICENSE +110 -0
- package/README.md +17 -0
- package/package.json +50 -0
- package/pithy.manifest.json +19 -0
- package/src/actions.ts +59 -0
- package/src/capability.ts +135 -0
- package/src/cli/emitFromCLI.ts +66 -0
- package/src/cli/resolveActor.ts +126 -0
- package/src/cloudflare-test.d.ts +13 -0
- package/src/data/auditEvent.ts +116 -0
- package/src/data/tables.ts +25 -0
- package/src/error/errors.ts +66 -0
- package/src/http/guards.ts +94 -0
- package/src/http/responses.ts +101 -0
- package/src/http/routes.ts +206 -0
- package/src/http/schemas.ts +111 -0
- package/src/http/views.ts +84 -0
- package/src/index.ts +31 -0
- package/src/migrations/0001_init.ts +117 -0
- package/src/query.ts +197 -0
- package/src/recorder.ts +155 -0
- package/src/seeds/example.ts +189 -0
- package/src/version.generated.ts +16 -0
package/src/recorder.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: FSL-1.1-MIT
|
|
3
|
+
|
|
4
|
+
import { AuditEvent, type AuditEventInput } from "@pithy-sh/core/src/audit/auditEvent";
|
|
5
|
+
import type { AuditEmit } from "@pithy-sh/core/src/audit/recorder";
|
|
6
|
+
import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
|
|
7
|
+
import { type D1RetryOptions, withD1Retry } from "@pithy-sh/core/src/data/withD1Retry";
|
|
8
|
+
import { messageOf, PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { AuditMetadataColumn } from "./data/auditEvent";
|
|
11
|
+
import type { AuditDatabase } from "./data/tables";
|
|
12
|
+
import { AuditInvalidEventError, AuditWriteFailedError } from "./error/errors";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The D1-backed audit recorder. `recordAuditEvent` performs a **synchronous, non-fatal** insert: it
|
|
16
|
+
* awaits a direct `INSERT` into `pithy_audit_events` inside the handler, before the response — so the
|
|
17
|
+
* event is durably persisted by the time the caller gets a reply — and it **never throws**. A write
|
|
18
|
+
* failure (or an invalid event) is converted to a namespaced `PithyError` and handed to `onError`
|
|
19
|
+
* (logged by default), so an audit write can never break the action it records (CLAUDE.md §Security:
|
|
20
|
+
* audit is a non-blocking side effect).
|
|
21
|
+
*
|
|
22
|
+
* The insert is wrapped in {@link withD1Retry} to ride out transient `timeout`/`database-busy` faults
|
|
23
|
+
* before falling through to the non-fatal drop; its always-on idempotency guard means a retry after a
|
|
24
|
+
* transport hiccup never double-writes.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Where the recording happened — the origin columns, supplied to the **recorder**, never by an
|
|
29
|
+
* emitter.
|
|
30
|
+
*
|
|
31
|
+
* This is deliberately not a field on `AuditEvent`. Origin is a property of the *writer*, not of the
|
|
32
|
+
* action, and putting it on the event seam would make it caller-settable: any of the ~40 `c.var.emit`
|
|
33
|
+
* sites could then claim to be another Worker, another environment, or another project, and the column
|
|
34
|
+
* would be worth nothing precisely when it mattered. Binding it here means an emitter cannot set it,
|
|
35
|
+
* cannot omit it, and cannot get it wrong.
|
|
36
|
+
*
|
|
37
|
+
* Every field is nullable because every field is genuinely unknowable in some real case: a Worker
|
|
38
|
+
* scaffolded before the vars existed carries none of them, and `worker` is always null for a
|
|
39
|
+
* CLI-originated action, which came from no Worker at all.
|
|
40
|
+
*
|
|
41
|
+
* **`tenant` is deliberately not here.** It is the mirror image of these: whose action it was is a
|
|
42
|
+
* property of the *action*, not of the writer, and no `env` var can know it — so it lives on
|
|
43
|
+
* `AuditEvent`, comes from the emitter, and this recorder can neither default it nor check it. That is
|
|
44
|
+
* the honest trade. Putting it here would mean stamping every row in a multi-tenant Worker with one
|
|
45
|
+
* constant, which is precisely the gap the column exists to close.
|
|
46
|
+
*/
|
|
47
|
+
export interface AuditOrigin {
|
|
48
|
+
/** The owning project, from the Worker's `PROJECT` var or the CLI's resolved project name. */
|
|
49
|
+
project: string | null;
|
|
50
|
+
/** The environment served, from `ENVIRONMENT` or the command's `--env`. */
|
|
51
|
+
environment: string | null;
|
|
52
|
+
/** The recording Worker's `apps/<name>` directory name; null for a CLI action. */
|
|
53
|
+
worker: string | null;
|
|
54
|
+
/**
|
|
55
|
+
* The Cloudflare version id of the build recording the event, from `CF_VERSION_METADATA`; null for a
|
|
56
|
+
* CLI action and for a Worker that does not declare the binding.
|
|
57
|
+
*
|
|
58
|
+
* Required-but-nullable rather than optional, deliberately: making it optional would let a
|
|
59
|
+
* construction site forget it and silently record null, and there are three of them — this recorder,
|
|
60
|
+
* `emitFromCLI`, and the CLI's own `cliOrigin()`. A required field makes the compiler find all three.
|
|
61
|
+
*/
|
|
62
|
+
version: string | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** No origin established — what an unstamped Worker and an un-migrated caller both record. */
|
|
66
|
+
const NO_ORIGIN: AuditOrigin = { project: null, environment: null, worker: null, version: null };
|
|
67
|
+
|
|
68
|
+
/** Options for the recorder. All optional; the defaults route failures to a sink and retry transient D1 faults. */
|
|
69
|
+
export interface AuditRecorderOptions {
|
|
70
|
+
/**
|
|
71
|
+
* Where this recorder is writing from, stamped onto every event it records. Omitted, the three
|
|
72
|
+
* columns are `null` — the same shape as a row written before they existed, which is the truthful
|
|
73
|
+
* answer rather than a guess.
|
|
74
|
+
*/
|
|
75
|
+
origin?: AuditOrigin;
|
|
76
|
+
/**
|
|
77
|
+
* Called with the namespaced failure when an event can't be validated or persisted. The audit write
|
|
78
|
+
* is non-fatal, so this is the only signal a drop happened. The audit capability wires it to the core
|
|
79
|
+
* logger seam (`c.var.log.error(...)`); when omitted the failure is dropped silently — pass a sink.
|
|
80
|
+
*/
|
|
81
|
+
onError?: (error: PithyError) => void;
|
|
82
|
+
/** Retry tuning for the insert. Defaults to retrying `timeout` and `database-busy`. */
|
|
83
|
+
retry?: D1RetryOptions;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Map any caught failure to its namespaced audit `PithyError`. A bad event is `invalid_event`; anything else is `write_failed`. */
|
|
87
|
+
function toAuditError(error: unknown): PithyError {
|
|
88
|
+
if (error instanceof PithyError) return error;
|
|
89
|
+
if (error instanceof z.ZodError) {
|
|
90
|
+
return new AuditInvalidEventError({ detail: error.message }, { cause: error });
|
|
91
|
+
}
|
|
92
|
+
return new AuditWriteFailedError({ detail: messageOf(error) }, { cause: error });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Record one audit event. Validates the event against the core {@link AuditEvent} seam, stamps
|
|
97
|
+
* `occurredAt` when the emitter did not, encodes every column through its codec, and inserts the row
|
|
98
|
+
* — all wrapped so no failure escapes. Returns when the event is persisted (or its failure logged).
|
|
99
|
+
*/
|
|
100
|
+
export async function recordAuditEvent(
|
|
101
|
+
db: AuditDatabase,
|
|
102
|
+
input: AuditEventInput,
|
|
103
|
+
options?: AuditRecorderOptions,
|
|
104
|
+
): Promise<void> {
|
|
105
|
+
const onError = options?.onError ?? (() => {});
|
|
106
|
+
const origin = options?.origin ?? NO_ORIGIN;
|
|
107
|
+
try {
|
|
108
|
+
const event = AuditEvent.parse(input);
|
|
109
|
+
const occurredAt = event.occurredAt ?? new Date();
|
|
110
|
+
// Generated once, before the retry wrapper, so every retry of this one record reuses it. A
|
|
111
|
+
// post-commit transport failure then retries onto the unique eventId index — a UNIQUE violation
|
|
112
|
+
// the guard reads as "already landed", not a duplicate row.
|
|
113
|
+
const eventId = crypto.randomUUID();
|
|
114
|
+
await withD1Retry(
|
|
115
|
+
() =>
|
|
116
|
+
db
|
|
117
|
+
.insertInto("pithyAuditEvents")
|
|
118
|
+
.values({
|
|
119
|
+
eventId,
|
|
120
|
+
occurredAt: SQLiteDate.encode(occurredAt),
|
|
121
|
+
action: event.action,
|
|
122
|
+
outcome: event.outcome,
|
|
123
|
+
severity: event.severity,
|
|
124
|
+
actorType: event.actorType,
|
|
125
|
+
actorId: event.actorId ?? null,
|
|
126
|
+
sessionId: event.sessionId ?? null,
|
|
127
|
+
resourceType: event.resourceType ?? null,
|
|
128
|
+
resourceId: event.resourceId ?? null,
|
|
129
|
+
ip: event.ip ?? null,
|
|
130
|
+
userAgent: event.userAgent ?? null,
|
|
131
|
+
requestId: event.requestId ?? null,
|
|
132
|
+
metadata: event.metadata == null ? null : AuditMetadataColumn.encode(event.metadata),
|
|
133
|
+
// From `event`, and it is the only dimension column that is. The recorder cannot forge this
|
|
134
|
+
// one and cannot default it: `origin` is read from the Worker's own vars, and no var knows
|
|
135
|
+
// which customer an action was taken for. So it is exactly as trustworthy as the emit site,
|
|
136
|
+
// and omitted it is null — "not tenant-scoped" — rather than a guess.
|
|
137
|
+
tenant: event.tenant ?? null,
|
|
138
|
+
// Last, and from `options` rather than `event` — the event cannot reach these columns.
|
|
139
|
+
project: origin.project,
|
|
140
|
+
environment: origin.environment,
|
|
141
|
+
worker: origin.worker,
|
|
142
|
+
version: origin.version,
|
|
143
|
+
})
|
|
144
|
+
.execute(),
|
|
145
|
+
options?.retry,
|
|
146
|
+
);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
onError(toAuditError(error));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Bind a recorder to one audit database — the `emit` seam `@pithy-sh/audit` installs on the request context. */
|
|
153
|
+
export function createAuditEmit(db: AuditDatabase, options?: AuditRecorderOptions): AuditEmit {
|
|
154
|
+
return (input) => recordAuditEvent(db, input, options);
|
|
155
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: FSL-1.1-MIT
|
|
3
|
+
|
|
4
|
+
import { EXAMPLE_ADA, EXAMPLE_ALAN, EXAMPLE_GRACE } from "@pithy-sh/core/src/seed/exampleIdentities";
|
|
5
|
+
import { d1SeedGroup, defineSeed, type SeedSet } from "@pithy-sh/core/src/seed/seed";
|
|
6
|
+
import { AuditEventRow } from "../data/auditEvent";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Where the audit example set sorts in the project's seed registry. It runs after `auth` (100), whose
|
|
10
|
+
* example seeds the users these events are attributed to, so every `actorId`/`resourceId` here points
|
|
11
|
+
* at a user that already exists.
|
|
12
|
+
*/
|
|
13
|
+
const AUDIT_EXAMPLE_SEED_ORDER = 150;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A short, realistic timeline ending "now", so the seeded events land across a dashboard's recency and
|
|
17
|
+
* time-range views instead of all sharing one timestamp.
|
|
18
|
+
*/
|
|
19
|
+
const minutesAgo = (minutes: number): Date => new Date(Date.now() - minutes * 60_000);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The origin every example row carries — so a dev database looks like a real one, and the origin
|
|
23
|
+
* filters have something to return.
|
|
24
|
+
*
|
|
25
|
+
* `project` and `worker` are fixture constants in this file's own `example-` idiom: fictional, obviously
|
|
26
|
+
* so, and stable. **`environment` is null, and that asymmetry is deliberate.** A seed row is a static
|
|
27
|
+
* literal, but this set runs in both `dev` and `staging` (`environments` below) and nothing rewrites a
|
|
28
|
+
* row per run — so any environment written here would be a lie in half the runs it appears in. The other
|
|
29
|
+
* two are properties of the fiction; the environment is a property of the run, and the fixture does not
|
|
30
|
+
* know it. Null is what "not recorded" looks like everywhere else in this column, which is also the
|
|
31
|
+
* state of every row written before the origin migration.
|
|
32
|
+
*/
|
|
33
|
+
const EXAMPLE_ORIGIN = { project: "example-app", environment: null, worker: "api", version: null } as const;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Two fictional tenants, so a dev database can show what the tenant filter is for.
|
|
37
|
+
*
|
|
38
|
+
* **Ada's events are split across both**, deliberately: one person administering two accounts is the
|
|
39
|
+
* case `actorId` cannot answer, and a fixture where every actor sat in exactly one tenant would make
|
|
40
|
+
* the column look redundant. One row is left with no tenant at all — null is a value, not a gap. It is
|
|
41
|
+
* the shape every row in a single-tenant app has, and it gives the "belongs to no tenant" filter
|
|
42
|
+
* something to return.
|
|
43
|
+
*/
|
|
44
|
+
const EXAMPLE_TENANT_ACME = "example-tenant-acme";
|
|
45
|
+
const EXAMPLE_TENANT_GLOBEX = "example-tenant-globex";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A handful of example audit events — the durable, queryable trail the licensed dashboard reads. They
|
|
49
|
+
* are attributed to the shared cast from `@pithy-sh/core` ({@link EXAMPLE_ADA} et al.), so the audit
|
|
50
|
+
* trail lines up with the same users `auth` seeds and `leaderboard`/`ledger`/`multiplayer` reference:
|
|
51
|
+
* a fresh backend with `seed.includeExamples` on has a dashboard with something to show.
|
|
52
|
+
*
|
|
53
|
+
* The set is deliberately spread across the taxonomy the dashboard filters on — every `outcome`
|
|
54
|
+
* (`success`/`failure`/`denied`), a range of `severity` (`info`→`critical`), and both `user` and
|
|
55
|
+
* `system` actors — and across the domains that matter to a licensed product (`auth`, `entitlement`,
|
|
56
|
+
* `admin`, `ledger`): a successful sign-in, a denied one, an entitlement grant, an admin config change,
|
|
57
|
+
* a critical token-reuse alert, and a balance debit. Composed only when the project turns on
|
|
58
|
+
* `seed.includeExamples`, and only for `dev`/`staging` — an example fixture never targets production.
|
|
59
|
+
*/
|
|
60
|
+
export const auditExampleSeed: SeedSet = defineSeed({
|
|
61
|
+
name: "example",
|
|
62
|
+
order: AUDIT_EXAMPLE_SEED_ORDER,
|
|
63
|
+
environments: ["dev", "staging"],
|
|
64
|
+
example: true,
|
|
65
|
+
d1: [
|
|
66
|
+
d1SeedGroup("app", "pithyAuditEvents", AuditEventRow, [
|
|
67
|
+
{
|
|
68
|
+
id: 1,
|
|
69
|
+
eventId: "example-audit-0001",
|
|
70
|
+
occurredAt: minutesAgo(90),
|
|
71
|
+
action: "auth/login",
|
|
72
|
+
outcome: "success",
|
|
73
|
+
severity: "info",
|
|
74
|
+
actorType: "user",
|
|
75
|
+
actorId: EXAMPLE_ADA.id,
|
|
76
|
+
sessionId: "example-session-ada",
|
|
77
|
+
resourceType: null,
|
|
78
|
+
resourceId: null,
|
|
79
|
+
ip: "203.0.113.10",
|
|
80
|
+
userAgent: "PithyDemo/1.0 (dev seed)",
|
|
81
|
+
requestId: "example-req-0001",
|
|
82
|
+
metadata: { method: "magic_link" },
|
|
83
|
+
tenant: EXAMPLE_TENANT_ACME,
|
|
84
|
+
...EXAMPLE_ORIGIN,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: 2,
|
|
88
|
+
eventId: "example-audit-0002",
|
|
89
|
+
occurredAt: minutesAgo(75),
|
|
90
|
+
action: "auth/login",
|
|
91
|
+
outcome: "denied",
|
|
92
|
+
severity: "warning",
|
|
93
|
+
actorType: "user",
|
|
94
|
+
actorId: EXAMPLE_GRACE.id,
|
|
95
|
+
sessionId: null,
|
|
96
|
+
resourceType: null,
|
|
97
|
+
resourceId: null,
|
|
98
|
+
ip: "203.0.113.22",
|
|
99
|
+
userAgent: "PithyDemo/1.0 (dev seed)",
|
|
100
|
+
requestId: "example-req-0002",
|
|
101
|
+
metadata: { reason: "expired_link" },
|
|
102
|
+
tenant: EXAMPLE_TENANT_GLOBEX,
|
|
103
|
+
...EXAMPLE_ORIGIN,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: 3,
|
|
107
|
+
eventId: "example-audit-0003",
|
|
108
|
+
occurredAt: minutesAgo(60),
|
|
109
|
+
action: "entitlement/granted",
|
|
110
|
+
outcome: "success",
|
|
111
|
+
severity: "info",
|
|
112
|
+
actorType: "system",
|
|
113
|
+
actorId: null,
|
|
114
|
+
sessionId: null,
|
|
115
|
+
// The two halves of a payments subject, spelled in the two columns the trail already had:
|
|
116
|
+
// `resourceType` is the kind (`user` or `organization` — `PaymentsSubjectType`) and `resourceId`
|
|
117
|
+
// is its id. An entitlement is held by a subject, never by a bare id, so a row naming only the
|
|
118
|
+
// id would be as ambiguous here as it would be in the table it describes.
|
|
119
|
+
resourceType: "user",
|
|
120
|
+
resourceId: EXAMPLE_ALAN.id,
|
|
121
|
+
ip: null,
|
|
122
|
+
userAgent: null,
|
|
123
|
+
requestId: "example-req-0003",
|
|
124
|
+
metadata: { plan: "pro", grantedBy: "system" },
|
|
125
|
+
tenant: EXAMPLE_TENANT_ACME,
|
|
126
|
+
...EXAMPLE_ORIGIN,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: 4,
|
|
130
|
+
eventId: "example-audit-0004",
|
|
131
|
+
occurredAt: minutesAgo(40),
|
|
132
|
+
action: "admin/config_changed",
|
|
133
|
+
outcome: "success",
|
|
134
|
+
severity: "warning",
|
|
135
|
+
actorType: "user",
|
|
136
|
+
actorId: EXAMPLE_ADA.id,
|
|
137
|
+
sessionId: "example-session-ada",
|
|
138
|
+
resourceType: "config",
|
|
139
|
+
resourceId: "billing",
|
|
140
|
+
ip: "203.0.113.10",
|
|
141
|
+
userAgent: "PithyDemo/1.0 (dev seed)",
|
|
142
|
+
requestId: "example-req-0004",
|
|
143
|
+
metadata: { field: "seatLimit", from: 5, to: 10 },
|
|
144
|
+
// The same actor as event 1, in the other tenant — what `actorId` alone cannot tell apart.
|
|
145
|
+
tenant: EXAMPLE_TENANT_GLOBEX,
|
|
146
|
+
...EXAMPLE_ORIGIN,
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: 5,
|
|
150
|
+
eventId: "example-audit-0005",
|
|
151
|
+
occurredAt: minutesAgo(20),
|
|
152
|
+
action: "auth/token_reuse_detected",
|
|
153
|
+
outcome: "denied",
|
|
154
|
+
severity: "critical",
|
|
155
|
+
actorType: "user",
|
|
156
|
+
actorId: EXAMPLE_GRACE.id,
|
|
157
|
+
sessionId: null,
|
|
158
|
+
resourceType: "session",
|
|
159
|
+
resourceId: "example-family-grace",
|
|
160
|
+
ip: "198.51.100.7",
|
|
161
|
+
userAgent: "curl/8.4.0",
|
|
162
|
+
requestId: "example-req-0005",
|
|
163
|
+
metadata: { familyId: "example-family-grace", action: "family_revoked" },
|
|
164
|
+
tenant: EXAMPLE_TENANT_GLOBEX,
|
|
165
|
+
...EXAMPLE_ORIGIN,
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
id: 6,
|
|
169
|
+
eventId: "example-audit-0006",
|
|
170
|
+
occurredAt: minutesAgo(5),
|
|
171
|
+
action: "ledger/debit",
|
|
172
|
+
outcome: "success",
|
|
173
|
+
severity: "info",
|
|
174
|
+
actorType: "user",
|
|
175
|
+
actorId: EXAMPLE_ALAN.id,
|
|
176
|
+
sessionId: "example-session-alan",
|
|
177
|
+
resourceType: "ledger",
|
|
178
|
+
resourceId: "coins",
|
|
179
|
+
ip: "203.0.113.31",
|
|
180
|
+
userAgent: "PithyDemo/1.0 (dev seed)",
|
|
181
|
+
requestId: "example-req-0006",
|
|
182
|
+
metadata: { amount: 50, currency: "coins" },
|
|
183
|
+
// No tenant, on purpose: the shape every row in a single-tenant app has.
|
|
184
|
+
tenant: null,
|
|
185
|
+
...EXAMPLE_ORIGIN,
|
|
186
|
+
},
|
|
187
|
+
]),
|
|
188
|
+
],
|
|
189
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: FSL-1.1-MIT
|
|
3
|
+
|
|
4
|
+
// GENERATED by scripts/stampVersions.ts — do not edit by hand. Regenerate with `bun run stamp-versions`.
|
|
5
|
+
//
|
|
6
|
+
// A Worker cannot read its own package.json, so this is how @pithy-sh/audit knows its own version at
|
|
7
|
+
// runtime. The capability attaches it, and `GET /control-plane/manifest` reports it per capability —
|
|
8
|
+
// which is what answers "should this project upgrade" and "is this customer exposed to what we just
|
|
9
|
+
// fixed". Those questions are only answerable per module, because a project composes some capabilities
|
|
10
|
+
// and not others.
|
|
11
|
+
|
|
12
|
+
/** This package's npm name — the join key against a release feed. */
|
|
13
|
+
export const PACKAGE_NAME = "@pithy-sh/audit";
|
|
14
|
+
|
|
15
|
+
/** This package's version, stamped from its own package.json at generation time. */
|
|
16
|
+
export const PACKAGE_VERSION = "0.1.0";
|