@flaghoist/server 0.2.3 → 0.3.1

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/dist/index.cjs CHANGED
@@ -64,7 +64,10 @@ function buildFlag(key, body, identity, existing) {
64
64
  return { ok: false, error: `Description exceeds ${import_core.LIMITS.maxDescriptionLength} characters` };
65
65
  }
66
66
  const inputRules = Array.isArray(b.rules) ? b.rules : [];
67
- const now = (/* @__PURE__ */ new Date()).toISOString();
67
+ const nowMs = Date.now();
68
+ const prevMs = existing ? Date.parse(existing.metadata.updatedAt) : 0;
69
+ const updatedAt = new Date(nowMs > prevMs ? nowMs : prevMs + 1).toISOString();
70
+ const createdAt = existing?.metadata.createdAt ?? new Date(nowMs).toISOString();
68
71
  const candidate = {
69
72
  key,
70
73
  enabled,
@@ -73,9 +76,9 @@ function buildFlag(key, body, identity, existing) {
73
76
  description,
74
77
  metadata: {
75
78
  createdBy: existing?.metadata.createdBy ?? identity,
76
- createdAt: existing?.metadata.createdAt ?? now,
79
+ createdAt,
77
80
  updatedBy: identity,
78
- updatedAt: now
81
+ updatedAt
79
82
  }
80
83
  };
81
84
  const validated = (0, import_core.parseFlag)(candidate);
@@ -85,6 +88,9 @@ function buildFlag(key, body, identity, existing) {
85
88
  }
86
89
  return { ok: true, flag: validated };
87
90
  }
91
+ function flagEtag(flag) {
92
+ return `"${flag.metadata.updatedAt}"`;
93
+ }
88
94
 
89
95
  // src/openapi.ts
