dineway 0.1.4 → 0.1.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.
Files changed (193) hide show
  1. package/README.md +6 -3
  2. package/dist/{apply-CAPvMfoU.mjs → apply-iVSqz2qs.mjs} +132 -39
  3. package/dist/astro/index.d.mts +18 -9
  4. package/dist/astro/index.mjs +238 -16
  5. package/dist/astro/middleware/auth.d.mts +16 -5
  6. package/dist/astro/middleware/auth.mjs +74 -37
  7. package/dist/astro/middleware/redirect.mjs +24 -8
  8. package/dist/astro/middleware/request-context.mjs +18 -5
  9. package/dist/astro/middleware/setup.mjs +1 -1
  10. package/dist/astro/middleware.mjs +411 -169
  11. package/dist/astro/types.d.mts +25 -8
  12. package/dist/{byline-DeWCMU_i.mjs → byline-OhH2dlRu.mjs} +6 -21
  13. package/dist/{bylines-DyqBV9EQ.mjs → bylines-BGpD9_hy.mjs} +16 -6
  14. package/dist/cache-BdSY-gQN.mjs +42 -0
  15. package/dist/chunks--4F8ddV4.mjs +18 -0
  16. package/dist/cli/index.mjs +935 -15
  17. package/dist/client/external-auth-headers.d.mts +1 -1
  18. package/dist/client/index.d.mts +11 -3
  19. package/dist/client/index.mjs +4 -3
  20. package/dist/{connection-C9pxzuag.mjs → connection-BCNICDWN.mjs} +22 -5
  21. package/dist/{content-zSgdNmnt.mjs → content-DWi4d0rT.mjs} +41 -2
  22. package/dist/database/instrumentation.d.mts +34 -0
  23. package/dist/database/instrumentation.mjs +53 -0
  24. package/dist/db/index.d.mts +3 -3
  25. package/dist/db/index.mjs +2 -2
  26. package/dist/db/libsql.d.mts +1 -1
  27. package/dist/db/libsql.mjs +11 -5
  28. package/dist/db/postgres.d.mts +1 -1
  29. package/dist/db/sqlite.d.mts +1 -1
  30. package/dist/db/sqlite.mjs +7 -1
  31. package/dist/db-errors-CEqD7qH9.mjs +23 -0
  32. package/dist/{default-WYlzADZL.mjs → default-VjJyuuG9.mjs} +2 -0
  33. package/dist/{dialect-helpers-B9uSp2GJ.mjs → dialect-helpers-DhTzaUxP.mjs} +3 -0
  34. package/dist/{error-DrxtnGPg.mjs → error-BmL6QipT.mjs} +7 -3
  35. package/dist/{index-C-jx21qs.d.mts → index-yvc6E_17.d.mts} +157 -30
  36. package/dist/index.d.mts +11 -11
  37. package/dist/index.mjs +24 -22
  38. package/dist/{loader-qKmo0wAY.mjs → loader-sMG4TZ-u.mjs} +9 -3
  39. package/dist/media/index.d.mts +1 -1
  40. package/dist/media/index.mjs +1 -1
  41. package/dist/media/local-runtime.d.mts +7 -7
  42. package/dist/page/index.d.mts +10 -2
  43. package/dist/page/index.mjs +22 -1
  44. package/dist/patterns-CrCYkMBb.mjs +92 -0
  45. package/dist/{placeholder-bOx1xCTY.d.mts → placeholder--wOi4TbO.d.mts} +1 -1
  46. package/dist/{placeholder-B3knXwNc.mjs → placeholder-Cp8g5Emj.mjs} +1 -1
  47. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  48. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  49. package/dist/{query-BiaPl_g2.mjs → query-kDmwCsHh.mjs} +118 -50
  50. package/dist/{redirect-JPqLAbxa.mjs → redirect-DnEWAkVg.mjs} +43 -99
  51. package/dist/{registry-DSd1GWB8.mjs → registry-C0zjeB9P.mjs} +191 -123
  52. package/dist/request-cache-Dk5qPSOx.mjs +66 -0
  53. package/dist/request-context.d.mts +4 -16
  54. package/dist/{runner-B5l1JfOj.d.mts → runner-CFI6B6J2.d.mts} +1 -1
  55. package/dist/{runner-BGUGywgG.mjs → runner-DWZm2KQm.mjs} +589 -137
  56. package/dist/runtime.d.mts +6 -6
  57. package/dist/runtime.mjs +2 -2
  58. package/dist/{search-BNruJHDL.mjs → search-BApX1xhM.mjs} +570 -424
  59. package/dist/seed/index.d.mts +2 -2
  60. package/dist/seed/index.mjs +11 -10
  61. package/dist/seo/index.d.mts +1 -1
  62. package/dist/storage/local.d.mts +1 -1
  63. package/dist/storage/local.mjs +1 -1
  64. package/dist/storage/s3.d.mts +11 -3
  65. package/dist/storage/s3.mjs +78 -15
  66. package/dist/taxonomies-1s5PaS_8.mjs +266 -0
  67. package/dist/transaction-Cn2rjY78.mjs +27 -0
  68. package/dist/{types-BgQeVaPj.d.mts → types-BuMDPy5C.d.mts} +52 -3
  69. package/dist/{types-DuNbGKjF.mjs → types-COeOq9nK.mjs} +6 -1
  70. package/dist/{types-ju-_ORz7.d.mts → types-CWbdtiux.d.mts} +13 -5
  71. package/dist/{types-D38djUXv.d.mts → types-Cj0KMIZV.d.mts} +16 -3
  72. package/dist/{types-DkvMXalq.d.mts → types-DOrVigru.d.mts} +159 -0
  73. package/dist/{validate-CXnRKfJK.mjs → validate-BZ5wnLLp.mjs} +2 -1
  74. package/dist/{validate-DVKJJ-M_.d.mts → validate-IPf8n4Fj.d.mts} +4 -51
  75. package/dist/{validate-CqRJb_xU.mjs → validate-VPnKoIzW.mjs} +10 -10
  76. package/dist/version-hmtC3Cmv.mjs +6 -0
  77. package/package.json +49 -38
  78. package/src/astro/routes/admin.astro +25 -9
  79. package/src/astro/routes/api/admin/api-tokens/[id].ts +4 -0
  80. package/src/astro/routes/api/admin/api-tokens/index.ts +24 -2
  81. package/src/astro/routes/api/admin/briefing.ts +76 -0
  82. package/src/astro/routes/api/admin/bylines/[id]/index.ts +3 -0
  83. package/src/astro/routes/api/admin/bylines/index.ts +2 -0
  84. package/src/astro/routes/api/admin/context/[id]/history.ts +35 -0
  85. package/src/astro/routes/api/admin/context/[id]/index.ts +35 -0
  86. package/src/astro/routes/api/admin/context/[id]/review.ts +57 -0
  87. package/src/astro/routes/api/admin/context/[id]/supersede.ts +58 -0
  88. package/src/astro/routes/api/admin/context/diff.ts +35 -0
  89. package/src/astro/routes/api/admin/context/index.ts +69 -0
  90. package/src/astro/routes/api/admin/context/stale.ts +35 -0
  91. package/src/astro/routes/api/admin/hitl-requests/[id]/index.ts +38 -0
  92. package/src/astro/routes/api/admin/hitl-requests/[id]/resolve.ts +54 -0
  93. package/src/astro/routes/api/admin/hitl-requests/index.ts +38 -0
  94. package/src/astro/routes/api/admin/hooks/exclusive/[hookName].ts +58 -17
  95. package/src/astro/routes/api/admin/oauth-clients/[id].ts +28 -1
  96. package/src/astro/routes/api/admin/oauth-clients/index.ts +25 -1
  97. package/src/astro/routes/api/admin/plugins/[id]/disable.ts +54 -2
  98. package/src/astro/routes/api/admin/plugins/[id]/enable.ts +54 -2
  99. package/src/astro/routes/api/admin/plugins/[id]/uninstall.ts +51 -1
  100. package/src/astro/routes/api/admin/plugins/[id]/update.ts +98 -3
  101. package/src/astro/routes/api/admin/plugins/marketplace/[id]/install.ts +72 -1
  102. package/src/astro/routes/api/admin/review-requests/[id]/index.ts +35 -0
  103. package/src/astro/routes/api/admin/review-requests/[id]/resolve.ts +52 -0
  104. package/src/astro/routes/api/admin/review-requests/index.ts +35 -0
  105. package/src/astro/routes/api/admin/users/[id]/disable.ts +26 -23
  106. package/src/astro/routes/api/admin/users/[id]/index.ts +41 -21
  107. package/src/astro/routes/api/auth/invite/register-options.ts +73 -0
  108. package/src/astro/routes/api/auth/magic-link/send.ts +2 -1
  109. package/src/astro/routes/api/auth/passkey/options.ts +2 -1
  110. package/src/astro/routes/api/auth/passkey/verify.ts +5 -1
  111. package/src/astro/routes/api/auth/signup/request.ts +20 -8
  112. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +3 -4
  113. package/src/astro/routes/api/content/[collection]/[id]/compare.ts +1 -1
  114. package/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts +16 -2
  115. package/src/astro/routes/api/content/[collection]/[id]/duplicate.ts +16 -0
  116. package/src/astro/routes/api/content/[collection]/[id]/permanent.ts +9 -0
  117. package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +1 -1
  118. package/src/astro/routes/api/content/[collection]/[id]/publish.ts +45 -1
  119. package/src/astro/routes/api/content/[collection]/[id]/restore.ts +12 -2
  120. package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +1 -1
  121. package/src/astro/routes/api/content/[collection]/[id]/schedule.ts +24 -0
  122. package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +3 -0
  123. package/src/astro/routes/api/content/[collection]/[id]/translations.ts +20 -0
  124. package/src/astro/routes/api/content/[collection]/[id]/unpublish.ts +13 -0
  125. package/src/astro/routes/api/content/[collection]/[id].ts +36 -0
  126. package/src/astro/routes/api/content/[collection]/index.ts +48 -4
  127. package/src/astro/routes/api/content/[collection]/trash.ts +1 -1
  128. package/src/astro/routes/api/health.ts +54 -0
  129. package/src/astro/routes/api/import/wordpress/analyze.ts +2 -10
  130. package/src/astro/routes/api/import/wordpress/execute.ts +40 -6
  131. package/src/astro/routes/api/import/wordpress/prepare.ts +36 -5
  132. package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +33 -1
  133. package/src/astro/routes/api/import/wordpress-plugin/analyze.ts +3 -3
  134. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +57 -15
  135. package/src/astro/routes/api/manifest.ts +13 -1
  136. package/src/astro/routes/api/mcp.ts +1 -0
  137. package/src/astro/routes/api/media/providers/[providerId]/[itemId].ts +7 -2
  138. package/src/astro/routes/api/media/upload-url.ts +11 -2
  139. package/src/astro/routes/api/media.ts +9 -7
  140. package/src/astro/routes/api/menus/[name]/items.ts +124 -5
  141. package/src/astro/routes/api/menus/[name]/reorder.ts +47 -1
  142. package/src/astro/routes/api/menus/[name].ts +84 -4
  143. package/src/astro/routes/api/menus/index.ts +46 -2
  144. package/src/astro/routes/api/oauth/authorize.ts +21 -8
  145. package/src/astro/routes/api/oauth/device/code.ts +2 -1
  146. package/src/astro/routes/api/oauth/device/token.ts +2 -1
  147. package/src/astro/routes/api/oauth/register.ts +182 -0
  148. package/src/astro/routes/api/oauth/token.ts +18 -7
  149. package/src/astro/routes/api/openapi.json.ts +3 -2
  150. package/src/astro/routes/api/plugins/[pluginId]/[...path].ts +21 -4
  151. package/src/astro/routes/api/redirects/[id].ts +103 -4
  152. package/src/astro/routes/api/redirects/index.ts +50 -2
  153. package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +28 -0
  154. package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +15 -0
  155. package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +13 -0
  156. package/src/astro/routes/api/schema/collections/[slug]/index.ts +27 -0
  157. package/src/astro/routes/api/schema/collections/index.ts +14 -0
  158. package/src/astro/routes/api/search/index.ts +1 -0
  159. package/src/astro/routes/api/search/suggest.ts +1 -0
  160. package/src/astro/routes/api/sections/[slug].ts +123 -4
  161. package/src/astro/routes/api/sections/index.ts +57 -2
  162. package/src/astro/routes/api/settings.ts +51 -2
  163. package/src/astro/routes/api/setup/admin-verify.ts +25 -5
  164. package/src/astro/routes/api/setup/admin.ts +16 -8
  165. package/src/astro/routes/api/setup/index.ts +3 -2
  166. package/src/astro/routes/api/taxonomies/[name]/terms/[slug].ts +141 -4
  167. package/src/astro/routes/api/taxonomies/[name]/terms/index.ts +64 -2
  168. package/src/astro/routes/api/taxonomies/index.ts +57 -2
  169. package/src/astro/routes/api/well-known/auth.ts +3 -1
  170. package/src/astro/routes/api/well-known/oauth-authorization-server.ts +8 -5
  171. package/src/astro/routes/api/well-known/oauth-protected-resource.ts +3 -2
  172. package/src/astro/routes/api/widget-areas/[name]/reorder.ts +58 -16
  173. package/src/astro/routes/api/widget-areas/[name]/widgets/[id].ts +124 -38
  174. package/src/astro/routes/api/widget-areas/[name]/widgets.ts +66 -20
  175. package/src/astro/routes/api/widget-areas/[name].ts +55 -7
  176. package/src/astro/routes/api/widget-areas/index.ts +56 -6
  177. package/src/components/DinewayHead.astro +15 -7
  178. package/src/components/DinewayMedia.astro +1 -1
  179. package/src/components/InlinePortableTextEditor.tsx +1 -1
  180. package/src/components/Table.astro +68 -41
  181. package/src/components/index.ts +2 -12
  182. package/src/components/marks.ts +19 -0
  183. package/LICENSE +0 -9
  184. /package/dist/{adapters-BlzWJG82.d.mts → adapters-C2ypTrZZ.d.mts} +0 -0
  185. /package/dist/{config-Cq8H0SfX.mjs → config-BXwuX8Bx.mjs} +0 -0
  186. /package/dist/{load-C6FCD1FU.mjs → load-Coc9HpHH.mjs} +0 -0
  187. /package/dist/{manifest-schema-CTSEyIJ3.mjs → manifest-schema-D1MSVnoI.mjs} +0 -0
  188. /package/dist/{mode-BlyYtIFO.mjs → mode-47goXBBK.mjs} +0 -0
  189. /package/dist/{tokens-4vgYuXsZ.mjs → tokens-CJz9ubV6.mjs} +0 -0
  190. /package/dist/{transport-C5FYnid7.mjs → transport-DB5eDN4x.mjs} +0 -0
  191. /package/dist/{transport-gIL-e43D.d.mts → transport-Wge_IzKl.d.mts} +0 -0
  192. /package/dist/{types-CLLdsG3g.d.mts → types-BzcUjoqg.d.mts} +0 -0
  193. /package/dist/{types-DShnjzb6.mjs → types-griIBQOQ.mjs} +0 -0
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Admin review requests
3
+ *
4
+ * GET /_dineway/api/admin/review-requests - List review requests
5
+ */
6
+
7
+ import type { APIRoute } from "astro";
8
+
9
+ import { requirePerm } from "#api/authorize.js";
10
+ import { handleError, requireDb, unwrapResult } from "#api/error.js";
11
+ import { handleReviewRequestList } from "#api/handlers/review-requests.js";
12
+ import { isParseError, parseQuery } from "#api/parse.js";
13
+ import { reviewRequestListQuery } from "#api/schemas.js";
14
+
15
+ export const prerender = false;
16
+
17
+ export const GET: APIRoute = async ({ url, locals }) => {
18
+ const { dineway, user } = locals;
19
+
20
+ const dbErr = requireDb(dineway?.db);
21
+ if (dbErr) return dbErr;
22
+
23
+ const denied = requirePerm(user, "content:edit_any");
24
+ if (denied) return denied;
25
+
26
+ try {
27
+ const query = parseQuery(url, reviewRequestListQuery);
28
+ if (isParseError(query)) return query;
29
+
30
+ const result = await handleReviewRequestList(dineway.db, query);
31
+ return unwrapResult(result);
32
+ } catch (error) {
33
+ return handleError(error, "Failed to list review requests", "REVIEW_REQUEST_LIST_ERROR");
34
+ }
35
+ };
@@ -9,6 +9,7 @@ import { createKyselyAdapter } from "@dineway-ai/auth/adapters/kysely";
9
9
  import type { APIRoute } from "astro";
