@hot-updater/server 0.36.7 → 1.0.0-rc.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.
Files changed (291) hide show
  1. package/dist/adapters/databaseConstraintErrors.cjs +23 -0
  2. package/dist/adapters/databaseConstraintErrors.mjs +21 -0
  3. package/dist/adapters/databasePluginUtils.cjs +117 -0
  4. package/dist/adapters/databasePluginUtils.mjs +106 -0
  5. package/dist/adapters/drizzle.cjs +53 -176
  6. package/dist/adapters/drizzle.d.cts +6 -4
  7. package/dist/adapters/drizzle.d.mts +6 -4
  8. package/dist/adapters/drizzle.mjs +54 -177
  9. package/dist/adapters/drizzleCrud.cjs +250 -0
  10. package/dist/adapters/drizzleCrud.mjs +249 -0
  11. package/dist/adapters/drizzleLazyDB.cjs +84 -36
  12. package/dist/adapters/drizzleLazyDB.mjs +84 -36
  13. package/dist/adapters/drizzleQuery.cjs +68 -0
  14. package/dist/adapters/drizzleQuery.mjs +67 -0
  15. package/dist/adapters/kysely.cjs +18 -138
  16. package/dist/adapters/kysely.d.cts +3 -12
  17. package/dist/adapters/kysely.d.mts +3 -12
  18. package/dist/adapters/kysely.mjs +19 -139
  19. package/dist/adapters/kyselyCrud.cjs +254 -0
  20. package/dist/adapters/kyselyCrud.mjs +253 -0
  21. package/dist/adapters/kyselyQuery.cjs +56 -0
  22. package/dist/adapters/kyselyQuery.mjs +55 -0
  23. package/dist/adapters/mongodb.cjs +21 -174
  24. package/dist/adapters/mongodb.d.cts +3 -2
  25. package/dist/adapters/mongodb.d.mts +3 -2
  26. package/dist/adapters/mongodb.mjs +21 -174
  27. package/dist/adapters/mongodbCollections.cjs +36 -0
  28. package/dist/adapters/mongodbCollections.mjs +30 -0
  29. package/dist/adapters/mongodbQuery.cjs +77 -0
  30. package/dist/adapters/mongodbQuery.mjs +70 -0
  31. package/dist/adapters/mongodbReads.cjs +148 -0
  32. package/dist/adapters/mongodbReads.mjs +147 -0
  33. package/dist/adapters/mongodbWrites.cjs +134 -0
  34. package/dist/adapters/mongodbWrites.mjs +133 -0
  35. package/dist/adapters/prisma.cjs +205 -180
  36. package/dist/adapters/prisma.d.cts +3 -3
  37. package/dist/adapters/prisma.d.mts +3 -3
  38. package/dist/adapters/prisma.mjs +206 -181
  39. package/dist/adapters/prismaQuery.cjs +58 -0
  40. package/dist/adapters/prismaQuery.mjs +56 -0
  41. package/dist/adapters/prismaRows.cjs +198 -0
  42. package/dist/adapters/prismaRows.mjs +188 -0
  43. package/dist/apiKeys.cjs +139 -0
  44. package/dist/apiKeys.d.cts +50 -0
  45. package/dist/apiKeys.d.mts +50 -0
  46. package/dist/apiKeys.mjs +133 -0
  47. package/dist/createHotUpdaterCore.cjs +48 -26
  48. package/dist/createHotUpdaterCore.d.cts +49 -17
  49. package/dist/createHotUpdaterCore.d.mts +49 -17
  50. package/dist/createHotUpdaterCore.mjs +51 -29
  51. package/dist/db/createBundleDiff.cjs +45 -68
  52. package/dist/db/createBundleDiff.d.cts +3 -3
  53. package/dist/db/createBundleDiff.d.mts +3 -3
  54. package/dist/db/createBundleDiff.mjs +47 -68
  55. package/dist/db/databasePluginCore.cjs +138 -0
  56. package/dist/db/databasePluginCore.mjs +137 -0
  57. package/dist/db/fixedMigrator.cjs +36 -93
  58. package/dist/db/fixedMigrator.mjs +36 -93
  59. package/dist/db/fixedMigratorMongo.cjs +319 -0
  60. package/dist/db/fixedMigratorMongo.mjs +319 -0
  61. package/dist/db/fixedMigratorShared.cjs +23 -0
  62. package/dist/db/fixedMigratorShared.mjs +18 -0
  63. package/dist/db/mongoMigrationExecution.cjs +11 -0
  64. package/dist/db/mongoMigrationExecution.mjs +11 -0
  65. package/dist/db/releaseCatalog.cjs +66 -0
  66. package/dist/db/releaseCatalog.d.cts +17 -0
  67. package/dist/db/releaseCatalog.d.mts +17 -0
  68. package/dist/db/releaseCatalog.mjs +64 -0
  69. package/dist/db/schema/mongodb.cjs +14 -6
  70. package/dist/db/schema/mongodb.mjs +14 -6
  71. package/dist/db/schema/registry.cjs +0 -2
  72. package/dist/db/schema/registry.mjs +1 -2
  73. package/dist/db/schema/sql.cjs +31 -14
  74. package/dist/db/schema/sql.mjs +32 -13
  75. package/dist/db/schema/sqlOperations.cjs +4 -4
  76. package/dist/db/schema/sqlOperations.mjs +5 -5
  77. package/dist/db/schemaGenerators.cjs +52 -18
  78. package/dist/db/schemaGenerators.mjs +52 -18
  79. package/dist/db/schemaReadiness.cjs +4 -3
  80. package/dist/db/schemaReadiness.mjs +4 -3
  81. package/dist/db/sqlMigrationExecution.cjs +67 -0
  82. package/dist/db/sqlMigrationExecution.mjs +66 -0
  83. package/dist/db/types.cjs +2 -6
  84. package/dist/db/types.d.cts +60 -16
  85. package/dist/db/types.d.mts +60 -16
  86. package/dist/db/types.mjs +3 -6
  87. package/dist/db/updateArtifacts.cjs +114 -60
  88. package/dist/db/updateArtifacts.mjs +117 -62
  89. package/dist/handler.cjs +108 -282
  90. package/dist/handler.d.cts +3 -46
  91. package/dist/handler.d.mts +3 -46
  92. package/dist/handler.mjs +107 -281
  93. package/dist/handlerBundleRoutes.cjs +155 -0
  94. package/dist/handlerBundleRoutes.mjs +155 -0
  95. package/dist/handlerErrors.cjs +9 -0
  96. package/dist/handlerErrors.mjs +9 -0
  97. package/dist/handlerParameters.cjs +39 -0
  98. package/dist/handlerParameters.mjs +34 -0
  99. package/dist/handlerReleaseCatalogRoutes.cjs +111 -0
  100. package/dist/handlerReleaseCatalogRoutes.mjs +110 -0
  101. package/dist/handlerReleaseManagementRoutes.cjs +125 -0
  102. package/dist/handlerReleaseManagementRoutes.mjs +124 -0
  103. package/dist/handlerTypes.d.cts +63 -0
  104. package/dist/handlerTypes.d.mts +63 -0
  105. package/dist/handlerVersionRoutes.cjs +10 -0
  106. package/dist/handlerVersionRoutes.d.cts +4 -0
  107. package/dist/handlerVersionRoutes.d.mts +4 -0
  108. package/dist/handlerVersionRoutes.mjs +9 -0
  109. package/dist/index.cjs +10 -1
  110. package/dist/index.d.cts +9 -3
  111. package/dist/index.d.mts +9 -3
  112. package/dist/index.mjs +5 -2
  113. package/dist/insights/bounded/activeOverview.cjs +87 -0
  114. package/dist/insights/bounded/activeOverview.mjs +85 -0
  115. package/dist/insights/bounded/installationSearch.cjs +42 -0
  116. package/dist/insights/bounded/installationSearch.mjs +42 -0
  117. package/dist/insights/bounded/persistence.cjs +39 -0
  118. package/dist/insights/bounded/persistence.mjs +38 -0
  119. package/dist/insights/bounded/provider.cjs +170 -0
  120. package/dist/insights/bounded/provider.d.cts +7 -0
  121. package/dist/insights/bounded/provider.d.mts +7 -0
  122. package/dist/insights/bounded/provider.mjs +170 -0
  123. package/dist/insights/bounded/scan.cjs +144 -0
  124. package/dist/insights/bounded/scan.mjs +138 -0
  125. package/dist/insights/domain.d.cts +121 -0
  126. package/dist/insights/domain.d.mts +121 -0
  127. package/dist/insights/errors.cjs +22 -0
  128. package/dist/insights/errors.mjs +20 -0
  129. package/dist/insights/eventInput.cjs +111 -0
  130. package/dist/insights/eventInput.mjs +111 -0
  131. package/dist/insights/persistence.d.cts +6 -0
  132. package/dist/insights/persistence.d.mts +6 -0
  133. package/dist/insights/queryInput.cjs +58 -0
  134. package/dist/insights/queryInput.mjs +55 -0
  135. package/dist/insights/routes.cjs +63 -0
  136. package/dist/insights/routes.mjs +61 -0
  137. package/dist/insights/types.d.cts +17 -0
  138. package/dist/insights/types.d.mts +17 -0
  139. package/dist/node.cjs +19 -12
  140. package/dist/node.d.cts +15 -10
  141. package/dist/node.d.mts +15 -10
  142. package/dist/node.mjs +19 -13
  143. package/dist/package.cjs +1 -1
  144. package/dist/package.mjs +1 -1
  145. package/dist/schema/dsl.cjs +19 -2
  146. package/dist/schema/dsl.mjs +17 -3
  147. package/dist/schema/index.cjs +1 -8
  148. package/dist/schema/index.mjs +2 -8
  149. package/dist/schema/types.cjs +3 -1
  150. package/dist/schema/types.mjs +3 -2
  151. package/dist/schema/v1_0_0.cjs +364 -0
  152. package/dist/schema/v1_0_0.mjs +357 -0
  153. package/dist/storageAccess.cjs +69 -25
  154. package/dist/storageAccess.mjs +68 -25
  155. package/dist/types/index.d.cts +4 -3
  156. package/dist/types/index.d.mts +5 -4
  157. package/package.json +8 -7
  158. package/src/adapters/databaseConstraintErrors.ts +25 -0
  159. package/src/adapters/databasePluginTestDatabase.ts +106 -0
  160. package/src/adapters/databasePluginUtils.spec.ts +29 -0
  161. package/src/adapters/databasePluginUtils.ts +214 -0
  162. package/src/adapters/drizzle.spec.ts +208 -47
  163. package/src/adapters/drizzle.ts +106 -293
  164. package/src/adapters/drizzleCrud.ts +419 -0
  165. package/src/adapters/drizzleLazyDB.ts +206 -100
  166. package/src/adapters/drizzleQuery.ts +164 -0
  167. package/src/adapters/kysely.spec.ts +126 -88
  168. package/src/adapters/kysely.ts +76 -334
  169. package/src/adapters/kyselyCrud.ts +588 -0
  170. package/src/adapters/kyselyQuery.ts +128 -0
  171. package/src/adapters/mongodb.spec.ts +141 -0
  172. package/src/adapters/mongodb.ts +60 -288
  173. package/src/adapters/mongodbCollections.ts +62 -0
  174. package/src/adapters/mongodbQuery.ts +226 -0
  175. package/src/adapters/mongodbReads.ts +311 -0
  176. package/src/adapters/mongodbRows.spec.ts +64 -0
  177. package/src/adapters/mongodbRows.ts +118 -0
  178. package/src/adapters/mongodbTestClient.ts +342 -0
  179. package/src/adapters/mongodbTestFilter.ts +166 -0
  180. package/src/adapters/mongodbWrites.ts +260 -0
  181. package/src/adapters/prisma.spec.ts +196 -0
  182. package/src/adapters/prisma.ts +417 -292
  183. package/src/adapters/prismaQuery.ts +129 -0
  184. package/src/adapters/prismaRows.spec.ts +98 -0
  185. package/src/adapters/prismaRows.ts +301 -0
  186. package/src/adapters/prismaTestClient.ts +396 -0
  187. package/src/apiKeys.integration.spec.ts +192 -0
  188. package/src/apiKeys.spec.ts +162 -0
  189. package/src/apiKeys.ts +220 -0
  190. package/src/createHotUpdaterCore.ts +164 -100
  191. package/src/db/bundleRows.spec.ts +164 -0
  192. package/src/db/bundleRows.ts +10 -140
  193. package/src/db/createBundleDiff.spec.ts +172 -127
  194. package/src/db/createBundleDiff.ts +91 -110
  195. package/src/db/databasePluginCore.spec.ts +62 -0
  196. package/src/db/databasePluginCore.testFixtures.ts +76 -0
  197. package/src/db/databasePluginCore.ts +185 -0
  198. package/src/db/databasePluginUpdateCheck.bench.ts +107 -0
  199. package/src/db/fixedMigrator.spec.ts +184 -42
  200. package/src/db/fixedMigrator.ts +64 -205
  201. package/src/db/fixedMigratorMongo.spec.ts +192 -0
  202. package/src/db/fixedMigratorMongo.ts +425 -0
  203. package/src/db/fixedMigratorShared.ts +36 -0
  204. package/src/db/hotUpdaterSchema.ts +0 -1
  205. package/src/db/index.spec.ts +333 -1025
  206. package/src/db/mongoMigrationExecution.ts +23 -0
  207. package/src/db/node-sqlite.d.ts +25 -0
  208. package/src/db/releaseCatalog.ts +148 -0
  209. package/src/db/schema/mongodb.ts +20 -6
  210. package/src/db/schema/sql.ts +52 -11
  211. package/src/db/schema/sqlOperations.ts +5 -4
  212. package/src/db/schemaGenerators.ts +107 -23
  213. package/src/db/schemaReadiness.ts +2 -1
  214. package/src/db/sqlMigrationExecution.spec.ts +65 -0
  215. package/src/db/sqlMigrationExecution.ts +108 -0
  216. package/src/db/types.spec.ts +55 -0
  217. package/src/db/types.ts +129 -65
  218. package/src/db/updateArtifacts.spec.ts +359 -0
  219. package/src/db/updateArtifacts.ts +290 -126
  220. package/src/handler-standalone.integration.spec.ts +401 -364
  221. package/src/handler.spec.ts +137 -582
  222. package/src/handler.testFixtures.ts +30 -0
  223. package/src/handler.ts +218 -616
  224. package/src/handlerAuth.integration.spec.ts +33 -0
  225. package/src/handlerBundleRoutes.ts +259 -0
  226. package/src/handlerErrors.ts +7 -0
  227. package/src/handlerManagement.spec.ts +262 -0
  228. package/src/handlerParameters.ts +105 -0
  229. package/src/handlerReleaseCatalogRoutes.ts +154 -0
  230. package/src/handlerReleaseManagementRoutes.ts +213 -0
  231. package/src/handlerTypes.ts +96 -0
  232. package/src/handlerVersionRoutes.ts +12 -0
  233. package/src/index.ts +22 -2
  234. package/src/insights/bounded/activeOverview.spec.ts +189 -0
  235. package/src/insights/bounded/activeOverview.ts +148 -0
  236. package/src/insights/bounded/installationSearch.spec.ts +130 -0
  237. package/src/insights/bounded/installationSearch.ts +69 -0
  238. package/src/insights/bounded/persistence.ts +43 -0
  239. package/src/insights/bounded/provider.spec.ts +219 -0
  240. package/src/insights/bounded/provider.ts +229 -0
  241. package/src/insights/bounded/scan.ts +235 -0
  242. package/src/insights/domain.ts +139 -0
  243. package/src/insights/errors.ts +18 -0
  244. package/src/insights/eventInput.ts +168 -0
  245. package/src/insights/insights.integration.spec.ts +144 -0
  246. package/src/insights/persistence.ts +10 -0
  247. package/src/insights/queryInput.ts +102 -0
  248. package/src/insights/routes.ts +123 -0
  249. package/src/insights/types.ts +44 -0
  250. package/src/node.spec.ts +79 -10
  251. package/src/node.ts +20 -17
  252. package/src/releaseCatalogRoutes.integration.spec.ts +238 -0
  253. package/src/runtime.spec.ts +126 -959
  254. package/src/runtime.testFixtures.ts +71 -0
  255. package/src/schema/dsl-all-versions.spec.ts +26 -17
  256. package/src/schema/dsl.ts +41 -2
  257. package/src/schema/index.ts +3 -9
  258. package/src/schema/types.ts +9 -3
  259. package/src/schema/v1_0_0.spec.ts +146 -0
  260. package/src/schema/v1_0_0.ts +465 -0
  261. package/src/storageAccess.spec.ts +127 -32
  262. package/src/storageAccess.ts +106 -55
  263. package/src/types/index.ts +2 -1
  264. package/dist/db/bundleRows.cjs +0 -82
  265. package/dist/db/bundleRows.d.cts +0 -33
  266. package/dist/db/bundleRows.d.mts +0 -33
  267. package/dist/db/bundleRows.mjs +0 -79
  268. package/dist/db/pluginCore.cjs +0 -242
  269. package/dist/db/pluginCore.mjs +0 -241
  270. package/dist/db/schema/sqlMigrations.cjs +0 -74
  271. package/dist/db/schema/sqlMigrations.mjs +0 -73
  272. package/dist/db/schemaEnhancements.cjs +0 -20
  273. package/dist/db/schemaEnhancements.mjs +0 -19
  274. package/dist/route.cjs +0 -7
  275. package/dist/route.mjs +0 -7
  276. package/dist/schema/v0_21_0.cjs +0 -38
  277. package/dist/schema/v0_21_0.mjs +0 -37
  278. package/dist/schema/v0_29_0.cjs +0 -45
  279. package/dist/schema/v0_29_0.mjs +0 -44
  280. package/dist/schema/v0_31_0.cjs +0 -82
  281. package/dist/schema/v0_31_0.mjs +0 -80
  282. package/src/db/pluginCore.spec.ts +0 -1095
  283. package/src/db/pluginCore.ts +0 -475
  284. package/src/db/pluginUpdateCheck.bench.ts +0 -254
  285. package/src/db/schema/sqlMigrations.ts +0 -219
  286. package/src/db/schemaEnhancements.ts +0 -55
  287. package/src/route.ts +0 -7
  288. package/src/schema/v0_21_0.ts +0 -52
  289. package/src/schema/v0_29_0.ts +0 -62
  290. package/src/schema/v0_31_0.spec.ts +0 -73
  291. package/src/schema/v0_31_0.ts +0 -120
