@lovelybunch/api 1.0.77-alpha.0 → 1.0.77-alpha.2

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 (154) hide show
  1. package/dist/config/oauth.d.ts +33 -0
  2. package/dist/config/oauth.js +43 -0
  3. package/dist/lib/auth/auth-manager.d.ts +21 -1
  4. package/dist/lib/auth/auth-manager.js +57 -3
  5. package/dist/lib/auth/clerk-verifier.d.ts +22 -0
  6. package/dist/lib/auth/clerk-verifier.js +39 -0
  7. package/dist/middleware/auth.js +40 -3
  8. package/dist/routes/api/v1/auth/route.js +94 -0
  9. package/dist/routes/api/v1/auth-settings/route.js +83 -2
  10. package/dist/routes/api/v1/jobs/[id]/route.d.ts +20 -20
  11. package/dist/routes/api/v1/jobs/route.d.ts +20 -20
  12. package/dist/routes/api/v1/slack/route.d.ts +6 -6
  13. package/dist/server-with-static.js +13 -0
  14. package/dist/server.js +11 -0
  15. package/package.json +5 -4
  16. package/static/assets/{ActivityPage-rASRHKYj.js → ActivityPage-BrpfoR1x.js} +1 -1
  17. package/static/assets/{AgentsContextEditPage-CMWD-7mS.js → AgentsContextEditPage-DD96j1Gu.js} +1 -1
  18. package/static/assets/AgentsContextPage-DbGPK_nF.js +1 -0
  19. package/static/assets/{ApiKeysSettingsPage-DxtuyXwn.js → ApiKeysSettingsPage-DCRw8BVU.js} +2 -2
  20. package/static/assets/AuthSettingsPage-BZcuolzF.js +11 -0
  21. package/static/assets/{CallbackPage-CjlTVuif.js → CallbackPage-j_Z5gQid.js} +1 -1
  22. package/static/assets/CoconutCallbackPage-ZEdMiq56.js +1 -0
  23. package/static/assets/CodePage-LOMEoK8O.js +2 -0
  24. package/static/assets/{CollapsibleSection-LrQJlSxD.js → CollapsibleSection-iVC3bo9u.js} +1 -1
  25. package/static/assets/{DashboardPage-DnyFyDO0.js → DashboardPage-CAxisL8l.js} +1 -1
  26. package/static/assets/{GitPage-o37iQMYQ.js → GitPage-BzVIV2mo.js} +2 -2
  27. package/static/assets/{GitSettingsPage-O0fe5kxm.js → GitSettingsPage-CKWWrG0f.js} +2 -2
  28. package/static/assets/{IdentityPage-KWjruliO.js → IdentityPage-IVZ-nvh_.js} +2 -2
  29. package/static/assets/{ImplementationStepsEditor-h_Mxu1tF.js → ImplementationStepsEditor-IzyUL2cs.js} +1 -1
  30. package/static/assets/IntegrationsSettingsPage-DvzES04i.js +1 -0
  31. package/static/assets/JobDetailPage-DVKKQ1j7.js +1 -0
  32. package/static/assets/KnowledgeDetailPage-jdJGqZT9.js +1 -0
  33. package/static/assets/{KnowledgeEditPage-DrdvEGuR.js → KnowledgeEditPage-C60yLCQf.js} +1 -1
  34. package/static/assets/{KnowledgePage-CK5rtZcv.js → KnowledgePage-B3G3eWhs.js} +2 -2
  35. package/static/assets/LoginPage-DXRpkMQE.js +1 -0
  36. package/static/assets/{MailInboxPage-CewILL7o.js → MailInboxPage-D4F-yUb2.js} +1 -1
  37. package/static/assets/MailProcessingModal-C5TbdlsX.js +1 -0
  38. package/static/assets/{MailReadPage--LnQ743i.js → MailReadPage-Bn6Zw8xJ.js} +1 -1
  39. package/static/assets/{MailSentPage-BqO4iBq6.js → MailSentPage-Bk3jJOzz.js} +1 -1
  40. package/static/assets/McpSettingsPage-D2UXUhK2.js +1 -0
  41. package/static/assets/{MemoryEditPage-BLfdKEDu.js → MemoryEditPage-fFwS44vf.js} +1 -1
  42. package/static/assets/MemoryPage-BTOJoF-z.js +1 -0
  43. package/static/assets/{NewKnowledgePage-SrBhXpH6.js → NewKnowledgePage-X86yjTVl.js} +1 -1
  44. package/static/assets/{NewSkillPage-vt-0rIkv.js → NewSkillPage-e8dGvY9r.js} +1 -1
  45. package/static/assets/{NewTaskPage-CBUpwIFP.js → NewTaskPage-DhUlLdQA.js} +2 -2
  46. package/static/assets/{NotFoundPage-CWF5qOC0.js → NotFoundPage-BjIfj-6G.js} +1 -1
  47. package/static/assets/{NotificationsSettingsPage-3ZvMrqiq.js → NotificationsSettingsPage-YWyp3_fq.js} +1 -1
  48. package/static/assets/PromptsSettingsPage-DS-7Hcyy.js +1 -0
  49. package/static/assets/{ResourceDetailPage-CE4iDbv7.js → ResourceDetailPage-5lxrzduZ.js} +1 -1
  50. package/static/assets/{ResourcesPage-4_5NA4RF.js → ResourcesPage-5e8iv04j.js} +1 -1
  51. package/static/assets/{RoleEditPage-4sSELIRX.js → RoleEditPage-CZl5kzLm.js} +1 -1
  52. package/static/assets/RolePage-DH1XSfmN.js +1 -0
  53. package/static/assets/{RulesSettingsPage-DnyGaden.js → RulesSettingsPage-CfHyX5-L.js} +4 -4
  54. package/static/assets/{RunDetailPage-D2ajvKfh.js → RunDetailPage-CKvjq6lg.js} +1 -1
  55. package/static/assets/SchedulePage-DK3H_i1s.js +4 -0
  56. package/static/assets/SkillDetailPage-K_CkFQv-.js +1 -0
  57. package/static/assets/{SkillEditPage-DeI8uu3S.js → SkillEditPage-2X3bZgJ0.js} +1 -1
  58. package/static/assets/{SkillsPage-Bb_dEdun.js → SkillsPage-Ba-7nPAX.js} +2 -2
  59. package/static/assets/{SkillsSettingsPage-CMWf2O9y.js → SkillsSettingsPage-w64_WHIP.js} +1 -1
  60. package/static/assets/{SourceInput-CtyUOXOG.js → SourceInput-I-hamu6Q.js} +1 -1
  61. package/static/assets/{TagInput-C0NyMxlY.js → TagInput-DEIDoXAF.js} +1 -1
  62. package/static/assets/{TaskDetailPage-6DDI0juh.js → TaskDetailPage-CIzKy_lB.js} +2 -2
  63. package/static/assets/{TaskEditPage-Btc1NXtR.js → TaskEditPage-DE5iZOlS.js} +1 -1
  64. package/static/assets/{TasksPage-C9TYvqRz.js → TasksPage-XJv3kCi4.js} +1 -1
  65. package/static/assets/{TeamEditPage-CLvNy6Ss.js → TeamEditPage-BJYjNG9s.js} +1 -1
  66. package/static/assets/TeamPage-8HQl8tJg.js +1 -0
  67. package/static/assets/{TerminalPage-DNDHEYJZ.js → TerminalPage-1wX_kSZq.js} +1 -1
  68. package/static/assets/{TerminalSessionPage-nNCw_oDE.js → TerminalSessionPage-C9DF64yx.js} +2 -2
  69. package/static/assets/{UserPreferencesPage-jc1SA79x.js → UserPreferencesPage-soBs08Yv.js} +1 -1
  70. package/static/assets/{UserSettingsPage-pr6n15Pz.js → UserSettingsPage-oGRL6qzb.js} +1 -1
  71. package/static/assets/UtilitiesPage-CtZZsdkO.js +1 -0
  72. package/static/assets/{alert-BtkLXQ3p.js → alert-yfoaxi15.js} +1 -1
  73. package/static/assets/{arrow-down-DnNLmXEs.js → arrow-down-CmC8SDMn.js} +1 -1
  74. package/static/assets/{arrow-left-B9NBHEkS.js → arrow-left-C3PN_VXn.js} +1 -1
  75. package/static/assets/{arrow-up-eMUJY7J9.js → arrow-up-C2_FqbAf.js} +1 -1
  76. package/static/assets/{arrow-up-down-DIuMvAne.js → arrow-up-down-BxszeBWu.js} +1 -1
  77. package/static/assets/{badge-BbfU_aPt.js → badge-CfOS4AEh.js} +1 -1
  78. package/static/assets/{browser-modal-J9l3o5os.js → browser-modal-CohGkjjX.js} +2 -2
  79. package/static/assets/{card-CL5bB4cs.js → card-DBbETqHr.js} +1 -1
  80. package/static/assets/{chevron-left-B-7K6MNx.js → chevron-left-CARf1vQR.js} +1 -1
  81. package/static/assets/{chevron-up-BwTmMEW2.js → chevron-up-DPqLVVFe.js} +1 -1
  82. package/static/assets/{chevrons-up-jI6nxhrz.js → chevrons-up-BJsTBH4G.js} +1 -1
  83. package/static/assets/{circle-alert-vJFSz39V.js → circle-alert-DvY6UCZI.js} +1 -1
  84. package/static/assets/{circle-check-D2LVHMl-.js → circle-check-D4I1hq5t.js} +1 -1
  85. package/static/assets/{circle-check-big-BwQ_q1N7.js → circle-check-big-DXkEigqp.js} +1 -1
  86. package/static/assets/{circle-play-CLKDBkrK.js → circle-play-Z-LH2XZF.js} +1 -1
  87. package/static/assets/{circle-x-CuUVLiI-.js → circle-x-Dh-KgkYg.js} +1 -1
  88. package/static/assets/{clipboard-BHOFelnW.js → clipboard-CME-lfxG.js} +1 -1
  89. package/static/assets/{clock-D-X3KCw6.js → clock-CzUgbpxH.js} +1 -1
  90. package/static/assets/{code-DqYaanki.js → code-Bf5KtxD0.js} +1 -1
  91. package/static/assets/{download-DeEju9jg.js → download-RyS06A9x.js} +1 -1
  92. package/static/assets/{external-link-CrRz0sU-.js → external-link-BUmaJn8N.js} +1 -1
  93. package/static/assets/{eye-Cx9ZGkg5.js → eye-B6H1Wnna.js} +1 -1
  94. package/static/assets/{folder-git-2-Cizv1NA6.js → folder-git-2-GfrcYXqB.js} +1 -1
  95. package/static/assets/{globe-lpD9Jv31.js → globe-DGJd-BEz.js} +1 -1
  96. package/static/assets/{index-CkT4WgqR.js → index--3ISLTsV.js} +1 -1
  97. package/static/assets/{index-BvZJRqTz.js → index--Syi3rI6.js} +1 -1
  98. package/static/assets/{index-Dc9Njo8L.js → index-2KcfNqV-.js} +1 -1
  99. package/static/assets/{index-DK1gGyTZ.js → index-3bHY1RZf.js} +1 -1
  100. package/static/assets/{index-BL-5Bhtg.js → index-7i8CPi9F.js} +1 -1
  101. package/static/assets/{index-Hy3cH93B.js → index-B2zWPdyi.js} +1 -1
  102. package/static/assets/{index-BCYTbJRb.js → index-BCEWTKM2.js} +1 -1
  103. package/static/assets/{index-CMaK0hpv.js → index-BMfWq2j7.js} +1 -1
  104. package/static/assets/{index-Djzp98Vj.js → index-BMooEqjV.js} +1 -1
  105. package/static/assets/{index-ihWq-CVE.js → index-BMtEpMwG.js} +1 -1
  106. package/static/assets/{index-DgAcL75U.js → index-D7MExzMq.js} +1 -1
  107. package/static/assets/{index-BPdWQ0rI.js → index-D7veMECT.js} +1 -1
  108. package/static/assets/{index-ByTA2ZiD.js → index-DQyKDJLd.js} +105 -105
  109. package/static/assets/{index-Dnj5cWsp.js → index-DztTpy2B.js} +1 -1
  110. package/static/assets/{index-D12O6wM3.js → index-Oe08b-EX.js} +1 -1
  111. package/static/assets/{index-DIt703WU.js → index-ctTeh4My.js} +1 -1
  112. package/static/assets/{index-BVrOTqTm.js → index-kIIlr6Bh.js} +1 -1
  113. package/static/assets/{index-CuLP7P_G.js → index-sRKyM1Il.js} +1 -1
  114. package/static/assets/{index-BknCCMZK.js → index-vmtCSD0U.js} +1 -1
  115. package/static/assets/{info-DxhTCbw1.js → info-CHiTEVvq.js} +1 -1
  116. package/static/assets/{label-Dp0-28_O.js → label-DZN-TTmJ.js} +1 -1
  117. package/static/assets/{markdown-editor-DUmrf1eN.js → markdown-editor-CjtUzCZF.js} +38 -38
  118. package/static/assets/{message-square-BPOApeM3.js → message-square-D8KMCL9-.js} +1 -1
  119. package/static/assets/{paperclip-Bfjc1WLZ.js → paperclip-CsjIUex0.js} +1 -1
  120. package/static/assets/{pause-Bfz-QQZp.js → pause-DyIY-Fg6.js} +1 -1
  121. package/static/assets/{play-fOwEoIcu.js → play-BsqG0Xxh.js} +1 -1
  122. package/static/assets/{radio-group-B8RB7N01.js → radio-group-Cd6D1zWl.js} +1 -1
  123. package/static/assets/{refresh-cw-Cj_5MZiJ.js → refresh-cw-DvA7rcDs.js} +1 -1
  124. package/static/assets/{search-BgbqRUnf.js → search-BtvwgzIM.js} +1 -1
  125. package/static/assets/{select-CfwLZl55.js → select-BTiepyTe.js} +1 -1
  126. package/static/assets/{server-u9FLHclt.js → server-DjyRLssO.js} +1 -1
  127. package/static/assets/{switch-CX_Inx_p.js → switch-QFsCjFLp.js} +1 -1
  128. package/static/assets/{tabs-Bj0YyeRI.js → tabs-BQcOvpJ3.js} +1 -1
  129. package/static/assets/{tag-rYG4CdRx.js → tag-ChBxeRYs.js} +1 -1
  130. package/static/assets/{terminal-preview-BNm5-Umi.js → terminal-preview-Ch9841_B.js} +1 -1
  131. package/static/assets/{triangle-alert-C0ovMJwZ.js → triangle-alert-Dj9MnxX5.js} +1 -1
  132. package/static/assets/{use-terminal-D1UnvAVs.js → use-terminal-CJ3EJhYK.js} +1 -1
  133. package/static/assets/{video-CuyRES-H.js → video-DHNX2nx5.js} +1 -1
  134. package/static/index.html +1 -1
  135. package/dist/routes/api/v1/context/architecture/route.d.ts +0 -3
  136. package/dist/routes/api/v1/context/architecture/route.js +0 -245
  137. package/dist/routes/api/v1/context/project/route.d.ts +0 -3
  138. package/dist/routes/api/v1/context/project/route.js +0 -200
  139. package/static/assets/AgentsContextPage-VfMmxxr6.js +0 -1
  140. package/static/assets/AuthSettingsPage-CWTpS6BA.js +0 -11
  141. package/static/assets/CodePage-DwjtGxQi.js +0 -2
  142. package/static/assets/IntegrationsSettingsPage-DeFQd6af.js +0 -1
  143. package/static/assets/JobDetailPage-HOLpF7Sx.js +0 -1
  144. package/static/assets/KnowledgeDetailPage-C5egPQ54.js +0 -1
  145. package/static/assets/LoginPage-DajEouCN.js +0 -1
  146. package/static/assets/MailProcessingModal-B1ZLXyYl.js +0 -1
  147. package/static/assets/McpSettingsPage-BZ6GJOtk.js +0 -1
  148. package/static/assets/MemoryPage-BV0RlvUS.js +0 -1
  149. package/static/assets/PromptsSettingsPage-pHKoiUPI.js +0 -1
  150. package/static/assets/RolePage-Ds17xn4X.js +0 -1
  151. package/static/assets/SchedulePage-oEGct1B1.js +0 -4
  152. package/static/assets/SkillDetailPage-mSypV_JK.js +0 -1
  153. package/static/assets/TeamPage-DSWax4fa.js +0 -1
  154. package/static/assets/UtilitiesPage-CjLb989o.js +0 -1
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Clerk OAuth configuration.
3
+ *
4
+ * Coconut currently has a single control plane, so these values are public
5
+ * (non-secret) constants. If a second control plane is ever introduced, move
6
+ * these to environment variables.
7
+ *
8
+ * Kept in sync with packages/cli/src/commands/serve.ts (startup banner).
9
+ */
10
+ import type { AuthConfig } from '@lovelybunch/types';
11
+ export declare const CLERK_JWKS_URL = "https://clerk.coconut.dev/.well-known/jwks.json";
12
+ export declare const CLERK_ISSUER = "https://clerk.coconut.dev";
13
+ export interface OAuthRuntimeConfig {
14
+ enabled: boolean;
15
+ clientId?: string;
16
+ }
17
+ export declare function setOAuthRuntimeConfig(config: OAuthRuntimeConfig): void;
18
+ export declare function getOAuthRuntimeConfig(): OAuthRuntimeConfig;
19
+ /**
20
+ * OAuth is active for this coconut iff auth.json has a Coconut OAuth provider
21
+ * entry that is explicitly enabled AND has a non-empty clientId. Missing entry,
22
+ * `enabled: false`, or missing clientId all disable OAuth.
23
+ *
24
+ * This is the single source of truth for "should we offer Continue with
25
+ * Coconut" — startup, middleware, /status, and the admin settings toggle all
26
+ * go through it.
27
+ */
28
+ export declare function isCoconutOAuthActive(config: AuthConfig): boolean;
29
+ /**
30
+ * Convert an AuthConfig into the runtime OAuth cache shape. Safe to call even
31
+ * when OAuth isn't active — returns `{ enabled: false }` in that case.
32
+ */
33
+ export declare function oauthRuntimeFromAuthConfig(config: AuthConfig): OAuthRuntimeConfig;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Clerk OAuth configuration.
3
+ *
4
+ * Coconut currently has a single control plane, so these values are public
5
+ * (non-secret) constants. If a second control plane is ever introduced, move
6
+ * these to environment variables.
7
+ *
8
+ * Kept in sync with packages/cli/src/commands/serve.ts (startup banner).
9
+ */
10
+ export const CLERK_JWKS_URL = 'https://clerk.coconut.dev/.well-known/jwks.json';
11
+ export const CLERK_ISSUER = 'https://clerk.coconut.dev';
12
+ let runtimeConfig = { enabled: false };
13
+ export function setOAuthRuntimeConfig(config) {
14
+ runtimeConfig = config;
15
+ }
16
+ export function getOAuthRuntimeConfig() {
17
+ return runtimeConfig;
18
+ }
19
+ /**
20
+ * OAuth is active for this coconut iff auth.json has a Coconut OAuth provider
21
+ * entry that is explicitly enabled AND has a non-empty clientId. Missing entry,
22
+ * `enabled: false`, or missing clientId all disable OAuth.
23
+ *
24
+ * This is the single source of truth for "should we offer Continue with
25
+ * Coconut" — startup, middleware, /status, and the admin settings toggle all
26
+ * go through it.
27
+ */
28
+ export function isCoconutOAuthActive(config) {
29
+ const coconut = config.providers.oauth?.coconut;
30
+ return !!(coconut && coconut.enabled && coconut.clientId);
31
+ }
32
+ /**
33
+ * Convert an AuthConfig into the runtime OAuth cache shape. Safe to call even
34
+ * when OAuth isn't active — returns `{ enabled: false }` in that case.
35
+ */
36
+ export function oauthRuntimeFromAuthConfig(config) {
37
+ if (!isCoconutOAuthActive(config))
38
+ return { enabled: false };
39
+ return {
40
+ enabled: true,
41
+ clientId: config.providers.oauth.coconut.clientId,
42
+ };
43
+ }
@@ -23,6 +23,23 @@ export declare class AuthManager {
23
23
  * Initialize auth config with defaults
24
24
  */
25
25
  initializeAuthConfig(adminEmail: string, adminName: string): Promise<AuthConfig>;
26
+ /**
27
+ * Upsert the Coconut OAuth provider entry in auth.json. Creates a minimal
28
+ * OAuth-only auth config on first run (auth.json missing), or merges the
29
+ * new Coconut OAuth settings into an existing config without touching other
30
+ * providers, users, or session state.
31
+ *
32
+ * Used by:
33
+ * - `nut init --oauth-client-id <id>` (provisioning automation)
34
+ * - `PUT /api/v1/auth-settings/oauth/coconut` (admin toggle)
35
+ *
36
+ * Callers are responsible for validating that `clientId` is present when
37
+ * `enabled: true` — this method stores what it's given.
38
+ */
39
+ upsertCoconutOAuth(options: {
40
+ enabled: boolean;
41
+ clientId?: string;
42
+ }): Promise<AuthConfig>;
26
43
  /**
27
44
  * Find user by email
28
45
  */
@@ -72,7 +89,10 @@ export declare class AuthManager {
72
89
  */
73
90
  generateToken(user: LocalAuthUser, provider?: string): Promise<string>;
74
91
  /**
75
- * Verify JWT token
92
+ * Verify JWT token (local HS256 session token)
93
+ *
94
+ * Algorithms are pinned to HS256 to prevent algorithm-confusion attacks
95
+ * when the middleware also accepts externally-issued RS256 JWTs (OAuth).
76
96
  */
77
97
  verifyToken(token: string): Promise<AuthSession | null>;
78
98
  /**
@@ -92,6 +92,55 @@ export class AuthManager {
92
92
  await this.saveAuthConfig(config);
93
93
  return config;
94
94
  }
95
+ /**
96
+ * Upsert the Coconut OAuth provider entry in auth.json. Creates a minimal
97
+ * OAuth-only auth config on first run (auth.json missing), or merges the
98
+ * new Coconut OAuth settings into an existing config without touching other
99
+ * providers, users, or session state.
100
+ *
101
+ * Used by:
102
+ * - `nut init --oauth-client-id <id>` (provisioning automation)
103
+ * - `PUT /api/v1/auth-settings/oauth/coconut` (admin toggle)
104
+ *
105
+ * Callers are responsible for validating that `clientId` is present when
106
+ * `enabled: true` — this method stores what it's given.
107
+ */
108
+ async upsertCoconutOAuth(options) {
109
+ let config = null;
110
+ try {
111
+ config = await this.loadAuthConfig();
112
+ }
113
+ catch {
114
+ // auth.json missing; fall through and bootstrap a minimal shape below.
115
+ }
116
+ if (!config) {
117
+ config = {
118
+ version: '1.0',
119
+ enabled: true,
120
+ allowRegistration: false,
121
+ providers: {
122
+ local: { enabled: false, users: [] },
123
+ oauth: {},
124
+ },
125
+ session: {
126
+ secret: this.generateSecret(),
127
+ expiresIn: DEFAULT_SESSION_EXPIRY,
128
+ cookieName: DEFAULT_COOKIE_NAME,
129
+ secure: process.env.NODE_ENV === 'production',
130
+ },
131
+ apiKeys: [],
132
+ };
133
+ }
134
+ if (!config.providers.oauth) {
135
+ config.providers.oauth = {};
136
+ }
137
+ config.providers.oauth.coconut = {
138
+ enabled: options.enabled,
139
+ clientId: options.clientId,
140
+ };
141
+ await this.saveAuthConfig(config);
142
+ return config;
143
+ }
95
144
  /**
96
145
  * Find user by email
97
146
  */
@@ -268,15 +317,20 @@ export class AuthManager {
268
317
  iat: Math.floor(Date.now() / 1000),
269
318
  exp: Math.floor(Date.now() / 1000) + this.parseExpiry(config.session.expiresIn),
270
319
  };
271
- return jwt.sign(payload, config.session.secret);
320
+ return jwt.sign(payload, config.session.secret, { algorithm: 'HS256' });
272
321
  }
273
322
  /**
274
- * Verify JWT token
323
+ * Verify JWT token (local HS256 session token)
324
+ *
325
+ * Algorithms are pinned to HS256 to prevent algorithm-confusion attacks
326
+ * when the middleware also accepts externally-issued RS256 JWTs (OAuth).
275
327
  */
276
328
  async verifyToken(token) {
277
329
  try {
278
330
  const config = await this.loadAuthConfig();
279
- const decoded = jwt.verify(token, config.session.secret);
331
+ const decoded = jwt.verify(token, config.session.secret, {
332
+ algorithms: ['HS256'],
333
+ });
280
334
  return decoded;
281
335
  }
282
336
  catch (error) {
@@ -0,0 +1,22 @@
1
+ import { jwtVerify, type JWTPayload } from 'jose';
2
+ /**
3
+ * Clerk JWT verification.
4
+ *
5
+ * - JWKS keys are fetched once and cached in memory by `jose`.
6
+ * - `issuer` is enforced so tokens from other Identity Providers cannot pass.
7
+ * - `audience` (the coconut's OAuth client_id) is enforced so a token issued
8
+ * for one coconut's client cannot be replayed against another coconut.
9
+ */
10
+ type JwksKeyFn = Parameters<typeof jwtVerify>[1];
11
+ export interface ClerkVerifyResult {
12
+ payload: JWTPayload;
13
+ }
14
+ export declare function verifyClerkToken(token: string, options: {
15
+ audience: string;
16
+ }): Promise<ClerkVerifyResult | null>;
17
+ /**
18
+ * Test-only: inject a custom JWKS key function (e.g. `jose.createLocalJWKSet`
19
+ * backed by a generated keypair) so middleware tests don't hit the network.
20
+ */
21
+ export declare function __setClerkJwksForTests(jwks: JwksKeyFn | null): void;
22
+ export {};
@@ -0,0 +1,39 @@
1
+ import { createRemoteJWKSet, jwtVerify } from 'jose';
2
+ import { CLERK_ISSUER, CLERK_JWKS_URL } from '../../config/oauth.js';
3
+ let jwksInstance = null;
4
+ function getJwks() {
5
+ if (!jwksInstance) {
6
+ jwksInstance = createRemoteJWKSet(new URL(CLERK_JWKS_URL));
7
+ }
8
+ return jwksInstance;
9
+ }
10
+ export async function verifyClerkToken(token, options) {
11
+ try {
12
+ const jwks = getJwks();
13
+ const verifyOptions = {
14
+ issuer: CLERK_ISSUER,
15
+ audience: options.audience,
16
+ };
17
+ const { payload } = await jwtVerify(token, jwks, verifyOptions);
18
+ return { payload };
19
+ }
20
+ catch (err) {
21
+ // jose throws typed errors like JWTExpired / JWTClaimValidationFailed /
22
+ // JWSSignatureVerificationFailed. We swallow them (returning null keeps
23
+ // the exchange handler's 401 path clean) but log the reason — without
24
+ // this, "OAuth exchange returned 401" is undiagnosable in prod.
25
+ const e = err;
26
+ const reason = e.code
27
+ ? `${e.code}${e.claim ? ` (claim: ${e.claim})` : ''}`
28
+ : e.message || 'unknown error';
29
+ console.warn(`[clerk-verifier] Token verification failed: ${reason}`);
30
+ return null;
31
+ }
32
+ }
33
+ /**
34
+ * Test-only: inject a custom JWKS key function (e.g. `jose.createLocalJWKSet`
35
+ * backed by a generated keypair) so middleware tests don't hit the network.
36
+ */
37
+ export function __setClerkJwksForTests(jwks) {
38
+ jwksInstance = jwks;
39
+ }
@@ -1,6 +1,8 @@
1
1
  import { getCookie } from 'hono/cookie';
2
2
  import { getConnInfo } from '@hono/node-server/conninfo';
3
3
  import { getAuthManager } from '../lib/auth/auth-manager.js';
4
+ import { getOAuthRuntimeConfig } from '../config/oauth.js';
5
+ import { verifyClerkToken } from '../lib/auth/clerk-verifier.js';
4
6
  const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
5
7
  /**
6
8
  * Check if a request is a direct localhost connection (not proxied).
@@ -33,6 +35,7 @@ const PUBLIC_ROUTES = [
33
35
  '/api/v1/auth/logout',
34
36
  '/api/v1/auth/oauth',
35
37
  '/api/v1/auth/callback',
38
+ '/api/v1/auth/oauth/exchange',
36
39
  ];
37
40
  // Routes that require specific roles
38
41
  const ADMIN_ROUTES = [
@@ -110,8 +113,35 @@ export async function authMiddleware(c, next) {
110
113
  if (!token) {
111
114
  return c.json({ error: 'Unauthorized', message: 'No authentication token provided' }, 401);
112
115
  }
113
- // Verify token
114
- const session = await authManager.verifyToken(token);
116
+ // Verify token: try local HS256 session JWT first (fast, no network),
117
+ // then fall back to Clerk JWKS verification when --oauth is enabled.
118
+ let session = await authManager.verifyToken(token);
119
+ let authType = 'session';
120
+ if (!session) {
121
+ const oauth = getOAuthRuntimeConfig();
122
+ if (oauth.enabled && oauth.clientId) {
123
+ const result = await verifyClerkToken(token, { audience: oauth.clientId });
124
+ if (result) {
125
+ const { payload } = result;
126
+ // Map Clerk claims to the AuthSession shape expected downstream.
127
+ // OAuth sessions never receive the 'admin' role - admin-gated routes
128
+ // remain reachable only via locally-minted session tokens.
129
+ session = {
130
+ userId: typeof payload.sub === 'string' ? payload.sub : '',
131
+ email: typeof payload.email === 'string' ? payload.email : '',
132
+ name: typeof payload.name === 'string' ? payload.name : '',
133
+ // OAuth sessions get the most restricted role; admin is reachable
134
+ // only via locally-minted tokens.
135
+ role: 'viewer',
136
+ provider: 'oauth',
137
+ iat: typeof payload.iat === 'number' ? payload.iat : Math.floor(Date.now() / 1000),
138
+ exp: typeof payload.exp === 'number' ? payload.exp : Math.floor(Date.now() / 1000) + 3600,
139
+ };
140
+ authType = 'oauth';
141
+ c.set('oauthClaims', payload);
142
+ }
143
+ }
144
+ }
115
145
  if (!session) {
116
146
  return c.json({ error: 'Unauthorized', message: 'Invalid or expired token' }, 401);
117
147
  }
@@ -121,7 +151,7 @@ export async function authMiddleware(c, next) {
121
151
  }
122
152
  // Store session in context
123
153
  c.set('session', session);
124
- c.set('authType', 'session');
154
+ c.set('authType', authType);
125
155
  return next();
126
156
  }
127
157
  /**
@@ -177,6 +207,13 @@ export function requireAdmin(c) {
177
207
  if (authEnabled === false) {
178
208
  return null;
179
209
  }
210
+ // Trusted non-session authenticators (direct localhost connection, valid API
211
+ // key) are authoritative: they've already passed auth at the middleware
212
+ // layer, so admin-gated routes accept them without a session.role check.
213
+ const authType = c.get('authType');
214
+ if (authType === 'localhost' || authType === 'apikey') {
215
+ return null;
216
+ }
180
217
  const session = requireAuth(c);
181
218
  if (!session) {
182
219
  throw new Error('Authentication required');
@@ -2,6 +2,8 @@ import { Hono } from 'hono';
2
2
  import { setCookie, deleteCookie } from 'hono/cookie';
3
3
  import { getAuthManager } from '../../../../lib/auth/auth-manager.js';
4
4
  import { getSession } from '../../../../middleware/auth.js';
5
+ import { CLERK_ISSUER, CLERK_JWKS_URL, getOAuthRuntimeConfig, } from '../../../../config/oauth.js';
6
+ import { verifyClerkToken } from '../../../../lib/auth/clerk-verifier.js';
5
7
  const auth = new Hono();
6
8
  /**
7
9
  * POST /api/v1/auth/login
@@ -209,11 +211,21 @@ auth.get('/status', async (c) => {
209
211
  },
210
212
  };
211
213
  }
214
+ const oauth = getOAuthRuntimeConfig();
215
+ const oauthInfo = oauth.enabled && oauth.clientId
216
+ ? {
217
+ enabled: true,
218
+ clientId: oauth.clientId,
219
+ issuer: CLERK_ISSUER,
220
+ jwksUrl: CLERK_JWKS_URL,
221
+ }
222
+ : { enabled: false };
212
223
  return c.json({
213
224
  success: true,
214
225
  data: {
215
226
  enabled: authEnabled,
216
227
  config,
228
+ oauth: oauthInfo,
217
229
  },
218
230
  });
219
231
  }
@@ -223,8 +235,90 @@ auth.get('/status', async (c) => {
223
235
  success: true,
224
236
  data: {
225
237
  enabled: false,
238
+ oauth: { enabled: false },
226
239
  },
227
240
  });
228
241
  }
229
242
  });
243
+ /**
244
+ * POST /api/v1/auth/oauth/exchange
245
+ * Exchange a verified Clerk id_token for a local nut-session cookie.
246
+ *
247
+ * The SPA handles the PKCE code-for-token exchange against Clerk directly,
248
+ * then POSTs the resulting id_token here. We verify it via Clerk's JWKS
249
+ * (issuer + this coconut's client_id as audience), then mint a local HS256
250
+ * session JWT and set it as the standard session cookie. Downstream auth
251
+ * flows are unchanged from this point on.
252
+ *
253
+ * id_token (OIDC) is used rather than access_token because identity claims
254
+ * (`sub`, `email`, `name`) live there; access_token carries API authorization
255
+ * for calling Clerk on the user's behalf and has a different claim set.
256
+ */
257
+ auth.post('/oauth/exchange', async (c) => {
258
+ try {
259
+ const oauth = getOAuthRuntimeConfig();
260
+ if (!oauth.enabled || !oauth.clientId) {
261
+ return c.json({ success: false, error: 'OAuth is not enabled on this coconut' }, 400);
262
+ }
263
+ const body = await c.req
264
+ .json()
265
+ .catch(() => ({}));
266
+ const idToken = body.idToken;
267
+ if (!idToken || typeof idToken !== 'string') {
268
+ return c.json({ success: false, error: 'idToken is required' }, 400);
269
+ }
270
+ const verified = await verifyClerkToken(idToken, {
271
+ audience: oauth.clientId,
272
+ });
273
+ if (!verified) {
274
+ return c.json({ success: false, error: 'Invalid or expired OAuth token' }, 401);
275
+ }
276
+ const { payload } = verified;
277
+ const sub = typeof payload.sub === 'string' ? payload.sub : '';
278
+ const email = typeof payload.email === 'string' ? payload.email : '';
279
+ const name = typeof payload.name === 'string'
280
+ ? payload.name
281
+ : (email ? email.split('@')[0] : 'Coconut User');
282
+ if (!sub || !email) {
283
+ return c.json({ success: false, error: 'OAuth token missing required claims (sub/email)' }, 401);
284
+ }
285
+ const authManager = getAuthManager();
286
+ const config = await authManager.loadAuthConfig();
287
+ // Mint a local session JWT so the existing cookie path remains unchanged.
288
+ // OAuth sessions are always 'viewer' - admin role is reserved for
289
+ // locally-managed users.
290
+ const localUserShape = {
291
+ id: sub,
292
+ email,
293
+ name,
294
+ role: 'viewer',
295
+ registered: true,
296
+ createdAt: new Date(),
297
+ updatedAt: new Date(),
298
+ };
299
+ const token = await authManager.generateToken(localUserShape, 'oauth');
300
+ setCookie(c, config.session.cookieName, token, {
301
+ httpOnly: true,
302
+ secure: config.session.secure || false,
303
+ sameSite: 'Lax',
304
+ maxAge: authManager['parseExpiry'](config.session.expiresIn),
305
+ path: '/',
306
+ });
307
+ return c.json({
308
+ success: true,
309
+ data: {
310
+ user: {
311
+ id: sub,
312
+ email,
313
+ name,
314
+ role: 'viewer',
315
+ },
316
+ },
317
+ });
318
+ }
319
+ catch (error) {
320
+ console.error('OAuth exchange error:', error);
321
+ return c.json({ success: false, error: 'OAuth exchange failed' }, 500);
322
+ }
323
+ });
230
324
  export default auth;
@@ -1,6 +1,7 @@
1
1
  import { Hono } from 'hono';
2
2
  import { getAuthManager } from '../../../../lib/auth/auth-manager.js';
3
3
  import { requireAdmin } from '../../../../middleware/auth.js';
4
+ import { CLERK_ISSUER, CLERK_JWKS_URL, oauthRuntimeFromAuthConfig, setOAuthRuntimeConfig, } from '../../../../config/oauth.js';
4
5
  const authSettings = new Hono();
5
6
  /**
6
7
  * GET /api/v1/auth-settings
@@ -52,6 +53,17 @@ authSettings.get('/', async (c) => {
52
53
  callbackUrl: config.providers.oauth.github.callbackUrl,
53
54
  }
54
55
  : undefined,
56
+ // Coconut OAuth: clientId is not a secret (it's the public
57
+ // OAuth client_id used in the authorize URL), so we return it in
58
+ // full so admins can verify it in the UI.
59
+ coconut: config.providers.oauth.coconut
60
+ ? {
61
+ enabled: config.providers.oauth.coconut.enabled,
62
+ clientId: config.providers.oauth.coconut.clientId,
63
+ issuer: CLERK_ISSUER,
64
+ jwksUrl: CLERK_JWKS_URL,
65
+ }
66
+ : undefined,
55
67
  },
56
68
  },
57
69
  session: {
@@ -220,15 +232,84 @@ authSettings.delete('/users/:id', async (c) => {
220
232
  return c.json({ success: false, error: error.message || 'Failed to remove user' }, error.message === 'Admin access required' ? 403 : 400);
221
233
  }
222
234
  });
235
+ /**
236
+ * PUT /api/v1/auth-settings/oauth/coconut
237
+ * Admin-only: toggle Coconut OAuth and/or update its client ID. Persists to
238
+ * auth.json and immediately refreshes the OAuth runtime cache so the change
239
+ * takes effect on the very next request (no server restart required).
240
+ *
241
+ * Request body:
242
+ * { enabled: boolean, clientId?: string }
243
+ *
244
+ * Rules:
245
+ * - If `enabled: true` and no clientId is supplied AND none is already
246
+ * persisted, responds 400 with an actionable message.
247
+ * - If `enabled: false`, clientId (if any) is preserved so the admin can
248
+ * toggle back on later without re-entering it.
249
+ */
250
+ authSettings.put('/oauth/coconut', async (c) => {
251
+ try {
252
+ requireAdmin(c);
253
+ const authManager = getAuthManager();
254
+ const body = await c.req
255
+ .json()
256
+ .catch(() => ({}));
257
+ if (typeof body.enabled !== 'boolean') {
258
+ return c.json({ success: false, error: '`enabled` (boolean) is required' }, 400);
259
+ }
260
+ // Resolve the effective clientId: explicit > previously-persisted.
261
+ let clientId = typeof body.clientId === 'string' ? body.clientId.trim() : undefined;
262
+ if (!clientId) {
263
+ const existing = await authManager.loadAuthConfig().catch(() => null);
264
+ clientId = existing?.providers.oauth.coconut?.clientId;
265
+ }
266
+ if (body.enabled && !clientId) {
267
+ return c.json({
268
+ success: false,
269
+ error: 'Coconut OAuth cannot be enabled without a client ID. Provide `clientId` in the request body, ' +
270
+ 'or run `nut init --oauth-client-id <id>` to seed it.',
271
+ }, 400);
272
+ }
273
+ const updated = await authManager.upsertCoconutOAuth({
274
+ enabled: body.enabled,
275
+ clientId,
276
+ });
277
+ // Live-apply so the next request hits the new state.
278
+ setOAuthRuntimeConfig(oauthRuntimeFromAuthConfig(updated));
279
+ return c.json({
280
+ success: true,
281
+ data: {
282
+ coconut: {
283
+ enabled: updated.providers.oauth.coconut?.enabled ?? false,
284
+ clientId: updated.providers.oauth.coconut?.clientId,
285
+ issuer: CLERK_ISSUER,
286
+ jwksUrl: CLERK_JWKS_URL,
287
+ },
288
+ },
289
+ });
290
+ }
291
+ catch (error) {
292
+ console.error('Update Coconut OAuth error:', error);
293
+ return c.json({ success: false, error: error.message || 'Failed to update Coconut OAuth' }, error.message === 'Admin access required' ? 403 : 500);
294
+ }
295
+ });
223
296
  /**
224
297
  * PUT /api/v1/auth-settings/oauth/:provider
225
- * Configure OAuth provider (admin only)
298
+ * Configure a third-party OAuth provider (admin only). Coconut OAuth has its
299
+ * own dedicated route (above) with stricter validation and live runtime
300
+ * reload, so this handler refuses `coconut` to avoid ambiguity.
226
301
  */
227
302
  authSettings.put('/oauth/:provider', async (c) => {
228
303
  try {
229
304
  requireAdmin(c);
230
- const authManager = getAuthManager();
231
305
  const provider = c.req.param('provider');
306
+ if (provider === 'coconut') {
307
+ return c.json({
308
+ success: false,
309
+ error: 'Use PUT /auth-settings/oauth/coconut for Coconut OAuth',
310
+ }, 400);
311
+ }
312
+ const authManager = getAuthManager();
232
313
  const body = await c.req.json();
233
314
  const config = await authManager.loadAuthConfig();
234
315
  if (!config.providers.oauth) {
@@ -9,6 +9,9 @@ export declare function GET(c: Context): Promise<(Response & import("hono").Type
9
9
  }, 404, "json">) | (Response & import("hono").TypedResponse<{
10
10
  success: true;
11
11
  data: {
12
+ id: string;
13
+ name: string;
14
+ status: ScheduledJobStatus;
12
15
  schedule: {
13
16
  type: "cron";
14
17
  expression: string;
@@ -21,9 +24,9 @@ export declare function GET(c: Context): Promise<(Response & import("hono").Type
21
24
  timezone?: string;
22
25
  anchorHour?: number;
23
26
  };
24
- status: ScheduledJobStatus;
25
- id: string;
26
- name: string;
27
+ description?: string;
28
+ prompt: string;
29
+ model: string;
27
30
  metadata: {
28
31
  createdAt: string;
29
32
  updatedAt: string;
@@ -31,19 +34,16 @@ export declare function GET(c: Context): Promise<(Response & import("hono").Type
31
34
  nextRunAt?: string;
32
35
  };
33
36
  tags?: string[];
34
- description?: string;
35
- model: string;
36
- prompt: string;
37
- mcpServers?: string[];
38
37
  contextPaths?: string[];
39
38
  agentId?: string;
40
39
  agentIds?: string[];
40
+ mcpServers?: string[];
41
41
  runs: {
42
+ id: string;
43
+ error?: string;
44
+ status: import("@lovelybunch/types").ScheduledJobRunStatus;
42
45
  jobId: string;
43
46
  trigger: import("@lovelybunch/types").ScheduledJobTrigger;
44
- status: import("@lovelybunch/types").ScheduledJobRunStatus;
45
- error?: string;
46
- id: string;
47
47
  startedAt: string;
48
48
  finishedAt?: string;
49
49
  }[];
@@ -70,6 +70,9 @@ export declare function PATCH(c: Context): Promise<(Response & import("hono").Ty
70
70
  }, 400, "json">) | (Response & import("hono").TypedResponse<{
71
71
  success: true;
72
72
  data: {
73
+ id: string;
74
+ name: string;
75
+ status: ScheduledJobStatus;
73
76
  schedule: {
74
77
  type: "cron";
75
78
  expression: string;
@@ -82,9 +85,9 @@ export declare function PATCH(c: Context): Promise<(Response & import("hono").Ty
82
85
  timezone?: string;
83
86
  anchorHour?: number;
84
87
  };
85
- status: ScheduledJobStatus;
86
- id: string;
87
- name: string;
88
+ description?: string;
89
+ prompt: string;
90
+ model: string;
88
91
  metadata: {
89
92
  createdAt: string;
90
93
  updatedAt: string;
@@ -92,19 +95,16 @@ export declare function PATCH(c: Context): Promise<(Response & import("hono").Ty
92
95
  nextRunAt?: string;
93
96
  };
94
97
  tags?: string[];
95
- description?: string;
96
- model: string;
97
- prompt: string;
98
- mcpServers?: string[];
99
98
  contextPaths?: string[];
100
99
  agentId?: string;
101
100
  agentIds?: string[];
101
+ mcpServers?: string[];
102
102
  runs: {
103
+ id: string;
104
+ error?: string;
105
+ status: import("@lovelybunch/types").ScheduledJobRunStatus;
103
106
  jobId: string;
104
107
  trigger: import("@lovelybunch/types").ScheduledJobTrigger;
105
- status: import("@lovelybunch/types").ScheduledJobRunStatus;
106
- error?: string;
107
- id: string;
108
108
  startedAt: string;
109
109
  finishedAt?: string;
110
110
  }[];