10
10
 
11
11
  import { apiError, apiSuccess, handleError } from "#api/error.js";
12
+ import { withTransaction } from "#db/transaction.js";
12
13
 
13
14
  export const prerender = false;
14
15
 
@@ -23,8 +24,6 @@ export const POST: APIRoute = async ({ params, locals }) => {
23
24
  return apiError("FORBIDDEN", "Admin privileges required", 403);
24
25
  }
25
26
 
26
- const adapter = createKyselyAdapter(dineway.db);
27
-
28
27
  const { id } = params;
29
28
 
30
29
  if (!id) {
@@ -37,32 +36,36 @@ export const POST: APIRoute = async ({ params, locals }) => {
37
36
  }
38
37
 
39
38
  try {
40
- // Get target user
41
- const targetUser = await adapter.getUserById(id);
42
- if (!targetUser) {
43
- return apiError("NOT_FOUND", "User not found", 404);
44
- }
39
+ return await withTransaction(dineway.db, async (trx) => {
40
+ const adapter = createKyselyAdapter(trx);
41
+
42
+ // Get target user
43
+ const targetUser = await adapter.getUserById(id);
44
+ if (!targetUser) {
45
+ return apiError("NOT_FOUND", "User not found", 404);
46
+ }
45
47
 
46
- // Check if this would leave no active admins
47
- if (targetUser.role === Role.ADMIN) {
48
- const adminCount = await adapter.countAdmins();
49
- if (adminCount <= 1) {
50
- return apiError(
51
- "VALIDATION_ERROR",
52
- "Cannot disable the last admin. Promote another user first.",
53
- 400,
54
- );
48
+ // Check if this would leave no active admins
49
+ if (targetUser.role === Role.ADMIN) {
50
+ const adminCount = await adapter.countAdmins();
51
+ if (adminCount <= 1) {
52
+ return apiError(
53
+ "VALIDATION_ERROR",
54
+ "Cannot disable the last admin. Promote another user first.",
55
+ 400,
56
+ );
57
+ }
55
58
  }
56
- }
57
59
 
58
- // Disable user
59
- await adapter.updateUser(id, { disabled: true });
60
+ // Disable user
61
+ await adapter.updateUser(id, { disabled: true });
60
62
 
61
- // SEC-43: Revoke all OAuth tokens for the disabled user.
62
- // Without this, existing refresh tokens remain valid for up to 90 days.
63
- await dineway.db.deleteFrom("_dineway_oauth_tokens").where("user_id", "=", id).execute();
63
+ // SEC-43: Revoke all OAuth tokens for the disabled user.
64
+ // Without this, existing refresh tokens remain valid for up to 90 days.
65
+ await trx.deleteFrom("_dineway_oauth_tokens").where("user_id", "=", id).execute();
64
66
 
65
- return apiSuccess({ success: true });
67
+ return apiSuccess({ success: true });
68
+ });
66
69
  } catch (error) {
67
70
  return handleError(error, "Failed to disable user", "USER_DISABLE_ERROR");
68
71
  }
@@ -12,6 +12,7 @@ import type { APIRoute } from "astro";
12
12
  import { apiError, apiSuccess, handleError } from "#api/error.js";
13
13
  import { isParseError, parseBody } from "#api/parse.js";
14
14
  import { userUpdateBody } from "#api/schemas.js";
15
+ import { withTransaction } from "#db/transaction.js";
15
16
 
16
17
  export const prerender = false;
17
18
 
@@ -117,28 +118,47 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
117
118
  }
118
119
  }