@@ -0,0 +1,162 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { createInMemoryDatabasePlugin } from "../../test-utils/test/inMemoryDatabasePlugin";
4
+ import {
5
+ API_KEY_HEADER_NAME,
6
+ authenticateApiKey,
7
+ createApiKey,
8
+ createApiKeyManagement,
9
+ hashApiKey,
10
+ normalizeApiKeyHeaderName,
11
+ provisionApiKey,
12
+ registerApiKey,
13
+ } from "./apiKeys";
14
+
15
+ const API_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE";
16
+
17
+ describe("API keys", () => {
18
+ it("normalizes valid header names and rejects invalid names", () => {
19
+ expect(normalizeApiKeyHeaderName()).toBe("x-api-key");
20
+ expect(normalizeApiKeyHeaderName("X-Hot-Updater-Key")).toBe(
21
+ "x-hot-updater-key",
22
+ );
23
+ expect(() => normalizeApiKeyHeaderName("")).toThrow(
24
+ "clientAccess.headerName must be a valid header name.",
25
+ );
26
+ expect(() => normalizeApiKeyHeaderName("invalid header")).toThrow(
27
+ "clientAccess.headerName must be a valid header name.",
28
+ );
29
+ });
30
+
31
+ it("persists only the digest and metadata for a registered key", async () => {
32
+ const database = createInMemoryDatabasePlugin();
33
+
34
+ const created = await registerApiKey({
35
+ apiKeys: database.models.apiKeys,
36
+ apiKey: API_KEY,
37
+ createdAtMs: 100,
38
+ name: " Production app ",
39
+ });
40
+
41
+ expect(created.apiKey).toBe(API_KEY);
42
+ expect(created.record).toEqual({
43
+ created_at_ms: 100,
44
+ id: expect.stringMatching(/^api-[A-Za-z0-9_-]{22}$/u),
45
+ name: "Production app",
46
+ prefix: "AQEBAQ",
47
+ revoked_at_ms: null,
48
+ role: "client",
49
+ });
50
+ expect(created.record).not.toHaveProperty("hash");
51
+ expect(created.record.id).not.toContain(await hashApiKey(API_KEY));
52
+ expect(await database.models.apiKeys.list()).toEqual([
53
+ expect.objectContaining({ hash: await hashApiKey(API_KEY) }),
54
+ ]);
55
+ expect(JSON.stringify(await database.models.apiKeys.list())).not.toContain(
56
+ API_KEY,
57
+ );
58
+
59
+ const registeredAgain = await registerApiKey({
60
+ apiKeys: database.models.apiKeys,
61
+ apiKey: API_KEY,
62
+ createdAtMs: 200,
63
+ name: "Ignored replacement name",
64
+ });
65
+ expect(registeredAgain.record).toEqual(created.record);
66
+ expect(await database.models.apiKeys.list()).toHaveLength(1);
67
+ });
68
+
69
+ it("authenticates only active keys after the readiness check", async () => {
70
+ const database = createInMemoryDatabasePlugin();
71
+ const beforeLookup = vi.fn(async () => undefined);
72
+ const { record } = await registerApiKey({
73
+ apiKeys: database.models.apiKeys,
74
+ apiKey: API_KEY,
75
+ name: "App",
76
+ });
77
+ const request = new Request("https://example.com", {
78
+ headers: { [API_KEY_HEADER_NAME]: API_KEY },
79
+ });
80
+
81
+ await expect(
82
+ authenticateApiKey({
83
+ apiKeys: database.models.apiKeys,
84
+ beforeLookup,
85
+ request,
86
+ }),
87
+ ).resolves.toBe(true);
88
+ expect(beforeLookup).toHaveBeenCalledOnce();
89
+
90
+ await database.models.apiKeys.revoke({
91
+ id: record.id,
92
+ revokedAtMs: Date.now(),
93
+ });
94
+ await expect(
95
+ authenticateApiKey({
96
+ apiKeys: database.models.apiKeys,
97
+ request,
98
+ }),
99
+ ).resolves.toBe(false);
100
+ });
101
+
102
+ it("creates a canonical key and validates visible names", async () => {
103
+ const database = createInMemoryDatabasePlugin();
104
+
105
+ const created = await createApiKey({
106
+ apiKeys: database.models.apiKeys,
107
+ name: "App",
108
+ });
109
+
110
+ expect(created.apiKey).toMatch(/^[A-Za-z0-9_-]{43}$/u);
111
+ expect(created.record.prefix).toBe(created.apiKey.slice(0, 6));
112
+ await expect(
113
+ registerApiKey({
114
+ apiKeys: database.models.apiKeys,
115
+ apiKey: API_KEY,
116
+ name: " ",
117
+ }),
118
+ ).rejects.toThrow("1-64 visible characters");
119
+ });
120
+
121
+ it("provisions a new key once and reuses an existing managed key", async () => {
122
+ const database = createInMemoryDatabasePlugin();
123
+
124
+ const created = await provisionApiKey({
125
+ apiKeys: database.models.apiKeys,
126
+ name: "Managed init",
127
+ });
128
+ const reused = await provisionApiKey({
129
+ apiKeys: database.models.apiKeys,
130
+ existingApiKey: created.apiKey,
131
+ name: "Managed init rerun",
132
+ });
133
+
134
+ expect(reused).toEqual(created);
135
+ expect(await database.models.apiKeys.list()).toHaveLength(1);
136
+ });
137
+
138
+ it("manages keys without exposing stored hashes", async () => {
139
+ const database = createInMemoryDatabasePlugin();
140
+ const beforeOperation = vi.fn(async () => undefined);
141
+ const apiKeys = createApiKeyManagement({
142
+ apiKeys: database.models.apiKeys,
143
+ beforeOperation,
144
+ });
145
+
146
+ const created = await apiKeys.create({ name: "Production app" });
147
+ expect(created.apiKey).toMatch(/^[A-Za-z0-9_-]{43}$/u);
148
+ expect(created.record).not.toHaveProperty("hash");
149
+
150
+ const records = await apiKeys.list();
151
+ expect(records).toEqual([created.record]);
152
+ expect(records[0]).not.toHaveProperty("hash");
153
+
154
+ const revoked = await apiKeys.revoke({ id: created.record.id });
155
+ expect(revoked).toMatchObject({
156
+ id: created.record.id,
157
+ revoked_at_ms: expect.any(Number),
158
+ });
159
+ expect(revoked).not.toHaveProperty("hash");
160
+ expect(beforeOperation).toHaveBeenCalledTimes(3);
161
+ });
162
+ });
package/src/apiKeys.ts ADDED
@@ -0,0 +1,220 @@
1
+ import type { ApiKeyModel, ApiKeyRow } from "@hot-updater/plugin-core";
2
+
3
+ export const API_KEY_HEADER_NAME = "x-api-key";
4
+
5
+ const BASE64URL_32_PATTERN = /^[A-Za-z0-9_-]{43}$/u;
6
+
7
+ const bytesToBase64Url = (bytes: Uint8Array): string => {
8
+ let binary = "";
9
+ for (const byte of bytes) binary += String.fromCharCode(byte);
10
+ return btoa(binary)
11
+ .replaceAll("+", "-")
12
+ .replaceAll("/", "_")
13
+ .replace(/=+$/u, "");
14
+ };
15
+
16
+ export const isApiKey = (value: string): boolean => {
17
+ if (!BASE64URL_32_PATTERN.test(value)) return false;
18
+
19
+ try {
20
+ const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
21
+ const binary = atob(`${base64}=`);
22
+ const bytes = Uint8Array.from(binary, (character) =>
23
+ character.charCodeAt(0),
24
+ );
25
+ return bytes.byteLength === 32 && bytesToBase64Url(bytes) === value;
26
+ } catch {
27
+ return false;
28
+ }
29
+ };
30
+
31
+ export const normalizeApiKeyHeaderName = (
32
+ value: unknown = API_KEY_HEADER_NAME,
33
+ ): string => {
34
+ if (typeof value !== "string" || value.length === 0) {
35
+ throw new TypeError("clientAccess.headerName must be a valid header name.");
36
+ }
37
+
38
+ try {
39
+ const headers = new Headers();
40
+ headers.set(value, "");
41
+ return headers.keys().next().value!;
42
+ } catch {
43
+ throw new TypeError("clientAccess.headerName must be a valid header name.");
44
+ }
45
+ };
46
+
47
+ export const hashApiKey = async (apiKey: string): Promise<string> => {
48
+ if (!isApiKey(apiKey)) {
49
+ throw new TypeError("API key must be a canonical 32-byte base64url value.");
50
+ }
51
+ const digest = await crypto.subtle.digest(
52
+ "SHA-256",
53
+ new TextEncoder().encode(apiKey),
54
+ );
55
+ return bytesToBase64Url(new Uint8Array(digest));
56
+ };
57
+
58
+ export const apiKeyId = (): string => {
59
+ const bytes = new Uint8Array(16);
60
+ crypto.getRandomValues(bytes);
61
+ return `api-${bytesToBase64Url(bytes)}`;
62
+ };
63
+
64
+ export const normalizeApiKeyName = (name: string): string => {
65
+ const normalized = name.trim();
66
+ if (
67
+ normalized.length === 0 ||
68
+ normalized.length > 64 ||
69
+ /[\p{Cc}\p{Cf}]/u.test(normalized)
70
+ ) {
71
+ throw new TypeError("API key names must contain 1-64 visible characters.");
72
+ }
73
+ return normalized;
74
+ };
75
+
76
+ export type ApiKeyMetadata = Omit<ApiKeyRow, "hash">;
77
+
78
+ export interface CreatedApiKey {
79
+ /** Plaintext credential. It cannot be recovered after this call returns. */
80
+ readonly apiKey: string;
81
+ /** Non-secret metadata. The persisted SHA-256 hash is never exposed. */
82
+ readonly record: ApiKeyMetadata;
83
+ }
84
+
85
+ export interface ApiKeyManagementAPI {
86
+ /** Creates an API key and returns its plaintext exactly once. */
87
+ create(input: { readonly name: string }): Promise<{
88
+ readonly apiKey: string;
89
+ readonly record: ApiKeyMetadata;
90
+ }>;
91
+ /** Lists non-secret API key metadata, including revoked keys. */
92
+ list(): Promise<readonly ApiKeyMetadata[]>;
93
+ /** Revokes an API key immediately and returns only non-secret metadata. */
94
+ revoke(input: { readonly id: string }): Promise<ApiKeyMetadata | null>;
95
+ }
96
+
97
+ const toApiKeyMetadata = ({
98
+ hash: _hash,
99
+ ...metadata
100
+ }: ApiKeyRow): ApiKeyMetadata => Object.freeze(metadata);
101
+
102
+ export const registerApiKey = async (input: {
103
+ readonly apiKey: string;
104
+ readonly apiKeys: ApiKeyModel;
105
+ readonly createdAtMs?: number;
106
+ readonly name: string;
107
+ }): Promise<CreatedApiKey> => {
108
+ const hash = await hashApiKey(input.apiKey);
109
+ const createdAtMs = input.createdAtMs ?? Date.now();
110
+ if (!Number.isSafeInteger(createdAtMs) || createdAtMs < 0) {
111
+ throw new TypeError("API key creation time must be non-negative.");
112
+ }
113
+ const record = Object.freeze({
114
+ created_at_ms: createdAtMs,
115
+ hash,
116
+ id: apiKeyId(),
117
+ name: normalizeApiKeyName(input.name),
118
+ prefix: input.apiKey.slice(0, 6),
119
+ revoked_at_ms: null,
120
+ role: "client" as const,
121
+ });
122
+ const status = await input.apiKeys.create(record);
123
+ if (status === "existing") {
124
+ const existing = await input.apiKeys.findByHash(hash);
125
+ if (existing === null || existing.revoked_at_ms !== null) {
126
+ throw new Error("The API key has been revoked.");
127
+ }
128
+ return Object.freeze({
129
+ apiKey: input.apiKey,
130
+ record: toApiKeyMetadata(existing),
131
+ });
132
+ }
133
+ return Object.freeze({
134
+ apiKey: input.apiKey,
135
+ record: toApiKeyMetadata(record),
136
+ });
137
+ };
138
+
139
+ export const createApiKey = (input: {
140
+ readonly apiKeys: ApiKeyModel;
141
+ readonly name: string;
142
+ }): Promise<CreatedApiKey> => {
143
+ const bytes = new Uint8Array(32);
144
+ crypto.getRandomValues(bytes);
145
+ return registerApiKey({
146
+ apiKey: bytesToBase64Url(bytes),
147
+ apiKeys: input.apiKeys,
148
+ name: input.name,
149
+ });
150
+ };
151
+
152
+ /**
153
+ * Provisions the client API key used by managed init.
154
+ *
155
+ * A saved plaintext key is registered idempotently; otherwise a new key is
156
+ * created. Callers must persist or display the returned plaintext because the
157
+ * database stores only its hash.
158
+ */
159
+ export const provisionApiKey = (input: {
160
+ readonly apiKeys: ApiKeyModel;
161
+ readonly existingApiKey?: string;
162
+ readonly name: string;
163
+ }): Promise<CreatedApiKey> => {
164
+ const existingApiKey = input.existingApiKey?.trim();
165
+ return existingApiKey
166
+ ? registerApiKey({
167
+ apiKey: existingApiKey,
168
+ apiKeys: input.apiKeys,
169
+ name: input.name,
170
+ })
171
+ : createApiKey({ apiKeys: input.apiKeys, name: input.name });
172
+ };
173
+
174
+ export const authenticateApiKey = async (input: {
175
+ readonly beforeLookup?: () => Promise<void>;
176
+ readonly apiKeys: ApiKeyModel;
177
+ readonly headerName?: string;
178
+ readonly request: Request;
179
+ }): Promise<boolean> => {
180
+ const apiKey = input.request.headers.get(
181
+ normalizeApiKeyHeaderName(input.headerName),
182
+ );
183
+ if (apiKey === null || !isApiKey(apiKey)) return false;
184
+ const hash = await hashApiKey(apiKey);
185
+ await input.beforeLookup?.();
186
+ const record = await input.apiKeys.findByHash(hash);
187
+ return (
188
+ record !== null &&
189
+ record.hash === hash &&
190
+ record.role === "client" &&
191
+ record.revoked_at_ms === null
192
+ );
193
+ };
194
+
195
+ export const createApiKeyManagement = (input: {
196
+ readonly apiKeys: ApiKeyModel;
197
+ readonly beforeOperation: () => Promise<void>;
198
+ }): ApiKeyManagementAPI =>
199
+ Object.freeze({
200
+ async create({ name }: { readonly name: string }) {
201
+ await input.beforeOperation();
202
+ const created = await createApiKey({ apiKeys: input.apiKeys, name });
203
+ return Object.freeze({
204
+ apiKey: created.apiKey,
205
+ record: created.record,
206
+ });
207
+ },
208
+ async list() {
209
+ await input.beforeOperation();
210
+ return Object.freeze((await input.apiKeys.list()).map(toApiKeyMetadata));
211
+ },
212
+ async revoke({ id }: { readonly id: string }) {
213
+ await input.beforeOperation();
214
+ const record = await input.apiKeys.revoke({
215
+ id,
216
+ revokedAtMs: Date.now(),
217
+ });
218
+ return record === null ? null : toApiKeyMetadata(record);
219
+ },
220
+ });