@getstrata/core 0.5.5 → 0.5.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 CHANGED
@@ -16,6 +16,7 @@ process.env.DATABASE_URL ??= "postgresql://postgres:postgres@localhost:5432/myap
16
16
 
17
17
  import {
18
18
  BaseRepository,
19
+ EtaViewEngine,
19
20
  FormRequest,
20
21
  Policy,
21
22
  mailer,
@@ -24,6 +25,8 @@ import {
24
25
  } from "@getstrata/core";
25
26
  ```
26
27
 
28
+ **Dependency:** `eta` is bundled as a direct dependency of `@getstrata/core` — apps do not need to list it separately. The database driver is your app's choice — WorkHub and getstrata use **Bun's built-in `Bun.sql`** client; bind it with `bindDatabaseConnection()`.
29
+
27
30
  `orderBy` accepts explicit `{ column, direction }` objects or Laravel-style shorthand `{ published_at: "desc" }`.
28
31
 
29
32
  ## Build & verify (monorepo root)
@@ -0,0 +1,2 @@
1
+ declare function nonCryptographicDigest(input: string): string;
2
+ export { nonCryptographicDigest };
@@ -0,0 +1,4 @@
1
+ import type { BunRequest } from "bun";
2
+ declare function readRequestCookie(request: Request, name: string): string | null;
3
+ declare function readBunRequestCookie(request: BunRequest, name: string): string | null;
4
+ export { readBunRequestCookie, readRequestCookie };
@@ -0,0 +1,12 @@
1
+ declare const DEFAULT_CSRF_TTL_MS: number;
2
+ interface CsrfProtectionOptions {
3
+ expiresIn?: number;
4
+ maxAge?: number;
5
+ }
6
+ declare function createCsrfProtection(secret: string, options?: CsrfProtectionOptions): {
7
+ generate(_sessionKey?: string): string;
8
+ verify(token: string | undefined, _sessionKey?: string): boolean;
9
+ secret: string;
10
+ };
11
+ export type { CsrfProtectionOptions };
12
+ export { createCsrfProtection, DEFAULT_CSRF_TTL_MS };
@@ -1555,26 +1555,26 @@ class Model {
1555
1555
  throw new Error(`${this.constructor.name}.primaryKey() is not implemented.`);
1556
1556
  }
1557
1557
  static primaryKeyField() {
1558
- return resolveModelRepository(this).getTable().primaryKey;
1558
+ return resolveModelRepository(Model).getTable().primaryKey;
1559
1559
  }
1560
1560
  static hydrateAttributes(attributes) {
1561
- const casts = modelStatics(this).$casts ?? {};
1561
+ const casts = modelStatics(Model).$casts ?? {};
1562
1562
  return applyCasts(attributes, casts, "hydrate");
1563
1563
  }
1564
1564
  static dehydrateAttributes(attributes) {
1565
- const casts = modelStatics(this).$casts ?? {};
1565
+ const casts = modelStatics(Model).$casts ?? {};
1566
1566
  return applyCasts(attributes, casts, "dehydrate");
1567
1567
  }
1568
1568
  static fromRecord(record, repository, exists = true) {
1569
- const statics = modelStatics(this);
1569
+ const statics = modelStatics(Model);
1570
1570
  const hydrated = statics.hydrateAttributes(record);
1571
1571
  return new statics(hydrated, repository, exists);
1572
1572
  }
1573
1573
  static boot() {}
1574
1574
  static addGlobalScope(_name, scope) {
1575
- ensureBooted(this);
1576
- const existing = modelGlobalScopes.get(this) ?? [];
1577
- modelGlobalScopes.set(this, [
1575
+ ensureBooted(Model);
1576
+ const existing = modelGlobalScopes.get(Model) ?? [];
1577
+ modelGlobalScopes.set(Model, [
1578
1578
  ...existing,
1579
1579
  scope
1580
1580
  ]);
@@ -1584,17 +1584,17 @@ class Model {
1584
1584
  }
1585
1585
  static query() {
1586
1586
  ensureBooted(this);
1587
- const repository = resolveModelRepository(this);
1587
+ const repository = resolveModelRepository(Model);
1588
1588
  let query = repository.query();
1589
- for (const scope of getGlobalScopes(this)) {
1589
+ for (const scope of getGlobalScopes(Model)) {
1590
1590
  query = scope(query);
1591
1591
  }
1592
1592
  return query;
1593
1593
  }
1594
1594
  static async create(attributes) {
1595
1595
  const statics = modelStatics(this);
1596
- ensureBooted(this);
1597
- const repository = resolveModelRepository(this);
1596
+ ensureBooted(Model);
1597
+ const repository = resolveModelRepository(Model);
1598
1598
  const table = repository.getTable();
1599
1599
  const timestamps = statics.$timestamps ?? true;
1600
1600
  const assignable = filterMassAssignable(statics.$fillable, statics.$guarded, attributes);
@@ -1604,23 +1604,23 @@ class Model {
1604
1604
  return statics.fromRecord(record, repository, true);
1605
1605
  }
1606
1606
  static async find(id) {
1607
- const statics = modelStatics(this);
1608
- const repository = resolveModelRepository(this);
1607
+ const statics = modelStatics(Model);
1608
+ const repository = resolveModelRepository(Model);
1609
1609
  const primaryKey = repository.getTable().primaryKey;
1610
- const record = await Model.query.call(this).where({ [primaryKey]: id }).first();
1610
+ const record = await Model.query.call(Model).where({ [primaryKey]: id }).first();
1611
1611
  return record ? statics.fromRecord(record, repository, true) : null;
1612
1612
  }
1613
1613
  static async findOrFail(id, errorFactory) {
1614
- const model = await Model.find.call(this, id);
1614
+ const model = await Model.find.call(Model, id);
1615
1615
  if (model) {
1616
1616
  return model;
1617
1617
  }
1618
- throw errorFactory?.(id) ?? new NotFoundError(`${this.name} ${String(id)} not found.`);
1618
+ throw errorFactory?.(id) ?? new NotFoundError(`${Model.name} ${String(id)} not found.`);
1619
1619
  }
1620
1620
  static async all(options = {}) {
1621
1621
  const statics = modelStatics(this);
1622
- const repository = resolveModelRepository(this);
1623
- let query = Model.query.call(this);
1622
+ const repository = resolveModelRepository(Model);
1623
+ let query = Model.query.call(Model);
1624
1624
  if (options.orderBy) {
1625
1625
  query = query.orderBy(options.orderBy);
1626
1626
  }
@@ -1632,8 +1632,8 @@ class Model {
1632
1632
  }
1633
1633
  static async firstWhere(where, options = {}) {
1634
1634
  const statics = modelStatics(this);
1635
- const repository = resolveModelRepository(this);
1636
- let query = Model.query.call(this).where(where);
1635
+ const repository = resolveModelRepository(Model);
1636
+ let query = Model.query.call(Model).where(where);
1637
1637
  if (options.orderBy) {
1638
1638
  query = query.orderBy(options.orderBy);
1639
1639
  }
@@ -1353,26 +1353,26 @@ class Model {
1353
1353
  throw new Error(`${this.constructor.name}.primaryKey() is not implemented.`);
1354
1354
  }
1355
1355
  static primaryKeyField() {
1356
- return resolveModelRepository(this).getTable().primaryKey;
1356
+ return resolveModelRepository(Model).getTable().primaryKey;
1357
1357
  }
1358
1358
  static hydrateAttributes(attributes) {
1359
- const casts = modelStatics(this).$casts ?? {};
1359
+ const casts = modelStatics(Model).$casts ?? {};
1360
1360
  return applyCasts(attributes, casts, "hydrate");
1361
1361
  }
1362
1362
  static dehydrateAttributes(attributes) {
1363
- const casts = modelStatics(this).$casts ?? {};
1363
+ const casts = modelStatics(Model).$casts ?? {};
1364
1364
  return applyCasts(attributes, casts, "dehydrate");
1365
1365
  }
1366
1366
  static fromRecord(record, repository, exists = true) {
1367
- const statics = modelStatics(this);
1367
+ const statics = modelStatics(Model);
1368
1368
  const hydrated = statics.hydrateAttributes(record);
1369
1369
  return new statics(hydrated, repository, exists);
1370
1370
  }
1371
1371
  static boot() {}
1372
1372
  static addGlobalScope(_name, scope) {
1373
- ensureBooted(this);
1374
- const existing = modelGlobalScopes.get(this) ?? [];
1375
- modelGlobalScopes.set(this, [
1373
+ ensureBooted(Model);
1374
+ const existing = modelGlobalScopes.get(Model) ?? [];
1375
+ modelGlobalScopes.set(Model, [
1376
1376
  ...existing,
1377
1377
  scope
1378
1378
  ]);
@@ -1382,17 +1382,17 @@ class Model {
1382
1382
  }
1383
1383
  static query() {
1384
1384
  ensureBooted(this);
1385
- const repository = resolveModelRepository(this);
1385
+ const repository = resolveModelRepository(Model);
1386
1386
  let query = repository.query();
1387
- for (const scope of getGlobalScopes(this)) {
1387
+ for (const scope of getGlobalScopes(Model)) {
1388
1388
  query = scope(query);
1389
1389
  }
1390
1390
  return query;
1391
1391
  }
1392
1392
  static async create(attributes) {
1393
1393
  const statics = modelStatics(this);
1394
- ensureBooted(this);
1395
- const repository = resolveModelRepository(this);
1394
+ ensureBooted(Model);
1395
+ const repository = resolveModelRepository(Model);
1396
1396
  const table = repository.getTable();
1397
1397
  const timestamps = statics.$timestamps ?? true;
1398
1398
  const assignable = filterMassAssignable(statics.$fillable, statics.$guarded, attributes);
@@ -1402,23 +1402,23 @@ class Model {
1402
1402
  return statics.fromRecord(record, repository, true);
1403
1403
  }
1404
1404
  static async find(id) {
1405
- const statics = modelStatics(this);
1406
- const repository = resolveModelRepository(this);
1405
+ const statics = modelStatics(Model);
1406
+ const repository = resolveModelRepository(Model);
1407
1407
  const primaryKey = repository.getTable().primaryKey;
1408
- const record = await Model.query.call(this).where({ [primaryKey]: id }).first();
1408
+ const record = await Model.query.call(Model).where({ [primaryKey]: id }).first();
1409
1409
  return record ? statics.fromRecord(record, repository, true) : null;
1410
1410
  }
1411
1411
  static async findOrFail(id, errorFactory) {
1412
- const model = await Model.find.call(this, id);
1412
+ const model = await Model.find.call(Model, id);
1413
1413
  if (model) {
1414
1414
  return model;
1415
1415
  }
1416
- throw errorFactory?.(id) ?? new NotFoundError(`${this.name} ${String(id)} not found.`);
1416
+ throw errorFactory?.(id) ?? new NotFoundError(`${Model.name} ${String(id)} not found.`);
1417
1417
  }
1418
1418
  static async all(options = {}) {
1419
1419
  const statics = modelStatics(this);
1420
- const repository = resolveModelRepository(this);
1421
- let query = Model.query.call(this);
1420
+ const repository = resolveModelRepository(Model);
1421
+ let query = Model.query.call(Model);
1422
1422
  if (options.orderBy) {
1423
1423
  query = query.orderBy(options.orderBy);
1424
1424
  }
@@ -1430,8 +1430,8 @@ class Model {
1430
1430
  }
1431
1431
  static async firstWhere(where, options = {}) {
1432
1432
  const statics = modelStatics(this);
1433
- const repository = resolveModelRepository(this);
1434
- let query = Model.query.call(this).where(where);
1433
+ const repository = resolveModelRepository(Model);
1434
+ let query = Model.query.call(Model).where(where);
1435
1435
  if (options.orderBy) {
1436
1436
  query = query.orderBy(options.orderBy);
1437
1437
  }
@@ -1,6 +1,34 @@
1
1
  // @bun
2
2
  // ../../src/core/http/csrfToken.ts
3
- import { createHmac, randomBytes, timingSafeEqual } from "crypto";
3
+ import { timingSafeEqual } from "crypto";
4
+
5
+ // ../../src/core/http/cookies.ts
6
+ function readRequestCookie(request, name) {
7
+ const cookies = request.cookies;
8
+ if (cookies && typeof cookies.get === "function") {
9
+ const value = cookies.get(name);
10
+ if (value) {
11
+ return value;
12
+ }
13
+ }
14
+ const header = request.headers.get("cookie");
15
+ if (!header) {
16
+ return null;
17
+ }
18
+ for (const part of header.split(";")) {
19
+ const idx = part.indexOf("=");
20
+ if (idx === -1)
21
+ continue;
22
+ const cookieName = part.slice(0, idx).trim();
23
+ if (cookieName !== name)
24
+ continue;
25
+ return decodeURIComponent(part.slice(idx + 1).trim());
26
+ }
27
+ return null;
28
+ }
29
+ function readBunRequestCookie(request, name) {
30
+ return request.cookies.get(name) ?? readRequestCookie(request, name);
31
+ }
4
32
 
5
33
  // ../../src/core/http/requestMetaContext.ts
6
34
  import { AsyncLocalStorage } from "async_hooks";
@@ -21,70 +49,28 @@ var CSRF_TTL_MS = 60 * 60 * 1000;
21
49
  function resolveCsrfSecret() {
22
50
  return process.env.SESSION_SECRET?.trim() || process.env.OAUTH_STATE_SECRET?.trim() || process.env.ADMIN_API_TOKEN?.trim() || "workhub-dev-csrf-secret";
23
51
  }
24
- function signCsrfToken(token, issuedAt) {
25
- const payload = `${token}.${issuedAt}`;
26
- const signature = createHmac("sha256", resolveCsrfSecret()).update(payload).digest("hex");
27
- return `${payload}.${signature}`;
52
+ function csrfVerifyOptions() {
53
+ return { secret: resolveCsrfSecret(), maxAge: CSRF_TTL_MS };
28
54
  }
29
- function readCsrfCookie(request) {
30
- const cookieHeader = request.headers.get("cookie");
31
- if (!cookieHeader) {
32
- return null;
33
- }
34
- for (const part of cookieHeader.split(";")) {
35
- const [name, ...rest] = part.trim().split("=");
36
- if (name === CSRF_COOKIE) {
37
- return decodeURIComponent(rest.join("="));
38
- }
39
- }
40
- return null;
41
- }
42
- function parseSignedCsrfValue(cookieValue) {
43
- const parts = cookieValue.split(".");
44
- if (parts.length !== 3) {
45
- return null;
46
- }
47
- const [token, issuedAtRaw, cookieSignature] = parts;
48
- if (!token || !issuedAtRaw || !cookieSignature) {
49
- return null;
50
- }
51
- const issuedAt = Number.parseInt(issuedAtRaw, 10);
52
- if (!Number.isFinite(issuedAt)) {
53
- return null;
54
- }
55
- if (Date.now() - issuedAt > CSRF_TTL_MS) {
56
- return null;
57
- }
58
- const expectedSignature = signCsrfToken(token, issuedAt).split(".").pop();
59
- if (!expectedSignature) {
60
- return null;
61
- }
62
- const expectedBuffer = Buffer.from(expectedSignature);
63
- const actualBuffer = Buffer.from(cookieSignature);
64
- if (expectedBuffer.length !== actualBuffer.length) {
65
- return null;
66
- }
67
- if (!timingSafeEqual(expectedBuffer, actualBuffer)) {
68
- return null;
55
+ function tokensMatch(left, right) {
56
+ const leftBuffer = Buffer.from(left);
57
+ const rightBuffer = Buffer.from(right);
58
+ if (leftBuffer.length !== rightBuffer.length) {
59
+ return false;
69
60
  }
70
- return { token, issuedAt };
61
+ return timingSafeEqual(leftBuffer, rightBuffer);
71
62
  }
72
63
  function createCsrfTokenCookie() {
73
- const token = randomBytes(24).toString("hex");
74
- const issuedAt = Date.now();
75
- const value = signCsrfToken(token, issuedAt);
64
+ const token = Bun.CSRF.generate(resolveCsrfSecret(), { expiresIn: CSRF_TTL_MS });
76
65
  return {
77
66
  token,
78
- cookie: `${CSRF_COOKIE}=${encodeURIComponent(value)}; Path=/; SameSite=Lax; Max-Age=3600`
67
+ cookie: `${CSRF_COOKIE}=${encodeURIComponent(token)}; Path=/; SameSite=Lax; Max-Age=${Math.floor(CSRF_TTL_MS / 1000)}`
79
68
  };
80
69
  }
81
70
  function resolveCsrfToken(request) {
82
- const cookieValue = readCsrfCookie(request);
83
- if (cookieValue) {
84
- const parsed = parseSignedCsrfValue(cookieValue);
85
- if (parsed) {
86
- return { token: parsed.token };
87
- }
71
+ const cookieValue = readRequestCookie(request, CSRF_COOKIE);
72
+ if (cookieValue && Bun.CSRF.verify(cookieValue, csrfVerifyOptions())) {
73
+ return { token: cookieValue };
88
74
  }
89
75
  return createCsrfTokenCookie();
90
76
  }
@@ -107,6 +93,10 @@ async function readSubmittedCsrfTokenFromBody(request) {
107
93
  if (typeof field === "string" && field.trim().length > 0) {
108
94
  return field.trim();
109
95
  }
96
+ const legacyField = formData.get("_csrf");
97
+ if (typeof legacyField === "string" && legacyField.trim().length > 0) {
98
+ return legacyField.trim();
99
+ }
110
100
  }
111
101
  return null;
112
102
  }
@@ -114,20 +104,14 @@ function verifyCsrfToken(request, submittedToken) {
114
104
  if (!submittedToken) {
115
105
  return false;
116
106
  }
117
- const cookieValue = readCsrfCookie(request);
107
+ const cookieValue = readRequestCookie(request, CSRF_COOKIE);
118
108
  if (!cookieValue) {
119
109
  return false;
120
110
  }
121
- const parsed = parseSignedCsrfValue(cookieValue);
122
- if (!parsed) {
123
- return false;
124
- }
125
- const submittedBuffer = Buffer.from(submittedToken);
126
- const expectedBuffer = Buffer.from(parsed.token);
127
- if (submittedBuffer.length !== expectedBuffer.length) {
111
+ if (!tokensMatch(submittedToken, cookieValue)) {
128
112
  return false;
129
113
  }
130
- return timingSafeEqual(submittedBuffer, expectedBuffer);
114
+ return Bun.CSRF.verify(submittedToken, csrfVerifyOptions());
131
115
  }
132
116
  function resolveCsrfTokenForRequest(request) {
133
117
  const metaToken = currentRequestMeta().csrfToken;
@@ -1,6 +1,8 @@
1
1
  // @bun
2
- // ../../src/core/http/etag.ts
3
- import { createHash } from "crypto";
2
+ // ../../src/core/crypto/nonCryptographicHash.ts
3
+ function nonCryptographicDigest(input) {
4
+ return Bun.hash(input).toString(16);
5
+ }
4
6
 
5
7
  // ../../src/core/errors/http.ts
6
8
  class HttpError extends Error {
@@ -76,13 +78,13 @@ function formatWeakEtag(digest) {
76
78
  return `W/"${digest}"`;
77
79
  }
78
80
  function computeEtagFromJson(data) {
79
- const digest = createHash("sha256").update(JSON.stringify(data)).digest("hex").slice(0, 32);
81
+ const digest = nonCryptographicDigest(JSON.stringify(data)).slice(0, 32);
80
82
  return formatWeakEtag(digest);
81
83
  }
82
84
  function etagFromResource(resource) {
83
85
  const version = resource.updated_at ?? resource.created_at ?? "";
84
86
  const versionText = version instanceof Date ? version.toISOString() : version ? String(version) : "0";
85
- const digest = createHash("sha256").update(`${String(resource.id ?? "0")}:${versionText}`).digest("hex").slice(0, 32);
87
+ const digest = nonCryptographicDigest(`${String(resource.id ?? "0")}:${versionText}`).slice(0, 32);
86
88
  return formatWeakEtag(digest);
87
89
  }
88
90
  function normalizeEtag(value) {