90
96
  var openApiDocument = {
@@ -473,17 +479,19 @@ async function safeEqual(a, b) {
473
479
  }
474
480
  var MIN_SECRET_LENGTH = 16;
475
481
  var warnedWeakSecrets = /* @__PURE__ */ new Set();
482
+ function weakSecretKey(secret) {
483
+ let hash = 5381;
484
+ for (let i = 0; i < secret.length; i++) hash = (hash << 5) + hash ^ secret.charCodeAt(i);
485
+ return hash >>> 0;
486
+ }
476
487
  function warnIfWeakSecret(kind, secret) {
477
488
  if (secret.length >= MIN_SECRET_LENGTH) return;
478
- void (async () => {
479
- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
480
- const id = Array.from(new Uint8Array(digest).slice(0, 6)).map((b) => b.toString(16).padStart(2, "0")).join("");
481
- if (warnedWeakSecrets.has(id)) return;
482
- warnedWeakSecrets.add(id);
483
- console.warn(
484
- `[flaghoist] the ${kind} is ${secret.length} characters. Use a long random value (for example \`openssl rand -hex 32\`); a short secret is guessable, especially since Flaghoist does not rate limit authentication.`
485
- );
486
- })();
489
+ const key = weakSecretKey(secret);
490
+ if (warnedWeakSecrets.has(key)) return;
491
+ warnedWeakSecrets.add(key);
492
+ console.warn(
493
+ `[flaghoist] the ${kind} is ${secret.length} characters. Use a long random value (for example \`openssl rand -hex 32\`); a short secret is guessable, especially since Flaghoist does not rate limit authentication.`
494
+ );
487
495
  }
488
496
  function apiKey(expected) {
489
497
  warnIfWeakSecret("read API key", expected);
@@ -690,6 +698,7 @@ function createFlagServer(config) {
690
698
  if (!auth.ok) return c.json({ error: auth.message ?? "Unauthorized" }, auth.status ?? 401);
691
699
  const flag = await cfg.storage.get(c.req.param("key"));
692
700
  if (!flag) return c.json({ error: "Flag not found" }, 404);
701
+ c.header("ETag", flagEtag(flag));
693
702
  return c.json(flag);
694
703
  });
695
704
  app.put(`${prefix}/flags/:key`, async (c) => {
@@ -697,17 +706,24 @@ function createFlagServer(config) {
697
706
  const auth = await cfg.auth.admin(c.req.raw.headers);
698
707
  if (!auth.ok) return c.json({ error: auth.message ?? "Unauthorized" }, auth.status ?? 401);
699
708
  const key = c.req.param("key");
709
+ const existing = await cfg.storage.get(key);
710
+ const ifMatch = c.req.header("If-Match")?.trim();
711
+ if (ifMatch !== void 0) {
712
+ const precondition = ifMatch === "*" ? existing !== null : existing !== null && flagEtag(existing) === ifMatch;
713
+ if (!precondition) {
714
+ return c.json(
715
+ { error: "This flag changed since you loaded it. Reload and reapply your change." },
716
+ 412
717
+ );
718
+ }
719
+ }
700
720
  const parsed = await readJsonBody(await c.req.text());
701
721
  if (!parsed.ok) return c.json({ error: parsed.message }, parsed.status);
702
- const built = buildFlag(
703
- key,
704
- parsed.value,
705
- auth.identity ?? "unknown",
706
- await cfg.storage.get(key)
707
- );
722
+ const built = buildFlag(key, parsed.value, auth.identity ?? "unknown", existing);
708
723
  if (!built.ok) return c.json({ error: built.error }, 400);
709
724
  await cfg.storage.put(key, built.flag);
710
725
  cache.invalidate();
726
+ c.header("ETag", flagEtag(built.flag));
711
727
  return c.json(built.flag);
712
728
  });
713
729
  app.delete(`${prefix}/flags/:key`, async (c) => {
package/dist/index.js CHANGED
@@ -34,7 +34,10 @@ function buildFlag(key, body, identity, existing) {
34
34
  return { ok: false, error: `Description exceeds ${LIMITS.maxDescriptionLength} characters` };
35
35
  }
36
36
  const inputRules = Array.isArray(b.rules) ? b.rules : [];
37
- const now = (/* @__PURE__ */ new Date()).toISOString();
37
+ const nowMs = Date.now();
38
+ const prevMs = existing ? Date.parse(existing.metadata.updatedAt) : 0;
39
+ const updatedAt = new Date(nowMs > prevMs ? nowMs : prevMs + 1).toISOString();
40
+ const createdAt = existing?.metadata.createdAt ?? new Date(nowMs).toISOString();
38
41
  const candidate = {
39
42
  key,
40
43
  enabled,
@@ -43,9 +46,9 @@ function buildFlag(key, body, identity, existing) {
43
46
  description,
44
47
  metadata: {
45
48
  createdBy: existing?.metadata.createdBy ?? identity,
46
- createdAt: existing?.metadata.createdAt ?? now,
49
+ createdAt,
47
50
  updatedBy: identity,
48
- updatedAt: now
51
+ updatedAt
49
52
  }
50
53
  };
51
54
  const validated = parseFlag(candidate);
@@ -55,6 +58,9 @@ function buildFlag(key, body, identity, existing) {
55
58
  }
56
59
  return { ok: true, flag: validated };
57
60
  }
61
+ function flagEtag(flag) {
62
+ return `"${flag.metadata.updatedAt}"`;
63
+ }
58
64
 
59
65
  // src/openapi.ts
60
66
  var openApiDocument = {
@@ -443,17 +449,19 @@ async function safeEqual(a, b) {
443
449
  }
444
450
  var MIN_SECRET_LENGTH = 16;
445
451
  var warnedWeakSecrets = /* @__PURE__ */ new Set();
452
+ function weakSecretKey(secret) {
453
+ let hash = 5381;
454
+ for (let i = 0; i < secret.length; i++) hash = (hash << 5) + hash ^ secret.charCodeAt(i);
455
+ return hash >>> 0;
456
+ }
446
457
  function warnIfWeakSecret(kind, secret) {
447
458
  if (secret.length >= MIN_SECRET_LENGTH) return;
448
- void (async () => {
449
- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
450
- const id = Array.from(new Uint8Array(digest).slice(0, 6)).map((b) => b.toString(16).padStart(2, "0")).join("");
451
- if (warnedWeakSecrets.has(id)) return;
452
- warnedWeakSecrets.add(id);
453
- console.warn(
454
- `[flaghoist] the ${kind} is ${secret.length} characters. Use a long random value (for example \`openssl rand -hex 32\`); a short secret is guessable, especially since Flaghoist does not rate limit authentication.`
455
- );
456
- })();
459
+ const key = weakSecretKey(secret);
460
+ if (warnedWeakSecrets.has(key)) return;
461
+ warnedWeakSecrets.add(key);
462
+ console.warn(
463
+ `[flaghoist] the ${kind} is ${secret.length} characters. Use a long random value (for example \`openssl rand -hex 32\`); a short secret is guessable, especially since Flaghoist does not rate limit authentication.`
464
+ );
457
465
  }
458
466
  function apiKey(expected) {
459
467
  warnIfWeakSecret("read API key", expected);
@@ -660,6 +668,7 @@ function createFlagServer(config) {
660
668
  if (!auth.ok) return c.json({ error: auth.message ?? "Unauthorized" }, auth.status ?? 401);
661
669
  const flag = await cfg.storage.get(c.req.param("key"));
662
670
  if (!flag) return c.json({ error: "Flag not found" }, 404);
671
+ c.header("ETag", flagEtag(flag));
663
672
  return c.json(flag);
664
673
  });
665
674
  app.put(`${prefix}/flags/:key`, async (c) => {
@@ -667,17 +676,24 @@ function createFlagServer(config) {
667
676
  const auth = await cfg.auth.admin(c.req.raw.headers);
668
677
  if (!auth.ok) return c.json({ error: auth.message ?? "Unauthorized" }, auth.status ?? 401);
669
678
  const key = c.req.param("key");
679
+ const existing = await cfg.storage.get(key);
680
+ const ifMatch = c.req.header("If-Match")?.trim();
681
+ if (ifMatch !== void 0) {
682
+ const precondition = ifMatch === "*" ? existing !== null : existing !== null && flagEtag(existing) === ifMatch;
683
+ if (!precondition) {
684
+ return c.json(
685
+ { error: "This flag changed since you loaded it. Reload and reapply your change." },
686
+ 412
687
+ );
688
+ }
689
+ }
670
690
  const parsed = await readJsonBody(await c.req.text());
671
691
  if (!parsed.ok) return c.json({ error: parsed.message }, parsed.status);
672
- const built = buildFlag(
673
- key,
674
- parsed.value,
675
- auth.identity ?? "unknown",
676
- await cfg.storage.get(key)
677
- );
692
+ const built = buildFlag(key, parsed.value, auth.identity ?? "unknown", existing);
678
693
  if (!built.ok) return c.json({ error: built.error }, 400);
679
694
  await cfg.storage.put(key, built.flag);
680
695
  cache.invalidate();
696
+ c.header("ETag", flagEtag(built.flag));
681
697
  return c.json(built.flag);
682
698
  });
683
699
  app.delete(`${prefix}/flags/:key`, async (c) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flaghoist/server",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "description": "Hono app factory for Flaghoist: OFREP evaluate, admin CRUD, and pluggable auth.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -49,8 +49,8 @@
49
49
  "@flaghoist/core": "0.1.2"
50
50
  },
51
51
  "devDependencies": {
52
- "@flaghoist/dashboard": "0.0.0",
53
- "@flaghoist/adapter-memory": "0.1.2"
52
+ "@flaghoist/adapter-memory": "0.1.2",
53
+ "@flaghoist/dashboard": "0.0.0"
54
54
  },
55
55
  "publishConfig": {
56
56
  "access": "public"