119
120
 
120
- // Update user
121
- await adapter.updateUser(id, {
122
- name: body.name,
123
- email: body.email,
124
- role,
125
- });
121
+ return await withTransaction(dineway.db, async (trx) => {
122
+ const txAdapter = createKyselyAdapter(trx);
123
+ const latestTargetUser = await txAdapter.getUserById(id);
124
+ if (!latestTargetUser) {
125
+ return apiError("NOT_FOUND", "User not found", 404);
126
+ }
127
+
128
+ if (role !== undefined && latestTargetUser.role === Role.ADMIN && role < Role.ADMIN) {
129
+ const adminCount = await txAdapter.countAdmins();
130
+ if (adminCount <= 1) {
131
+ return apiError(
132
+ "VALIDATION_ERROR",
133
+ "Cannot demote the last admin. Promote another user first.",
134
+ 400,
135
+ );
136
+ }
137
+ }
126
138
 
127
- // Fetch updated user
128
- const updated = await adapter.getUserById(id);
129
-
130
- return apiSuccess({
131
- item: {
132
- id: updated!.id,
133
- email: updated!.email,
134
- name: updated!.name,
135
- avatarUrl: updated!.avatarUrl,
136
- role: updated!.role,
137
- emailVerified: updated!.emailVerified,
138
- disabled: updated!.disabled,
139
- createdAt: updated!.createdAt.toISOString(),
140
- updatedAt: updated!.updatedAt.toISOString(),
141
- },
139
+ // Update user
140
+ await txAdapter.updateUser(id, {
141
+ name: body.name,
142
+ email: body.email,
143
+ role,
144
+ });
145
+
146
+ // Fetch updated user
147
+ const updated = await txAdapter.getUserById(id);
148
+
149
+ return apiSuccess({
150
+ item: {
151
+ id: updated!.id,
152
+ email: updated!.email,
153
+ name: updated!.name,
154
+ avatarUrl: updated!.avatarUrl,
155
+ role: updated!.role,
156
+ emailVerified: updated!.emailVerified,
157
+ disabled: updated!.disabled,
158
+ createdAt: updated!.createdAt.toISOString(),
159
+ updatedAt: updated!.updatedAt.toISOString(),
160
+ },
161
+ });
142
162
  });
143
163
  } catch (error) {
144
164
  return handleError(error, "Failed to update user", "USER_UPDATE_ERROR");
@@ -0,0 +1,73 @@
1
+ /**
2
+ * POST /_dineway/api/auth/invite/register-options
3
+ *
4
+ * Generate WebAuthn registration options for an invited user.
5
+ */
6
+
7
+ import type { APIRoute } from "astro";
8
+
9
+ export const prerender = false;
10
+
11
+ import { validateInvite, InviteError } from "@dineway-ai/auth";
12
+ import { createKyselyAdapter } from "@dineway-ai/auth/adapters/kysely";
13
+ import { generateRegistrationOptions } from "@dineway-ai/auth/passkey";
14
+ import { ulid } from "ulidx";
15
+
16
+ import { apiError, apiSuccess, handleError } from "#api/error.js";
17
+ import { isParseError, parseBody } from "#api/parse.js";
18
+ import { getPublicOrigin } from "#api/public-url.js";
19
+ import { inviteRegisterOptionsBody } from "#api/schemas.js";
20
+ import { createChallengeStore } from "#auth/challenge-store.js";
21
+ import { getPasskeyConfig } from "#auth/passkey-config.js";
22
+ import { OptionsRepository } from "#db/repositories/options.js";
23
+
24
+ export const POST: APIRoute = async ({ request, locals }) => {
25
+ const { dineway } = locals;
26
+
27
+ if (!dineway?.db) {
28
+ return apiError("NOT_CONFIGURED", "Dineway is not initialized", 500);
29
+ }
30
+
31
+ try {
32
+ const body = await parseBody(request, inviteRegisterOptionsBody);
33
+ if (isParseError(body)) return body;
34
+
35
+ const adapter = createKyselyAdapter(dineway.db);
36
+ const invite = await validateInvite(adapter, body.token);
37
+
38
+ const url = new URL(request.url);
39
+ const optionsRepo = new OptionsRepository(dineway.db);
40
+ const siteName = (await optionsRepo.get<string>("dineway:site_title")) ?? undefined;
41
+ const siteUrl = getPublicOrigin(url, dineway?.config);
42
+ const passkeyConfig = getPasskeyConfig(url, siteName, siteUrl);
43
+
44
+ const challengeStore = createChallengeStore(dineway.db);
45
+ const registrationOptions = await generateRegistrationOptions(
46
+ passkeyConfig,
47
+ {
48
+ id: ulid(),
49
+ email: invite.email,
50
+ name: body.name || null,
51
+ },
52
+ [],
53
+ challengeStore,
54
+ );
55
+
56
+ return apiSuccess({ options: registrationOptions });
57
+ } catch (error) {
58
+ if (error instanceof InviteError) {
59
+ const statusMap: Record<string, number> = {
60
+ invalid_token: 404,
61
+ token_expired: 410,
62
+ user_exists: 409,
63
+ };
64
+ return apiError(error.code.toUpperCase(), error.message, statusMap[error.code] ?? 400);
65
+ }
66
+
67
+ return handleError(
68
+ error,
69
+ "Failed to generate registration options",
70
+ "INVITE_REGISTER_OPTIONS_ERROR",
71
+ );
72
+ }
73
+ };
@@ -19,6 +19,7 @@ import { isParseError, parseBody } from "#api/parse.js";
19
19
  import { magicLinkSendBody } from "#api/schemas.js";
20
20
  import { getSiteBaseUrl } from "#api/site-url.js";
21
21
  import { checkRateLimit, getClientIp } from "#auth/rate-limit.js";
22
+ import { getTrustedProxyHeaders } from "#auth/trusted-proxy.js";
22
23
  import { OptionsRepository } from "#db/repositories/options.js";
23
24
 
24
25
  export const POST: APIRoute = async ({ request, locals }) => {
@@ -36,7 +37,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
36
37
  if (isParseError(body)) return body;
37
38
 
38
39
  // Rate limit: 3 requests per 300 seconds (5 minutes) per IP
39
- const ip = getClientIp(request);
40
+ const ip = getClientIp(request, getTrustedProxyHeaders(dineway.config));
40
41
  const rateLimit = await checkRateLimit(dineway.db, ip, "magic-link/send", 3, 300);
41
42
  if (!rateLimit.allowed) {
42
43
  // Return success-shaped response to avoid revealing rate limit
@@ -20,6 +20,7 @@ import { passkeyOptionsBody } from "#api/schemas.js";
20
20
  import { createChallengeStore, cleanupExpiredChallenges } from "#auth/challenge-store.js";
21
21
  import { getPasskeyConfig } from "#auth/passkey-config.js";
22
22
  import { checkRateLimit, getClientIp, rateLimitResponse } from "#auth/rate-limit.js";
23
+ import { getTrustedProxyHeaders } from "#auth/trusted-proxy.js";
23
24
  import { OptionsRepository } from "#db/repositories/options.js";
24
25
 
25
26
  export const POST: APIRoute = async ({ request, locals }) => {
@@ -38,7 +39,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
38
39
  if (isParseError(body)) return body;
39
40
 
40
41
  // Rate limit: 10 requests per 60 seconds per IP
41
- const ip = getClientIp(request);
42
+ const ip = getClientIp(request, getTrustedProxyHeaders(dineway.config));
42
43
  const rateLimit = await checkRateLimit(dineway.db, ip, "passkey/options", 10, 60);
43
44
  if (!rateLimit.allowed) {
44
45
  return rateLimitResponse(60);
@@ -9,7 +9,7 @@ import type { APIRoute } from "astro";
9
9
  export const prerender = false;
10
10
 
11
11
  import { createKyselyAdapter } from "@dineway-ai/auth/adapters/kysely";
12
- import { authenticateWithPasskey } from "@dineway-ai/auth/passkey";
12
+ import { authenticateWithPasskey, PasskeyAuthenticationError } from "@dineway-ai/auth/passkey";
13
13
 
14
14
  import { apiError, apiSuccess, handleError } from "#api/error.js";
15
15
  import { isParseError, parseBody } from "#api/parse.js";
@@ -63,6 +63,10 @@ export const POST: APIRoute = async ({ request, locals, session }) => {
63
63
  },
64
64
  });
65
65
  } catch (error) {
66
+ if (error instanceof PasskeyAuthenticationError) {
67
+ return apiError("UNAUTHORIZED", "Authentication failed", 401);
68
+ }
69
+
66
70
  return handleError(error, "Authentication failed", "PASSKEY_VERIFY_ERROR");
67
71
  }
68
72
  };
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * Request self-signup. Sends verification email if domain is allowed.
5
5
  * Always returns 200 to prevent email enumeration.
6
+ *
7
+ * Rate limited: 3 requests per 5 minutes per IP. Mirrors magic-link/send.
6
8
  */
7
9
 
8
10
  import type { APIRoute } from "astro";
@@ -16,8 +18,15 @@ import { apiError, apiSuccess } from "#api/error.js";
16
18
  import { isParseError, parseBody } from "#api/parse.js";
17
19
  import { signupRequestBody } from "#api/schemas.js";
18
20
  import { getSiteBaseUrl } from "#api/site-url.js";
21
+ import { checkRateLimit, getClientIp } from "#auth/rate-limit.js";
22
+ import { getTrustedProxyHeaders } from "#auth/trusted-proxy.js";
19
23
  import { OptionsRepository } from "#db/repositories/options.js";
20
24
 
25
+ const GENERIC_SUCCESS = {
26
+ success: true,
27
+ message: "If your email domain is allowed, you'll receive a verification email.",
28
+ };
29
+
21
30
  export const POST: APIRoute = async ({ request, locals }) => {
22
31
  const { dineway } = locals;
23
32
 
@@ -38,6 +47,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
38
47
  const body = await parseBody(request, signupRequestBody);
39
48
  if (isParseError(body)) return body;
40
49
 
50
+ // Rate limit: 3 requests per 300 seconds per IP. Return the same
51
+ // response as the normal path so callers cannot observe the limit.
52
+ const trustedHeaders = getTrustedProxyHeaders(dineway.config);
53
+ const ip = getClientIp(request, trustedHeaders);
54
+ const rateLimit = await checkRateLimit(dineway.db, ip, "signup/request", 3, 300);
55
+ if (!rateLimit.allowed) {
56
+ return apiSuccess(GENERIC_SUCCESS);
57
+ }
58
+
41
59
  const adapter = createKyselyAdapter(dineway.db);
42
60
 
43
61
  // Get site config for signup email
@@ -60,18 +78,12 @@ export const POST: APIRoute = async ({ request, locals }) => {
60
78
  );
61
79
 
62
80
  // Always return success to prevent email enumeration
63
- return apiSuccess({
64
- success: true,
65
- message: "If your email domain is allowed, you'll receive a verification email.",
66
- });
81
+ return apiSuccess(GENERIC_SUCCESS);
67
82
  } catch (error) {
68
83
  console.error("Signup request error:", error);
69
84
 
70
85
  // Don't reveal internal errors - just return generic success
71
86
  // to prevent information leakage
72
- return apiSuccess({
73
- success: true,
74
- message: "If your email domain is allowed, you'll receive a verification email.",
75
- });
87
+ return apiSuccess(GENERIC_SUCCESS);
76
88
  }
77
89
  };
@@ -15,6 +15,7 @@ import { getSiteBaseUrl } from "#api/site-url.js";
15
15
  import { sendCommentNotification } from "#comments/notifications.js";
16
16
  import { createComment, type CommentHookRunner } from "#comments/service.js";
17
17
  import { CommentRepository } from "#db/repositories/comment.js";
18
+ import { validateIdentifier } from "#db/validate.js";
18
19
  import { extractRequestMeta } from "#plugins/request-meta.js";
19
20
  import type { CollectionCommentSettings, ModerationDecision } from "#plugins/types.js";
20
21
 
@@ -106,6 +107,7 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
106
107
  }
107
108
 
108
109
  // Verify the content item exists, is published, and not soft-deleted
110
+ validateIdentifier(collection, "collection");
109
111
  const contentRow = await dineway.db
110
112
  .selectFrom(`ec_${collection}` as never)
111
113
  .select(["id" as never, "slug" as never, "author_id" as never, "published_at" as never])
@@ -137,15 +139,12 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
137
139
  }
138
140
 
139
141
  // Anti-spam: Rate limiting
140
- const meta = extractRequestMeta(request);
142
+ const meta = extractRequestMeta(request, dineway.config);
141
143
  const ipSalt =
142
144
  import.meta.env.DINEWAY_AUTH_SECRET || import.meta.env.AUTH_SECRET || "dineway-ip-salt";
143
145
  let ipHash: string;
144
146
  if (meta.ip) {
145
147
  ipHash = await hashIp(meta.ip, ipSalt);
146
- } else if (meta.userAgent) {
147
- // Fallback: hash user-agent as a rough identifier when IP is unavailable
148
- ipHash = await hashIp(`ua:${meta.userAgent}`, ipSalt);
149
148
  } else {
150
149
  // Fail closed: all unidentifiable requests share one rate-limit bucket.
151
150
  // Use a larger limit since this bucket is shared across all anonymous users.
@@ -13,7 +13,7 @@ export const prerender = false;
13
13
 
14
14
  export const GET: APIRoute = async ({ params, locals }) => {
15
15
  const { dineway, user } = locals;
16
- const denied = requirePerm(user, "content:read");
16
+ const denied = requirePerm(user, "content:read_drafts");
17
17
  if (denied) return denied;
18
18
  const collection = params.collection!;
19
19
  const id = params.id!;
@@ -8,6 +8,11 @@ import type { APIRoute } from "astro";
8
8
 
9
9
  import { requireOwnerPerm } from "#api/authorize.js";
10
10
  import { apiError, mapErrorStatus, unwrapResult } from "#api/error.js";
11
+ import {
12
+ contentApiRouteSource,
13
+ extractActivityItemId,
14
+ logContentActivity,
15
+ } from "#site-context/activity-events.js";
11
16
 
12
17
  export const prerender = false;
13
18
 
@@ -43,12 +48,21 @@ export const POST: APIRoute = async ({ params, locals, cache }) => {
43
48
  const authorId = typeof existingItem?.authorId === "string" ? existingItem.authorId : "";
44
49
  const denied = requireOwnerPerm(user, authorId, "content:edit_own", "content:edit_any");
45
50
  if (denied) return denied;
51
+ const resolvedId = typeof existingItem?.id === "string" ? existingItem.id : id;
46
52
 
47
- const result = await dineway.handleContentDiscardDraft(collection, id);
53
+ const result = await dineway.handleContentDiscardDraft(collection, resolvedId);
48
54
 
49
55
  if (!result.success) return unwrapResult(result);
50
56
 
51
- if (cache.enabled) await cache.invalidate({ tags: [collection, id] });
57
+ if (cache.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
58
+
59
+ await logContentActivity(dineway.db, locals, {
60
+ action: "draft_discarded",
61
+ collection,
62
+ entryId: extractActivityItemId(result.data, resolvedId),
63
+ ...contentApiRouteSource("draft_discarded"),
64
+ summary: `Discarded draft for content in ${collection}`,
65
+ });
52
66
 
53
67
  return unwrapResult(result);
54
68
  };
@@ -8,6 +8,11 @@ import type { APIRoute } from "astro";
8
8
 
9
9
  import { requirePerm, requireOwnerPerm } from "#api/authorize.js";
10
10
  import { apiError, mapErrorStatus, unwrapResult } from "#api/error.js";
11
+ import {
12
+ contentApiRouteSource,
13
+ extractActivityItemId,
14
+ logContentActivity,
15
+ } from "#site-context/activity-events.js";
11
16
 
12
17
  export const prerender = false;
13
18
 
@@ -57,5 +62,16 @@ export const POST: APIRoute = async ({ params, locals, cache }) => {
57
62
 
58
63
  if (cache.enabled) await cache.invalidate({ tags: [collection] });
59
64
 
65
+ await logContentActivity(dineway.db, locals, {
66
+ action: "duplicated",
67
+ collection,
68
+ entryId: extractActivityItemId(result.data),
69
+ ...contentApiRouteSource("duplicated"),
70
+ summary: `Duplicated content in ${collection}`,
71
+ detail: {
72
+ sourceEntryId: resolvedId,
73
+ },
74
+ });
75
+
60
76
  return unwrapResult(result, 201);
61
77
  };
@@ -8,6 +8,7 @@ import type { APIRoute } from "astro";
8
8
 
9
9
  import { requirePerm } from "#api/authorize.js";
10
10
  import { apiError, unwrapResult } from "#api/error.js";
11
+ import { contentApiRouteSource, logContentActivity } from "#site-context/activity-events.js";
11
12
 
12
13
  export const prerender = false;
13
14
 
@@ -29,5 +30,13 @@ export const DELETE: APIRoute = async ({ params, locals, cache }) => {
29
30
 
30
31
  if (cache.enabled) await cache.invalidate({ tags: [collection, id] });
31
32
 
33
+ await logContentActivity(dineway.db, locals, {
34
+ action: "permanently_deleted",
35
+ collection,
36
+ entryId: id,
37
+ ...contentApiRouteSource("permanently_deleted"),
38
+ summary: `Permanently deleted content in ${collection}`,
39
+ });
40
+
32
41
  return unwrapResult(result);
33
42
  };
@@ -30,7 +30,7 @@ const DURATION_PATTERN = /^(\d+)([smhdw])$/;
30
30
 
31
31
  export const POST: APIRoute = async ({ params, request, locals }) => {
32
32
  const { dineway, user } = locals;
33
- const denied = requirePerm(user, "content:read");
33
+ const denied = requirePerm(user, "content:read_drafts");
34
34
  if (denied) return denied;
35
35
  const collection = params.collection!;
36
36
  const id = params.id!;
@@ -8,10 +8,18 @@ import type { APIRoute } from "astro";
8
8
 
9
9
  import { requireOwnerPerm } from "#api/authorize.js";
10
10
  import { apiError, mapErrorStatus, unwrapResult } from "#api/error.js";
11
+ import { isParseError, parseOptionalBody } from "#api/parse.js";
12
+ import { contentPublishBody } from "#api/schemas.js";
13
+ import {
14
+ contentApiRouteSource,
15
+ extractActivityItemId,
16
+ logContentActivity,
17
+ } from "#site-context/activity-events.js";
18
+ import { resolveActorIdentity, RiskPolicyEvaluator } from "#site-context/index.js";
11
19
 
12
20
  export const prerender = false;
13
21
 
14
- export const POST: APIRoute = async ({ params, locals, cache }) => {
22
+ export const POST: APIRoute = async ({ params, request, locals, cache }) => {
15
23
  const { dineway, user } = locals;
16
24
  const collection = params.collection!;
17
25
  const id = params.id!;
@@ -45,6 +53,31 @@ export const POST: APIRoute = async ({ params, locals, cache }) => {
45
53
  if (denied) return denied;
46
54
 
47
55
  const resolvedId = typeof existingItem?.id === "string" ? existingItem.id : id;
56
+ const body = await parseOptionalBody(request, contentPublishBody, {});
57
+ if (isParseError(body)) return body;
58
+
59
+ const actor = resolveActorIdentity({
60
+ user: user ? { id: user.id } : null,
61
+ tokenScopes: locals.tokenScopes,
62
+ authToken: locals.authToken,
63
+ });
64
+ const evaluator = new RiskPolicyEvaluator({
65
+ db: dineway.db,
66
+ handlers: dineway,
67
+ });
68
+ const reviewRequestId = body.reviewRequestId ?? body.review_request_id;
69
+ const decision = await evaluator.evaluateContentPublishReview({
70
+ actor,
71
+ collection,
72
+ id: resolvedId,
73
+ reviewRequestId,
74
+ });
75
+ if (!decision.allowed) {
76
+ return apiError("REVIEW_REQUEST_REQUIRED", decision.message, 409, {
77
+ reason: decision.reason,
78
+ target: decision.target ?? null,
79
+ });
80
+ }
48
81
 
49
82
  const result = await dineway.handleContentPublish(collection, resolvedId);
50
83
 
@@ -52,5 +85,16 @@ export const POST: APIRoute = async ({ params, locals, cache }) => {
52
85
 
53
86
  if (cache.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
54
87
 
88
+ await logContentActivity(dineway.db, locals, {
89
+ action: "published",
90
+ collection,
91
+ entryId: extractActivityItemId(result.data, resolvedId),
92
+ ...contentApiRouteSource("published"),
93
+ summary: `Published content in ${collection}`,
94
+ detail: {
95
+ reviewRequestId: decision.required ? decision.reviewRequest.id : null,
96
+ },
97
+ });
98
+
55
99
  return unwrapResult(result);
56
100
  };
@@ -8,6 +8,7 @@ import type { APIRoute } from "astro";
8
8
 
9
9
  import { requireOwnerPerm } from "#api/authorize.js";
10
10
  import { apiError, mapErrorStatus, unwrapResult } from "#api/error.js";
11
+ import { contentApiRouteSource, logContentActivity } from "#site-context/activity-events.js";
11
12
 
12
13
  export const prerender = false;
13
14
 
@@ -43,12 +44,21 @@ export const POST: APIRoute = async ({ params, locals, cache }) => {
43
44
  const authorId = typeof existingItem?.authorId === "string" ? existingItem.authorId : "";
44
45
  const denied = requireOwnerPerm(user, authorId, "content:edit_own", "content:edit_any");
45
46
  if (denied) return denied;
47
+ const resolvedId = typeof existingItem?.id === "string" ? existingItem.id : id;
46
48
 
47
- const result = await dineway.handleContentRestore(collection, id);
49
+ const result = await dineway.handleContentRestore(collection, resolvedId);
48
50
 
49
51
  if (!result.success) return unwrapResult(result);
50
52
 
51
- if (cache.enabled) await cache.invalidate({ tags: [collection, id] });
53
+ if (cache.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
54
+
55
+ await logContentActivity(dineway.db, locals, {
56
+ action: "restored",
57
+ collection,
58
+ entryId: resolvedId,
59
+ ...contentApiRouteSource("restored"),
60
+ summary: `Restored content in ${collection}`,
61
+ });
52
62
 
53
63
  return unwrapResult(result);
54
64
  };
@@ -13,7 +13,7 @@ export const prerender = false;
13
13
 
14
14
  export const GET: APIRoute = async ({ params, url, locals }) => {
15
15
  const { dineway, user } = locals;
16
- const denied = requirePerm(user, "content:read");
16
+ const denied = requirePerm(user, "content:read_drafts");
17
17
  if (denied) return denied;
18
18
  const collection = params.collection!;
19
19
  const id = params.id!;