@app-vitals/agent-admin 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +43 -0
- package/README.md +72 -2
- package/e2e/agents-page.e2e.ts +338 -0
- package/e2e/chat-degraded.e2e.ts +153 -0
- package/e2e/chat-page.e2e.ts +169 -0
- package/e2e/chat-progress.e2e.ts +206 -0
- package/e2e/chat-thread-page.e2e.ts +444 -0
- package/e2e/login-page.e2e.ts +148 -0
- package/e2e/test-server.ts +427 -0
- package/openapi.json +6098 -0
- package/openapi.platform.json +5553 -0
- package/package.json +62 -4
- package/playwright.config.ts +25 -0
- package/prisma/client/browser.ts +134 -0
- package/prisma/client/client.ts +158 -0
- package/prisma/client/commonInputTypes.ts +578 -0
- package/prisma/client/enums.ts +15 -0
- package/prisma/client/internal/class.ts +424 -0
- package/prisma/client/internal/prismaNamespace.ts +2898 -0
- package/prisma/client/internal/prismaNamespaceBrowser.ts +476 -0
- package/prisma/client/models/Account.ts +1774 -0
- package/prisma/client/models/AccountInvite.ts +1386 -0
- package/prisma/client/models/AccountMember.ts +1351 -0
- package/prisma/client/models/Agent.ts +4554 -0
- package/prisma/client/models/AgentAsk.ts +1373 -0
- package/prisma/client/models/AgentChatTokenUsageDailyByModel.ts +1638 -0
- package/prisma/client/models/AgentCronJob.ts +2416 -0
- package/prisma/client/models/AgentCronRun.ts +2858 -0
- package/prisma/client/models/AgentCronRunModelBreakdown.ts +1532 -0
- package/prisma/client/models/AgentCronRunSkillUsage.ts +1642 -0
- package/prisma/client/models/AgentEnv.ts +1421 -0
- package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +1289 -0
- package/prisma/client/models/AgentMember.ts +1316 -0
- package/prisma/client/models/AgentPhaseMethodology.ts +1355 -0
- package/prisma/client/models/AgentPlugin.ts +1421 -0
- package/prisma/client/models/AgentToken.ts +1380 -0
- package/prisma/client/models/AgentTool.ts +1351 -0
- package/prisma/client/models/AgentWorkQueueSnapshot.ts +1297 -0
- package/prisma/client/models/ChatThreadWatch.ts +1183 -0
- package/prisma/client/models/PushSubscription.ts +1233 -0
- package/prisma/client/models/SessionAlertState.ts +1187 -0
- package/prisma/client/models/SessionFollow.ts +1187 -0
- package/prisma/client/models/UserNotificationPrefs.ts +1221 -0
- package/prisma/client/models.ts +34 -0
- package/prisma/migrations/20260608000000_init/migration.sql +112 -0
- package/prisma/migrations/20260616000000_agent_members/migration.sql +18 -0
- package/prisma/migrations/20260624000000_add_agent_self_hosted/migration.sql +2 -0
- package/prisma/migrations/20260624120000_add_agent_repos/migration.sql +3 -0
- package/prisma/migrations/20260625000000_add_agent_cron_run/migration.sql +26 -0
- package/prisma/migrations/20260626000000_add_cron_run_token_fields/migration.sql +10 -0
- package/prisma/migrations/20260627000000_add_agent_chat_token_usage_daily/migration.sql +18 -0
- package/prisma/migrations/20260627010000_add_agent_chat_token_usage_fk/migration.sql +2 -0
- package/prisma/migrations/20260701000000_add_chat_threads_messages/migration.sql +37 -0
- package/prisma/migrations/20260701010000_drop_chat_threads_messages/migration.sql +17 -0
- package/prisma/migrations/20260701020000_add_cron_run_model_breakdown/migration.sql +23 -0
- package/prisma/migrations/20260701020000_chat_token_by_model/migration.sql +25 -0
- package/prisma/migrations/20260701030000_add_secret_flag_to_agent_env/migration.sql +2 -0
- package/prisma/migrations/20260701030000_drop_cron_run_legacy_columns/migration.sql +5 -0
- package/prisma/migrations/20260706000000_consolidate_cron_run_token_accounting/migration.sql +54 -0
- package/prisma/migrations/20260709000000_add_phase_to_agent_cron_run/migration.sql +8 -0
- package/prisma/migrations/20260717000000_add_item_tracking_to_agent_cron_run/migration.sql +10 -0
- package/prisma/migrations/20260717010000_add_agent_work_queue_snapshot/migration.sql +20 -0
- package/prisma/migrations/20260718000000_add_cron_phase_columns/migration.sql +25 -0
- package/prisma/migrations/20260718100000_drop_agent_cron_run_phase_column/migration.sql +8 -0
- package/prisma/migrations/20260724000000_add_agent_type_name/migration.sql +3 -0
- package/prisma/migrations/20260725232433_add_agent_author_allowlist/migration.sql +2 -0
- package/prisma/migrations/20260727120000_add_not_null_to_agent_author_allowlist/migration.sql +3 -0
- package/prisma/migrations/20260731000000_add_session_id_to_agent_cron_run/migration.sql +2 -0
- package/prisma/migrations/20260821000000_add_agent_restrict_slack_to_members/migration.sql +3 -0
- package/prisma/migrations/20260901000000_add_push_subscription_and_chat_thread_watch/migration.sql +40 -0
- package/prisma/migrations/20260903000000_add_agent_patch_author_allowlist/migration.sql +2 -0
- package/prisma/migrations/20260903120000_add_agent_review_author_allowlist/migration.sql +18 -0
- package/prisma/migrations/20260903150000_drop_agent_author_allowlist/migration.sql +14 -0
- package/prisma/migrations/20260905000000_add_started_at_index_to_agent_cron_run/migration.sql +11 -0
- package/prisma/migrations/20260906000000_add_session_follow_notification_prefs_alert_state/migration.sql +47 -0
- package/prisma/migrations/20260911000000_backfill_auto_follow_since/migration.sql +16 -0
- package/prisma/migrations/20260923115814_add_last_heartbeat_at_to_agent_cron_run/migration.sql +2 -0
- package/prisma/migrations/20260924120000_add_trial_expiry_fields_to_agent/migration.sql +6 -0
- package/prisma/migrations/20260929000000_add_agent_phase_methodology/migration.sql +21 -0
- package/prisma/migrations/20260929120000_add_agent_policy_fields/migration.sql +15 -0
- package/prisma/migrations/20261005000000_add_agent_github_installations_snapshot/migration.sql +21 -0
- package/prisma/migrations/20261008000000_add_cron_run_telemetry/migration.sql +50 -0
- package/prisma/migrations/20261008010000_add_account_models/migration.sql +48 -0
- package/prisma/migrations/20261008020000_add_cron_lockdown_disabled_at/migration.sql +3 -0
- package/prisma/migrations/20261009000000_chat_token_usage_bigint/migration.sql +7 -0
- package/prisma/migrations/20261010000000_add_cron_run_command_variant/migration.sql +2 -0
- package/prisma/migrations/20261019000000_add_agent_anthropic_identity/migration.sql +9 -0
- package/prisma/migrations/20261025000000_add_agent_ask/migration.sql +23 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema/coding.prisma +85 -0
- package/prisma/schema/schema.prisma +506 -0
- package/prisma.config.ts +34 -0
- package/pwa-assets/icons/apple-touch-icon.png +0 -0
- package/pwa-assets/icons/favicon-32.png +0 -0
- package/pwa-assets/icons/icon-192.png +0 -0
- package/pwa-assets/icons/icon-512.png +0 -0
- package/pwa-assets/icons/icon-maskable-192.png +0 -0
- package/pwa-assets/icons/icon-maskable-512.png +0 -0
- package/src/account-agent-create.ts +42 -0
- package/src/account-agent-create.unit.test.ts +28 -0
- package/src/account-created-notifier.ts +71 -0
- package/src/account-created-notifier.unit.test.ts +70 -0
- package/src/account-email.ts +9 -0
- package/src/account-email.unit.test.ts +21 -0
- package/src/account-invites.ts +70 -0
- package/src/account-lifecycle.integration.test.ts +329 -0
- package/src/account-lifecycle.ts +206 -0
- package/src/account-lifecycle.unit.test.ts +108 -0
- package/src/account-members.ts +128 -0
- package/src/account-onboarding.integration.test.ts +101 -0
- package/src/account-onboarding.ts +95 -0
- package/src/account-trial-expiry-sweeper.ts +85 -0
- package/src/account-trial-warning-sweeper.integration.test.ts +228 -0
- package/src/account-trial-warning-sweeper.ts +216 -0
- package/src/accounts-api.integration.test.ts +196 -0
- package/src/accounts-api.smoke.test.ts +292 -0
- package/src/accounts-api.ts +289 -0
- package/src/accounts-api.unit.test.ts +93 -0
- package/src/accounts.integration.test.ts +178 -0
- package/src/accounts.ts +208 -0
- package/src/admin-boot-config.ts +574 -0
- package/src/admin-boot-config.unit.test.ts +28 -0
- package/src/admin-boot.boot-order.unit.test.ts +91 -0
- package/src/admin-boot.ts +690 -0
- package/src/admin-dev-login.smoke.test.ts +317 -0
- package/src/admin-extension.ts +432 -0
- package/src/admin-extension.unit.test.ts +532 -0
- package/src/admin-local-agent.smoke.test.ts +1643 -0
- package/src/admin-spec.smoke.test.ts +96 -0
- package/src/admin-spec.ts +186 -0
- package/src/admin-ui-account-nav.ts +20 -0
- package/src/admin-ui-account-pages.ts +131 -0
- package/src/admin-ui-account-pages.unit.test.ts +112 -0
- package/src/admin-ui-account.smoke.test.ts +286 -0
- package/src/admin-ui-account.ts +185 -0
- package/src/admin-ui-accounts-pages.ts +202 -0
- package/src/admin-ui-accounts-pages.unit.test.ts +138 -0
- package/src/admin-ui-accounts.smoke.test.ts +434 -0
- package/src/admin-ui-accounts.ts +266 -0
- package/src/admin-ui-layout.ts +60 -0
- package/src/admin-ui-layout.unit.test.ts +140 -0
- package/src/admin-ui-pages.ts +3489 -0
- package/src/admin-ui-pages.unit.test.ts +4355 -0
- package/src/admin-ui-push-test.smoke.test.ts +125 -0
- package/src/admin-ui-push-test.ts +102 -0
- package/src/admin-ui-styles.ts +656 -0
- package/src/admin-ui-styles.unit.test.ts +103 -0
- package/src/admin-ui.fixtures.ts +457 -0
- package/src/admin-ui.smoke.test.ts +7227 -0
- package/src/admin-ui.ts +3824 -0
- package/src/agent-account-assignment.ts +49 -0
- package/src/agent-account-assignment.unit.test.ts +99 -0
- package/src/agent-anthropic-identity.integration.test.ts +99 -0
- package/src/agent-anthropic-identity.ts +78 -0
- package/src/agent-anthropic-identity.unit.test.ts +77 -0
- package/src/agent-ask-config.ts +59 -0
- package/src/agent-ask-config.unit.test.ts +70 -0
- package/src/agent-ask.integration.test.ts +175 -0
- package/src/agent-ask.smoke.test.ts +457 -0
- package/src/agent-ask.ts +378 -0
- package/src/agent-chat-tokens.integration.test.ts +420 -0
- package/src/agent-chat-tokens.smoke.test.ts +551 -0
- package/src/agent-chat-tokens.ts +259 -0
- package/src/agent-cron-jobs.integration.test.ts +582 -0
- package/src/agent-cron-jobs.ts +615 -0
- package/src/agent-cron-jobs.unit.test.ts +420 -0
- package/src/agent-cron-run-stats.integration.test.ts +1551 -0
- package/src/agent-cron-run-stats.smoke.test.ts +953 -0
- package/src/agent-cron-run-stats.ts +752 -0
- package/src/agent-cron-runs.integration.test.ts +1491 -0
- package/src/agent-cron-runs.ts +489 -0
- package/src/agent-deletion-checklist.ts +128 -0
- package/src/agent-deletion-checklist.unit.test.ts +201 -0
- package/src/agent-deletion-per-agent.integration.test.ts +323 -0
- package/src/agent-deletion.integration.test.ts +323 -0
- package/src/agent-deletion.ts +329 -0
- package/src/agent-deletion.unit.test.ts +412 -0
- package/src/agent-envs.integration.test.ts +323 -0
- package/src/agent-envs.ts +244 -0
- package/src/agent-github-installations.integration.test.ts +115 -0
- package/src/agent-github-installations.ts +52 -0
- package/src/agent-manifest.ts +685 -0
- package/src/agent-manifest.unit.test.ts +870 -0
- package/src/agent-members.ts +69 -0
- package/src/agent-members.unit.test.ts +250 -0
- package/src/agent-patch-author-allowlist.integration.test.ts +218 -0
- package/src/agent-plugins.integration.test.ts +157 -0
- package/src/agent-plugins.ts +95 -0
- package/src/agent-plugins.unit.test.ts +43 -0
- package/src/agent-provisioner-per-agent.integration.test.ts +949 -0
- package/src/agent-provisioner.integration.test.ts +1245 -0
- package/src/agent-provisioner.ts +1650 -0
- package/src/agent-provisioner.unit.test.ts +1852 -0
- package/src/agent-tokens.integration.test.ts +150 -0
- package/src/agent-tokens.ts +112 -0
- package/src/agent-tools.integration.test.ts +129 -0
- package/src/agent-tools.ts +111 -0
- package/src/agents-api.integration.test.ts +705 -0
- package/src/agents-api.sentry.smoke.test.ts +478 -0
- package/src/agents-api.smoke.test.ts +5986 -0
- package/src/agents-api.ts +2826 -0
- package/src/agents.integration.test.ts +512 -0
- package/src/agents.ts +1097 -0
- package/src/agents.unit.test.ts +1736 -0
- package/src/anthropic-credential-guard.ts +44 -0
- package/src/anthropic-credential-guard.unit.test.ts +88 -0
- package/src/anthropic-federated-token-provider.integration.test.ts +248 -0
- package/src/anthropic-federated-token-provider.ts +249 -0
- package/src/anthropic-identity-client.fixtures.ts +237 -0
- package/src/anthropic-identity-client.integration.test.ts +342 -0
- package/src/anthropic-identity-client.ts +473 -0
- package/src/anthropic-identity-fixture-spec.integration.test.ts +169 -0
- package/src/anthropic-identity-name.integration.test.ts +83 -0
- package/src/anthropic-identity-name.ts +163 -0
- package/src/anthropic-identity-name.unit.test.ts +345 -0
- package/src/anthropic-openapi-contract.fixtures.ts +192 -0
- package/src/api-auth.ts +383 -0
- package/src/api-auth.unit.test.ts +837 -0
- package/src/api.smoke.test.ts +1225 -0
- package/src/api.ts +376 -0
- package/src/attachment-validation.ts +124 -0
- package/src/attachment-validation.unit.test.ts +218 -0
- package/src/caller-scope.ts +131 -0
- package/src/caller-scope.unit.test.ts +116 -0
- package/src/chat-credential-config.ts +22 -0
- package/src/chat-credentials-logging.unit.test.ts +92 -0
- package/src/chat-markers.ts +114 -0
- package/src/chat-markers.unit.test.ts +116 -0
- package/src/chat-service-provisioning-client.integration.test.ts +412 -0
- package/src/chat-service-provisioning-client.ts +221 -0
- package/src/chat.smoke.test.ts +1875 -0
- package/src/clock.ts +47 -0
- package/src/coding-task-store-provisioning-client.integration.test.ts +372 -0
- package/src/coding-task-store-provisioning-client.ts +156 -0
- package/src/crypto.ts +83 -0
- package/src/dev-auth-guard.ts +29 -0
- package/src/dev-auth-guard.unit.test.ts +51 -0
- package/src/errors.ts +77 -0
- package/src/errors.unit.test.ts +52 -0
- package/src/fixtures/anthropic-identity-recorded-fixtures.json +850 -0
- package/src/fixtures/anthropic-openapi/SDK_VERSION +1 -0
- package/src/fixtures/anthropic-openapi/mock-spec.json.gz +0 -0
- package/src/fixtures/chat-service-provisioning-cassette.json +156 -0
- package/src/fixtures/github-app-manifest-cassette.json +44 -0
- package/src/fixtures/google-auth-cassette.json +36 -0
- package/src/fixtures/http-chat-client-cassette.json +226 -0
- package/src/fixtures/k8s-cassette.json +470 -0
- package/src/fixtures/okta-auth-cassette.json +36 -0
- package/src/fixtures/slack-provision-cassette.json +12 -0
- package/src/fixtures/slack-provisioning-http-cassette.json +98 -0
- package/src/fixtures/task-store-provisioning-cassette.json +85 -0
- package/src/github-app-provisioning-client.integration.test.ts +149 -0
- package/src/github-app-provisioning-client.ts +161 -0
- package/src/github-app-provisioning-client.unit.test.ts +114 -0
- package/src/github-provisioning-service.ts +569 -0
- package/src/github-provisioning-service.unit.test.ts +123 -0
- package/src/google-auth-client.integration.test.ts +195 -0
- package/src/google-auth-client.ts +121 -0
- package/src/http-chat-client.integration.test.ts +554 -0
- package/src/http-chat-client.ts +533 -0
- package/src/http-chat-client.unit.test.ts +119 -0
- package/src/import-boundary.unit.test.ts +240 -0
- package/src/kubernetes-client.integration.test.ts +697 -0
- package/src/kubernetes-client.ts +1027 -0
- package/src/kubernetes-client.unit.test.ts +726 -0
- package/src/main.smoke.test.ts +91 -0
- package/src/main.ts +21 -0
- package/src/main.unit.test.ts +801 -0
- package/src/okta-auth-client.integration.test.ts +263 -0
- package/src/okta-auth-client.ts +152 -0
- package/src/openapi-schemas.ts +1310 -0
- package/src/openapi-schemas.unit.test.ts +348 -0
- package/src/per-agent-identity.fixtures.ts +305 -0
- package/src/platform-doc-route.ts +40 -0
- package/src/prisma-client.integration.test.ts +57 -0
- package/src/prisma-client.ts +187 -0
- package/src/prisma-client.unit.test.ts +178 -0
- package/src/prisma-tx.ts +23 -0
- package/src/push-content.ts +177 -0
- package/src/push-content.unit.test.ts +213 -0
- package/src/push-crypto.ts +135 -0
- package/src/push-crypto.unit.test.ts +66 -0
- package/src/push-sender.ts +169 -0
- package/src/push-sender.unit.test.ts +107 -0
- package/src/push-service.ts +332 -0
- package/src/push-service.unit.test.ts +679 -0
- package/src/push-toggle.ts +135 -0
- package/src/push-toggle.unit.test.ts +78 -0
- package/src/pwa-https-gating.smoke.test.ts +229 -0
- package/src/pwa-icons.unit.test.ts +41 -0
- package/src/pwa-routes.smoke.test.ts +276 -0
- package/src/pwa.ts +380 -0
- package/src/pwa.unit.test.ts +321 -0
- package/src/schema.unit.test.ts +122 -0
- package/src/self-serve-config.ts +52 -0
- package/src/self-serve-config.unit.test.ts +79 -0
- package/src/server.smoke.test.ts +273 -0
- package/src/server.ts +50 -0
- package/src/service-integration.ts +67 -0
- package/src/service-integration.unit.test.ts +129 -0
- package/src/slack-provisioning-client.integration.test.ts +308 -0
- package/src/slack-provisioning-client.ts +411 -0
- package/src/slack-provisioning-client.unit.test.ts +80 -0
- package/src/slack-provisioning-service.integration.test.ts +637 -0
- package/src/slack-provisioning-service.ts +451 -0
- package/src/slack-provisioning.integration.test.ts +395 -0
- package/src/specialty-manifest-loader.fixture.integration.test.ts +63 -0
- package/src/specialty-manifest-loader.ts +255 -0
- package/src/specialty-manifest-loader.unit.test.ts +301 -0
- package/src/specialty-registry.schema.unit.test.ts +35 -0
- package/src/specialty-registry.ts +236 -0
- package/src/specialty-registry.unit.test.ts +272 -0
- package/src/startup-policy-reconcile.ts +77 -0
- package/src/startup-policy-reconcile.unit.test.ts +251 -0
- package/src/theme-tokens.json +12 -0
- package/src/token-crypto.ts +60 -0
- package/src/token-crypto.unit.test.ts +182 -0
- package/src/trial-expiry-sweeper.integration.test.ts +318 -0
- package/src/trial-expiry-sweeper.ts +132 -0
- package/src/trial-expiry-sweeper.unit.test.ts +391 -0
- package/src/trial-expiry-warning-sweeper.integration.test.ts +468 -0
- package/src/trial-expiry-warning-sweeper.ts +447 -0
- package/src/trial-expiry-warning-sweeper.unit.test.ts +148 -0
- package/src/voice-contract.unit.test.ts +102 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/api-auth.unit.test.ts
|
|
3
|
+
* Unit tests for the combined admin auth middleware (bearer token + session cookie).
|
|
4
|
+
*
|
|
5
|
+
* Pure logic — mocks agentTokenService, injects a known sessionSecret.
|
|
6
|
+
* No real DB, no real JWT library calls beyond what Hono's own helpers do.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import type { Caller } from "@app-vitals/agent-lib/request-context";
|
|
11
|
+
import { Hono } from "hono";
|
|
12
|
+
import { sign } from "hono/jwt";
|
|
13
|
+
import type { AgentTokenService, AgentTokenValidated } from "./agent-tokens.ts";
|
|
14
|
+
import type { AdminAuthEnv } from "./api-auth.ts";
|
|
15
|
+
import {
|
|
16
|
+
AGENT_CROSS_ROUTES,
|
|
17
|
+
createAdminAuthMiddleware,
|
|
18
|
+
extractAgentId,
|
|
19
|
+
PLATFORM_OPS_ROUTES,
|
|
20
|
+
parseAdminApiKeys,
|
|
21
|
+
requireScope,
|
|
22
|
+
} from "./api-auth.ts";
|
|
23
|
+
import { createCallerScopeResolver } from "./caller-scope.ts";
|
|
24
|
+
|
|
25
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
const SESSION_SECRET = "test-session-secret-exactly-32-bytes!";
|
|
28
|
+
const VALID_RAW_TOKEN = "valid-raw-token-hex-string";
|
|
29
|
+
const AGENT_ID = "agent-abc-123";
|
|
30
|
+
|
|
31
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/** Build a valid session JWT */
|
|
34
|
+
async function makeSessionJwt(secret = SESSION_SECRET): Promise<string> {
|
|
35
|
+
return sign(
|
|
36
|
+
{
|
|
37
|
+
isAdmin: true,
|
|
38
|
+
userId: "user-1",
|
|
39
|
+
email: "admin@example.com",
|
|
40
|
+
iat: Math.floor(Date.now() / 1000),
|
|
41
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
42
|
+
},
|
|
43
|
+
secret,
|
|
44
|
+
"HS256",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Build a minimal Hono app protected by the middleware under test */
|
|
49
|
+
function buildApp(
|
|
50
|
+
validateFn: (raw: string) => Promise<AgentTokenValidated | null>,
|
|
51
|
+
adminApiKeys?: Map<string, { name: string; scope: string }>,
|
|
52
|
+
): Hono<AdminAuthEnv> {
|
|
53
|
+
const mockTokenService: Pick<AgentTokenService, "validate"> = {
|
|
54
|
+
validate: validateFn,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const app = new Hono<AdminAuthEnv>();
|
|
58
|
+
app.use(
|
|
59
|
+
"*",
|
|
60
|
+
createAdminAuthMiddleware({
|
|
61
|
+
sessionSecret: SESSION_SECRET,
|
|
62
|
+
agentTokenService: mockTokenService,
|
|
63
|
+
adminApiKeys,
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
app.get("/test", (c) => c.json({ ok: true, caller: c.get("caller") }));
|
|
67
|
+
// Scoped agent route — mirrors real admin API pattern
|
|
68
|
+
app.get("/agents/:id/envs", (c) =>
|
|
69
|
+
c.json({ agentId: c.req.param("id"), caller: c.get("caller") }),
|
|
70
|
+
);
|
|
71
|
+
return app;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
describe("createAdminAuthMiddleware — no auth", () => {
|
|
77
|
+
it("returns 401 when no Authorization header and no cookie", async () => {
|
|
78
|
+
const app = buildApp(async () => null);
|
|
79
|
+
const res = await app.request("/test");
|
|
80
|
+
expect(res.status).toBe(401);
|
|
81
|
+
const body = await res.json();
|
|
82
|
+
expect(body.error).toBe("Unauthorized");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("createAdminAuthMiddleware — session cookie", () => {
|
|
87
|
+
it("passes when a valid session cookie is present", async () => {
|
|
88
|
+
const app = buildApp(async () => null);
|
|
89
|
+
const jwt = await makeSessionJwt();
|
|
90
|
+
const res = await app.request("/test", {
|
|
91
|
+
headers: { Cookie: `admin_session=${jwt}` },
|
|
92
|
+
});
|
|
93
|
+
expect(res.status).toBe(200);
|
|
94
|
+
const body = await res.json();
|
|
95
|
+
expect(body.ok).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns 401 when session cookie is signed with wrong secret", async () => {
|
|
99
|
+
const app = buildApp(async () => null);
|
|
100
|
+
const jwt = await makeSessionJwt("wrong-secret-32-bytes-exactly!!!");
|
|
101
|
+
const res = await app.request("/test", {
|
|
102
|
+
headers: { Cookie: `admin_session=${jwt}` },
|
|
103
|
+
});
|
|
104
|
+
expect(res.status).toBe(401);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("returns 401 when session cookie is not a valid JWT", async () => {
|
|
108
|
+
const app = buildApp(async () => null);
|
|
109
|
+
const res = await app.request("/test", {
|
|
110
|
+
headers: { Cookie: "admin_session=not.a.valid.jwt" },
|
|
111
|
+
});
|
|
112
|
+
expect(res.status).toBe(401);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("returns 401 when session cookie JWT is missing required fields", async () => {
|
|
116
|
+
// Sign a JWT missing userId/email
|
|
117
|
+
const jwt = await sign(
|
|
118
|
+
{
|
|
119
|
+
iat: Math.floor(Date.now() / 1000),
|
|
120
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
121
|
+
},
|
|
122
|
+
SESSION_SECRET,
|
|
123
|
+
"HS256",
|
|
124
|
+
);
|
|
125
|
+
const app = buildApp(async () => null);
|
|
126
|
+
const res = await app.request("/test", {
|
|
127
|
+
headers: { Cookie: `admin_session=${jwt}` },
|
|
128
|
+
});
|
|
129
|
+
expect(res.status).toBe(401);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("createAdminAuthMiddleware — bearer token", () => {
|
|
134
|
+
it("passes when a valid bearer token is provided", async () => {
|
|
135
|
+
const app = buildApp(async (raw) =>
|
|
136
|
+
raw === VALID_RAW_TOKEN ? { agentId: AGENT_ID } : null,
|
|
137
|
+
);
|
|
138
|
+
const res = await app.request("/test", {
|
|
139
|
+
headers: { Authorization: `Bearer ${VALID_RAW_TOKEN}` },
|
|
140
|
+
});
|
|
141
|
+
expect(res.status).toBe(200);
|
|
142
|
+
const body = await res.json();
|
|
143
|
+
expect(body.ok).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("returns 401 when bearer token is invalid (validate returns null)", async () => {
|
|
147
|
+
const app = buildApp(async () => null);
|
|
148
|
+
const res = await app.request("/test", {
|
|
149
|
+
headers: { Authorization: "Bearer invalid-token" },
|
|
150
|
+
});
|
|
151
|
+
expect(res.status).toBe(401);
|
|
152
|
+
const body = await res.json();
|
|
153
|
+
expect(body.error).toBe("Unauthorized");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("returns 401 when bearer token is revoked (validate returns null for revoked)", async () => {
|
|
157
|
+
// validate() returns null for revoked tokens — same branch
|
|
158
|
+
const app = buildApp(async () => null);
|
|
159
|
+
const res = await app.request("/test", {
|
|
160
|
+
headers: { Authorization: "Bearer revoked-token-value" },
|
|
161
|
+
});
|
|
162
|
+
expect(res.status).toBe(401);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("returns 401 immediately when Authorization header is present but invalid — does NOT fall through to cookie", async () => {
|
|
166
|
+
// Authorization header present + invalid → reject, even if a valid cookie is also present
|
|
167
|
+
const jwt = await makeSessionJwt();
|
|
168
|
+
const app = buildApp(async () => null);
|
|
169
|
+
const res = await app.request("/test", {
|
|
170
|
+
headers: {
|
|
171
|
+
Authorization: "Bearer invalid-token",
|
|
172
|
+
Cookie: `admin_session=${jwt}`,
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
expect(res.status).toBe(401);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("falls through to session cookie when Authorization header is absent", async () => {
|
|
179
|
+
// No Authorization header → try cookie path → should succeed
|
|
180
|
+
const app = buildApp(async () => null);
|
|
181
|
+
const jwt = await makeSessionJwt();
|
|
182
|
+
const res = await app.request("/test", {
|
|
183
|
+
headers: { Cookie: `admin_session=${jwt}` },
|
|
184
|
+
});
|
|
185
|
+
expect(res.status).toBe(200);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("returns 401 with WWW-Authenticate when Authorization header is malformed", async () => {
|
|
189
|
+
const app = buildApp(async () => null);
|
|
190
|
+
const res = await app.request("/test", {
|
|
191
|
+
headers: { Authorization: "Basic dXNlcjpwYXNz" },
|
|
192
|
+
});
|
|
193
|
+
expect(res.status).toBe(401);
|
|
194
|
+
expect(res.headers.get("WWW-Authenticate")).toBe("Bearer");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("returns 401 with WWW-Authenticate when bearer token is invalid", async () => {
|
|
198
|
+
const app = buildApp(async () => null);
|
|
199
|
+
const res = await app.request("/test", {
|
|
200
|
+
headers: { Authorization: "Bearer bad-token" },
|
|
201
|
+
});
|
|
202
|
+
expect(res.status).toBe(401);
|
|
203
|
+
expect(res.headers.get("WWW-Authenticate")).toBe(
|
|
204
|
+
'Bearer error="invalid_token"',
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe("createAdminAuthMiddleware — bearer token scope enforcement", () => {
|
|
210
|
+
it("passes when token agentId matches the :id route param", async () => {
|
|
211
|
+
const app = buildApp(async (raw) =>
|
|
212
|
+
raw === VALID_RAW_TOKEN ? { agentId: AGENT_ID } : null,
|
|
213
|
+
);
|
|
214
|
+
const res = await app.request(`/agents/${AGENT_ID}/envs`, {
|
|
215
|
+
headers: { Authorization: `Bearer ${VALID_RAW_TOKEN}` },
|
|
216
|
+
});
|
|
217
|
+
expect(res.status).toBe(200);
|
|
218
|
+
const body = await res.json();
|
|
219
|
+
expect(body.agentId).toBe(AGENT_ID);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("returns 403 when token agentId does not match the :id route param", async () => {
|
|
223
|
+
const app = buildApp(async (raw) =>
|
|
224
|
+
raw === VALID_RAW_TOKEN ? { agentId: AGENT_ID } : null,
|
|
225
|
+
);
|
|
226
|
+
const res = await app.request("/agents/agent-different-id/envs", {
|
|
227
|
+
headers: { Authorization: `Bearer ${VALID_RAW_TOKEN}` },
|
|
228
|
+
});
|
|
229
|
+
expect(res.status).toBe(403);
|
|
230
|
+
const body = await res.json();
|
|
231
|
+
expect(body.error).toBe("Forbidden");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("passes for routes without an :id param (no scope to enforce)", async () => {
|
|
235
|
+
// Unscoped route — token is valid, no :id to compare against
|
|
236
|
+
const app = buildApp(async (raw) =>
|
|
237
|
+
raw === VALID_RAW_TOKEN ? { agentId: AGENT_ID } : null,
|
|
238
|
+
);
|
|
239
|
+
const res = await app.request("/test", {
|
|
240
|
+
headers: { Authorization: `Bearer ${VALID_RAW_TOKEN}` },
|
|
241
|
+
});
|
|
242
|
+
expect(res.status).toBe(200);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// ─── Admin API key tests ───────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
const ADMIN_TOKEN = "admin-key-scope-star";
|
|
249
|
+
const SCOPED_TOKEN = "scoped-key-for-agent";
|
|
250
|
+
|
|
251
|
+
describe("parseAdminApiKeys", () => {
|
|
252
|
+
it("returns empty map for undefined input", () => {
|
|
253
|
+
const map = parseAdminApiKeys(undefined);
|
|
254
|
+
expect(map.size).toBe(0);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("returns empty map for empty string", () => {
|
|
258
|
+
const map = parseAdminApiKeys("");
|
|
259
|
+
expect(map.size).toBe(0);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("parses a single admin key with scope=*", () => {
|
|
263
|
+
const map = parseAdminApiKeys("admin:admin-key-scope-star:*");
|
|
264
|
+
expect(map.size).toBe(1);
|
|
265
|
+
const entry = map.get("admin-key-scope-star");
|
|
266
|
+
expect(entry).toEqual({ name: "admin", scope: "*" });
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("parses a scoped key with agentId scope", () => {
|
|
270
|
+
const map = parseAdminApiKeys(`svc:${SCOPED_TOKEN}:${AGENT_ID}`);
|
|
271
|
+
expect(map.size).toBe(1);
|
|
272
|
+
expect(map.get(SCOPED_TOKEN)).toEqual({ name: "svc", scope: AGENT_ID });
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("parses multiple keys from comma-separated string", () => {
|
|
276
|
+
const map = parseAdminApiKeys(
|
|
277
|
+
`admin:${ADMIN_TOKEN}:*,svc:${SCOPED_TOKEN}:${AGENT_ID}`,
|
|
278
|
+
);
|
|
279
|
+
expect(map.size).toBe(2);
|
|
280
|
+
expect(map.get(ADMIN_TOKEN)).toEqual({ name: "admin", scope: "*" });
|
|
281
|
+
expect(map.get(SCOPED_TOKEN)).toEqual({ name: "svc", scope: AGENT_ID });
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("handles tokens with embedded colons", () => {
|
|
285
|
+
const map = parseAdminApiKeys("admin:sk:abc:def:*");
|
|
286
|
+
expect(map.size).toBe(1);
|
|
287
|
+
expect(map.get("sk:abc:def")).toEqual({ name: "admin", scope: "*" });
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("skips malformed entries with fewer than 3 parts", () => {
|
|
291
|
+
const map = parseAdminApiKeys("bad-entry,admin:token:*");
|
|
292
|
+
expect(map.size).toBe(1);
|
|
293
|
+
expect(map.get("token")).toEqual({ name: "admin", scope: "*" });
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe("createAdminAuthMiddleware — admin API keys", () => {
|
|
298
|
+
it("admin key with scope=* bypasses all scope enforcement", async () => {
|
|
299
|
+
const adminApiKeys = new Map([
|
|
300
|
+
[ADMIN_TOKEN, { name: "admin", scope: "*" }],
|
|
301
|
+
]);
|
|
302
|
+
// validateFn always returns null — should NOT be called
|
|
303
|
+
let validateCalled = false;
|
|
304
|
+
const app = buildApp(async () => {
|
|
305
|
+
validateCalled = true;
|
|
306
|
+
return null;
|
|
307
|
+
}, adminApiKeys);
|
|
308
|
+
|
|
309
|
+
const res = await app.request(`/agents/${AGENT_ID}/envs`, {
|
|
310
|
+
headers: { Authorization: `Bearer ${ADMIN_TOKEN}` },
|
|
311
|
+
});
|
|
312
|
+
expect(res.status).toBe(200);
|
|
313
|
+
expect(validateCalled).toBe(false);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("admin key accepted for any route (no agent ID in path)", async () => {
|
|
317
|
+
const adminApiKeys = new Map([
|
|
318
|
+
[ADMIN_TOKEN, { name: "admin", scope: "*" }],
|
|
319
|
+
]);
|
|
320
|
+
const app = buildApp(async () => null, adminApiKeys);
|
|
321
|
+
|
|
322
|
+
const res = await app.request("/test", {
|
|
323
|
+
headers: { Authorization: `Bearer ${ADMIN_TOKEN}` },
|
|
324
|
+
});
|
|
325
|
+
expect(res.status).toBe(200);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("scoped admin key enforces agentId match on agent routes", async () => {
|
|
329
|
+
const adminApiKeys = new Map([
|
|
330
|
+
[SCOPED_TOKEN, { name: "svc", scope: AGENT_ID }],
|
|
331
|
+
]);
|
|
332
|
+
const app = buildApp(async () => null, adminApiKeys);
|
|
333
|
+
|
|
334
|
+
const res = await app.request("/agents/different-agent/envs", {
|
|
335
|
+
headers: { Authorization: `Bearer ${SCOPED_TOKEN}` },
|
|
336
|
+
});
|
|
337
|
+
expect(res.status).toBe(403);
|
|
338
|
+
const body = await res.json();
|
|
339
|
+
expect(body.error).toBe("Forbidden");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("scoped admin key allows access when agentId matches scope", async () => {
|
|
343
|
+
const adminApiKeys = new Map([
|
|
344
|
+
[SCOPED_TOKEN, { name: "svc", scope: AGENT_ID }],
|
|
345
|
+
]);
|
|
346
|
+
const app = buildApp(async () => null, adminApiKeys);
|
|
347
|
+
|
|
348
|
+
const res = await app.request(`/agents/${AGENT_ID}/envs`, {
|
|
349
|
+
headers: { Authorization: `Bearer ${SCOPED_TOKEN}` },
|
|
350
|
+
});
|
|
351
|
+
expect(res.status).toBe(200);
|
|
352
|
+
const body = await res.json();
|
|
353
|
+
expect(body.agentId).toBe(AGENT_ID);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("invalid bearer 401 when neither env key nor DB token matches", async () => {
|
|
357
|
+
const adminApiKeys = new Map([
|
|
358
|
+
[ADMIN_TOKEN, { name: "admin", scope: "*" }],
|
|
359
|
+
]);
|
|
360
|
+
const app = buildApp(async () => null, adminApiKeys);
|
|
361
|
+
|
|
362
|
+
const res = await app.request("/test", {
|
|
363
|
+
headers: { Authorization: "Bearer unknown-token" },
|
|
364
|
+
});
|
|
365
|
+
expect(res.status).toBe(401);
|
|
366
|
+
const body = await res.json();
|
|
367
|
+
expect(body.error).toBe("Unauthorized");
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("absent SHIPWRIGHT_ADMIN_API_KEYS env var is a no-op (falls through to DB path)", async () => {
|
|
371
|
+
// No adminApiKeys provided — should fall through to validateFn (DB path)
|
|
372
|
+
let validateCalled = false;
|
|
373
|
+
const app = buildApp(async (raw) => {
|
|
374
|
+
validateCalled = true;
|
|
375
|
+
return raw === VALID_RAW_TOKEN ? { agentId: AGENT_ID } : null;
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const res = await app.request("/test", {
|
|
379
|
+
headers: { Authorization: `Bearer ${VALID_RAW_TOKEN}` },
|
|
380
|
+
});
|
|
381
|
+
expect(res.status).toBe(200);
|
|
382
|
+
expect(validateCalled).toBe(true);
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// ─── Shared Caller tests (AOB-3.4) ─────────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
describe("createAdminAuthMiddleware — shared Caller", () => {
|
|
389
|
+
it("sets caller = {name, scope: '*'} for an admin env key", async () => {
|
|
390
|
+
const adminApiKeys = new Map([
|
|
391
|
+
[ADMIN_TOKEN, { name: "admin", scope: "*" }],
|
|
392
|
+
]);
|
|
393
|
+
const app = buildApp(async () => null, adminApiKeys);
|
|
394
|
+
|
|
395
|
+
const res = await app.request("/test", {
|
|
396
|
+
headers: { Authorization: `Bearer ${ADMIN_TOKEN}` },
|
|
397
|
+
});
|
|
398
|
+
expect(res.status).toBe(200);
|
|
399
|
+
const body = (await res.json()) as { caller: Caller };
|
|
400
|
+
expect(body.caller).toEqual({ name: "admin", scope: "*" });
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("sets caller = {name, scope: agentId} for a scoped env key", async () => {
|
|
404
|
+
const adminApiKeys = new Map([
|
|
405
|
+
[SCOPED_TOKEN, { name: "svc", scope: AGENT_ID }],
|
|
406
|
+
]);
|
|
407
|
+
const app = buildApp(async () => null, adminApiKeys);
|
|
408
|
+
|
|
409
|
+
const res = await app.request(`/agents/${AGENT_ID}/envs`, {
|
|
410
|
+
headers: { Authorization: `Bearer ${SCOPED_TOKEN}` },
|
|
411
|
+
});
|
|
412
|
+
expect(res.status).toBe(200);
|
|
413
|
+
const body = (await res.json()) as { caller: Caller };
|
|
414
|
+
expect(body.caller).toEqual({ name: "svc", scope: AGENT_ID });
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("sets caller = {name: agentId, scope: agentId} for a DB agent token", async () => {
|
|
418
|
+
const app = buildApp(async (raw) =>
|
|
419
|
+
raw === VALID_RAW_TOKEN ? { agentId: AGENT_ID } : null,
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
const res = await app.request("/test", {
|
|
423
|
+
headers: { Authorization: `Bearer ${VALID_RAW_TOKEN}` },
|
|
424
|
+
});
|
|
425
|
+
expect(res.status).toBe(200);
|
|
426
|
+
const body = (await res.json()) as { caller: Caller };
|
|
427
|
+
expect(body.caller).toEqual({ name: AGENT_ID, scope: AGENT_ID });
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("sets caller = {name: email, scope: 'session'} for a session cookie", async () => {
|
|
431
|
+
const app = buildApp(async () => null);
|
|
432
|
+
const jwt = await makeSessionJwt();
|
|
433
|
+
|
|
434
|
+
const res = await app.request("/test", {
|
|
435
|
+
headers: { Cookie: `admin_session=${jwt}` },
|
|
436
|
+
});
|
|
437
|
+
expect(res.status).toBe(200);
|
|
438
|
+
const body = (await res.json()) as { caller: Caller };
|
|
439
|
+
expect(body.caller).toEqual({
|
|
440
|
+
name: "admin@example.com",
|
|
441
|
+
scope: "session",
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// ─── Non-admin cookie scoping ─────────────────────────────────────────────────
|
|
447
|
+
|
|
448
|
+
async function makeMemberJwt(
|
|
449
|
+
email = "Member@Example.com",
|
|
450
|
+
isAdmin?: boolean,
|
|
451
|
+
): Promise<string> {
|
|
452
|
+
return sign(
|
|
453
|
+
{
|
|
454
|
+
...(isAdmin === undefined ? {} : { isAdmin }),
|
|
455
|
+
userId: "user-2",
|
|
456
|
+
email,
|
|
457
|
+
iat: Math.floor(Date.now() / 1000),
|
|
458
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
459
|
+
},
|
|
460
|
+
SESSION_SECRET,
|
|
461
|
+
"HS256",
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function buildScopedApp(memberOf: Array<[string, string]>) {
|
|
466
|
+
const app = new Hono<AdminAuthEnv>();
|
|
467
|
+
app.use(
|
|
468
|
+
"*",
|
|
469
|
+
createAdminAuthMiddleware({
|
|
470
|
+
sessionSecret: SESSION_SECRET,
|
|
471
|
+
agentTokenService: { validate: async () => null },
|
|
472
|
+
agentMemberService: {
|
|
473
|
+
listByEmail: async (email) =>
|
|
474
|
+
memberOf
|
|
475
|
+
.filter(([, e]) => e === email)
|
|
476
|
+
.map(([agentId]) => ({ agentId }) as never),
|
|
477
|
+
},
|
|
478
|
+
}),
|
|
479
|
+
);
|
|
480
|
+
app.all("*", (c) =>
|
|
481
|
+
c.json({
|
|
482
|
+
isAdmin: c.get("isAdmin"),
|
|
483
|
+
callerEmail: c.get("callerEmail"),
|
|
484
|
+
}),
|
|
485
|
+
);
|
|
486
|
+
return app;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
describe("createAdminAuthMiddleware — non-admin session cookie scoping", () => {
|
|
490
|
+
const member: Array<[string, string]> = [[AGENT_ID, "member@example.com"]];
|
|
491
|
+
|
|
492
|
+
it("treats a cookie without the isAdmin claim as non-admin and exposes the lowercased email", async () => {
|
|
493
|
+
const app = buildScopedApp(member);
|
|
494
|
+
const jwt = await makeMemberJwt();
|
|
495
|
+
const res = await app.request("/agents", {
|
|
496
|
+
headers: { Cookie: `admin_session=${jwt}` },
|
|
497
|
+
});
|
|
498
|
+
expect(res.status).toBe(200);
|
|
499
|
+
expect(await res.json()).toEqual({
|
|
500
|
+
isAdmin: false,
|
|
501
|
+
callerEmail: "member@example.com",
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("treats isAdmin: false the same as a missing claim", async () => {
|
|
506
|
+
const app = buildScopedApp(member);
|
|
507
|
+
const jwt = await makeMemberJwt("member@example.com", false);
|
|
508
|
+
const res = await app.request("/agents/other-agent/envs", {
|
|
509
|
+
headers: { Cookie: `admin_session=${jwt}` },
|
|
510
|
+
});
|
|
511
|
+
expect(res.status).toBe(403);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("allows /agents/:id/** for a member agent", async () => {
|
|
515
|
+
const app = buildScopedApp(member);
|
|
516
|
+
const jwt = await makeMemberJwt();
|
|
517
|
+
const res = await app.request(`/agents/${AGENT_ID}/crons`, {
|
|
518
|
+
method: "PATCH",
|
|
519
|
+
headers: { Cookie: `admin_session=${jwt}` },
|
|
520
|
+
});
|
|
521
|
+
expect(res.status).toBe(200);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("returns 403 for an agent outside the membership", async () => {
|
|
525
|
+
const app = buildScopedApp(member);
|
|
526
|
+
const jwt = await makeMemberJwt();
|
|
527
|
+
for (const method of ["GET", "PATCH", "DELETE"]) {
|
|
528
|
+
const res = await app.request("/agents/other-agent", {
|
|
529
|
+
method,
|
|
530
|
+
headers: { Cookie: `admin_session=${jwt}` },
|
|
531
|
+
});
|
|
532
|
+
expect(res.status).toBe(403);
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it("lets a scoped cookie reach POST /agents (handler enforces the account) but not other collection routes", async () => {
|
|
537
|
+
const app = buildScopedApp(member);
|
|
538
|
+
const jwt = await makeMemberJwt();
|
|
539
|
+
const create = await app.request("/agents", {
|
|
540
|
+
method: "POST",
|
|
541
|
+
headers: { Cookie: `admin_session=${jwt}` },
|
|
542
|
+
});
|
|
543
|
+
expect(create.status).toBe(200);
|
|
544
|
+
const cases: Array<[string, string]> = [
|
|
545
|
+
["POST", "/agents/reconcile"],
|
|
546
|
+
["GET", "/agents/all/cron-runs/stats"],
|
|
547
|
+
["GET", "/agents/chat-tokens/daily/stats"],
|
|
548
|
+
];
|
|
549
|
+
for (const [method, path] of cases) {
|
|
550
|
+
const res = await app.request(path, {
|
|
551
|
+
method,
|
|
552
|
+
headers: { Cookie: `admin_session=${jwt}` },
|
|
553
|
+
});
|
|
554
|
+
expect(res.status).toBe(403);
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it("returns 403 for non-agent paths", async () => {
|
|
559
|
+
const app = buildScopedApp(member);
|
|
560
|
+
const jwt = await makeMemberJwt();
|
|
561
|
+
const res = await app.request("/other", {
|
|
562
|
+
headers: { Cookie: `admin_session=${jwt}` },
|
|
563
|
+
});
|
|
564
|
+
expect(res.status).toBe(403);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it("denies agent routes when no agentMemberService is wired (fail closed)", async () => {
|
|
568
|
+
const app = new Hono<AdminAuthEnv>();
|
|
569
|
+
app.use(
|
|
570
|
+
"*",
|
|
571
|
+
createAdminAuthMiddleware({
|
|
572
|
+
sessionSecret: SESSION_SECRET,
|
|
573
|
+
agentTokenService: { validate: async () => null },
|
|
574
|
+
}),
|
|
575
|
+
);
|
|
576
|
+
app.all("*", (c) => c.json({ ok: true }));
|
|
577
|
+
const jwt = await makeMemberJwt();
|
|
578
|
+
const res = await app.request(`/agents/${AGENT_ID}/envs`, {
|
|
579
|
+
headers: { Cookie: `admin_session=${jwt}` },
|
|
580
|
+
});
|
|
581
|
+
expect(res.status).toBe(403);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("admin cookie bypasses membership checks", async () => {
|
|
585
|
+
const app = buildScopedApp([]);
|
|
586
|
+
const jwt = await makeMemberJwt("admin@example.com", true);
|
|
587
|
+
const res = await app.request("/agents/reconcile", {
|
|
588
|
+
method: "POST",
|
|
589
|
+
headers: { Cookie: `admin_session=${jwt}` },
|
|
590
|
+
});
|
|
591
|
+
expect(res.status).toBe(200);
|
|
592
|
+
expect((await res.json()) as { isAdmin: boolean }).toMatchObject({
|
|
593
|
+
isAdmin: true,
|
|
594
|
+
});
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
describe("createAdminAuthMiddleware — account-scoped callers (SSP-2.1)", () => {
|
|
599
|
+
function buildAccountApp() {
|
|
600
|
+
const app = new Hono<AdminAuthEnv>();
|
|
601
|
+
app.use(
|
|
602
|
+
"*",
|
|
603
|
+
createAdminAuthMiddleware({
|
|
604
|
+
sessionSecret: SESSION_SECRET,
|
|
605
|
+
agentTokenService: { validate: async () => null },
|
|
606
|
+
callerScopeResolver: createCallerScopeResolver(
|
|
607
|
+
{
|
|
608
|
+
getAccountIdByEmail: async (e) =>
|
|
609
|
+
e === "a@example.com" ? "acct-a" : null,
|
|
610
|
+
listAgentIdsByAccount: async (id) =>
|
|
611
|
+
id === "acct-a" ? ["agent-in-a"] : [],
|
|
612
|
+
listMemberAgentIds: async () => [],
|
|
613
|
+
},
|
|
614
|
+
true,
|
|
615
|
+
),
|
|
616
|
+
}),
|
|
617
|
+
);
|
|
618
|
+
app.all("*", (c) => c.json({ ok: true }));
|
|
619
|
+
return app;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
it("allows an account member on their account's agent", async () => {
|
|
623
|
+
const jwt = await makeMemberJwt("a@example.com");
|
|
624
|
+
const res = await buildAccountApp().request("/agents/agent-in-a/envs", {
|
|
625
|
+
headers: { Cookie: `admin_session=${jwt}` },
|
|
626
|
+
});
|
|
627
|
+
expect(res.status).toBe(200);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it("denies the same caller on another account's agent", async () => {
|
|
631
|
+
const jwt = await makeMemberJwt("a@example.com");
|
|
632
|
+
const res = await buildAccountApp().request("/agents/agent-in-b/envs", {
|
|
633
|
+
headers: { Cookie: `admin_session=${jwt}` },
|
|
634
|
+
});
|
|
635
|
+
expect(res.status).toBe(403);
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
describe("extractAgentId", () => {
|
|
640
|
+
it("returns the first segment after /agents/", () => {
|
|
641
|
+
expect(extractAgentId("/agents/abc/crons/x")).toBe("abc");
|
|
642
|
+
expect(extractAgentId("/agents/abc")).toBe("abc");
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it("returns null when there is no agent segment", () => {
|
|
646
|
+
expect(extractAgentId("/agents")).toBeNull();
|
|
647
|
+
expect(extractAgentId("/other/abc")).toBeNull();
|
|
648
|
+
});
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
// ─── Platform scopes (COS-28.1) ──────────────────────────────────────────────
|
|
652
|
+
|
|
653
|
+
describe("parseAdminApiKeys — platform scopes", () => {
|
|
654
|
+
it("parses svc:tok:platform.read with scope platform.read and token tok", () => {
|
|
655
|
+
const keys = parseAdminApiKeys("svc:tok:platform.read");
|
|
656
|
+
expect(keys.get("tok")).toEqual({ name: "svc", scope: "platform.read" });
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it("parses platform.ops", () => {
|
|
660
|
+
expect(parseAdminApiKeys("ops:tok2:platform.ops").get("tok2")?.scope).toBe(
|
|
661
|
+
"platform.ops",
|
|
662
|
+
);
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
describe("createAdminAuthMiddleware — platform scopes", () => {
|
|
667
|
+
const READ = "read-token";
|
|
668
|
+
const OPS = "ops-token";
|
|
669
|
+
|
|
670
|
+
function buildPlatformApp(): Hono<AdminAuthEnv> {
|
|
671
|
+
const app = buildApp(
|
|
672
|
+
async () => null,
|
|
673
|
+
parseAdminApiKeys(`r:${READ}:platform.read,o:${OPS}:platform.ops`),
|
|
674
|
+
);
|
|
675
|
+
app.patch("/agents/:id", (c) => {
|
|
676
|
+
requireScope(c, "ops");
|
|
677
|
+
return c.json({ scope: c.get("scope"), isAdmin: c.get("isAdmin") });
|
|
678
|
+
});
|
|
679
|
+
app.delete("/agents/:id", (c) => c.json({ ok: true }));
|
|
680
|
+
app.patch("/agents/:id/envs", (c) => c.json({ ok: true }));
|
|
681
|
+
app.get("/agents", (c) => c.json({ scope: c.get("scope") }));
|
|
682
|
+
return app;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const call = (
|
|
686
|
+
app: Hono<AdminAuthEnv>,
|
|
687
|
+
token: string,
|
|
688
|
+
method: string,
|
|
689
|
+
path: string,
|
|
690
|
+
) =>
|
|
691
|
+
app.request(path, {
|
|
692
|
+
method,
|
|
693
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it("platform.read: 200 on GET /agents, scope kind platform-read, isAdmin false", async () => {
|
|
697
|
+
const res = await call(buildPlatformApp(), READ, "GET", "/agents");
|
|
698
|
+
expect(res.status).toBe(200);
|
|
699
|
+
expect(((await res.json()) as { scope: unknown }).scope).toEqual({
|
|
700
|
+
kind: "platform-read",
|
|
701
|
+
});
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it("platform.read: 200 on GET of an agent-scoped route", async () => {
|
|
705
|
+
const res = await call(
|
|
706
|
+
buildPlatformApp(),
|
|
707
|
+
READ,
|
|
708
|
+
"GET",
|
|
709
|
+
`/agents/${AGENT_ID}/envs`,
|
|
710
|
+
);
|
|
711
|
+
expect(res.status).toBe(200);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it("platform.read: 403 on PATCH /agents/{id}", async () => {
|
|
715
|
+
const res = await call(
|
|
716
|
+
buildPlatformApp(),
|
|
717
|
+
READ,
|
|
718
|
+
"PATCH",
|
|
719
|
+
`/agents/${AGENT_ID}`,
|
|
720
|
+
);
|
|
721
|
+
expect(res.status).toBe(403);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it("platform.ops: 200 on PATCH /agents/{id} with isAdmin false and scope platform-ops", async () => {
|
|
725
|
+
const res = await call(
|
|
726
|
+
buildPlatformApp(),
|
|
727
|
+
OPS,
|
|
728
|
+
"PATCH",
|
|
729
|
+
`/agents/${AGENT_ID}`,
|
|
730
|
+
);
|
|
731
|
+
expect(res.status).toBe(200);
|
|
732
|
+
expect(await res.json()).toEqual({
|
|
733
|
+
scope: { kind: "platform-ops" },
|
|
734
|
+
isAdmin: false,
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it("platform.ops: 403 on PATCH /agents/{id}/envs and DELETE /agents/{id}", async () => {
|
|
739
|
+
const app = buildPlatformApp();
|
|
740
|
+
expect(
|
|
741
|
+
(await call(app, OPS, "PATCH", `/agents/${AGENT_ID}/envs`)).status,
|
|
742
|
+
).toBe(403);
|
|
743
|
+
expect((await call(app, OPS, "DELETE", `/agents/${AGENT_ID}`)).status).toBe(
|
|
744
|
+
403,
|
|
745
|
+
);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it("platform.ops: subpath entries match deeper paths but not look-alike prefixes", async () => {
|
|
749
|
+
const app = buildApp(
|
|
750
|
+
async () => null,
|
|
751
|
+
parseAdminApiKeys(`o:${OPS}:platform.ops`),
|
|
752
|
+
);
|
|
753
|
+
app.delete("/agents/:id/repos/:repo", (c) => c.json({ ok: true }));
|
|
754
|
+
app.delete("/agents/:id/reposx", (c) => c.json({ ok: true }));
|
|
755
|
+
expect(
|
|
756
|
+
(await call(app, OPS, "DELETE", `/agents/${AGENT_ID}/repos/r`)).status,
|
|
757
|
+
).toBe(200);
|
|
758
|
+
expect(
|
|
759
|
+
(await call(app, OPS, "DELETE", `/agents/${AGENT_ID}/reposx`)).status,
|
|
760
|
+
).toBe(403);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
it("agent and admin keys get scope kinds agent / admin", async () => {
|
|
764
|
+
const app = buildApp(
|
|
765
|
+
async () => null,
|
|
766
|
+
parseAdminApiKeys(`a:adm:*,s:svc:${AGENT_ID}`),
|
|
767
|
+
);
|
|
768
|
+
app.get("/who", (c) => c.json(c.get("scope")));
|
|
769
|
+
expect(await (await call(app, "adm", "GET", "/who")).json()).toEqual({
|
|
770
|
+
kind: "admin",
|
|
771
|
+
});
|
|
772
|
+
expect(await (await call(app, "svc", "GET", "/who")).json()).toEqual({
|
|
773
|
+
kind: "agent",
|
|
774
|
+
agentId: AGENT_ID,
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it("PLATFORM_OPS_ROUTES lists the allowlisted mutating routes", () => {
|
|
779
|
+
expect(PLATFORM_OPS_ROUTES.map((r) => `${r.method} ${r.path}`)).toContain(
|
|
780
|
+
"PATCH /agents/{id}",
|
|
781
|
+
);
|
|
782
|
+
});
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
// ─── Agent cross routes (COS-31.1) ──────────────────────────────────────────
|
|
786
|
+
|
|
787
|
+
describe("createAdminAuthMiddleware — agent cross routes", () => {
|
|
788
|
+
function crossApp() {
|
|
789
|
+
const app = new Hono<AdminAuthEnv>();
|
|
790
|
+
app.use(
|
|
791
|
+
"*",
|
|
792
|
+
createAdminAuthMiddleware({
|
|
793
|
+
sessionSecret: SESSION_SECRET,
|
|
794
|
+
agentTokenService: {
|
|
795
|
+
validate: async (raw) =>
|
|
796
|
+
raw === VALID_RAW_TOKEN ? { agentId: AGENT_ID } : null,
|
|
797
|
+
},
|
|
798
|
+
adminApiKeys: new Map([
|
|
799
|
+
[SCOPED_TOKEN, { name: "svc", scope: AGENT_ID }],
|
|
800
|
+
]),
|
|
801
|
+
}),
|
|
802
|
+
);
|
|
803
|
+
app.post("/agents/:id/ask", (c) => c.json({ scope: c.get("scope") }));
|
|
804
|
+
app.post("/agents/:id/envs", (c) => c.json({ ok: true }));
|
|
805
|
+
return app;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
it("lists POST /agents/{id}/ask", () => {
|
|
809
|
+
expect(AGENT_CROSS_ROUTES).toEqual([
|
|
810
|
+
{ method: "POST", path: "/agents/{id}/ask" },
|
|
811
|
+
]);
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
for (const [label, token] of [
|
|
815
|
+
["env-scoped key", SCOPED_TOKEN],
|
|
816
|
+
["DB token", VALID_RAW_TOKEN],
|
|
817
|
+
] as const) {
|
|
818
|
+
it(`${label} may POST another agent's /ask (handler binds from)`, async () => {
|
|
819
|
+
const res = await crossApp().request("/agents/other-agent/ask", {
|
|
820
|
+
method: "POST",
|
|
821
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
822
|
+
});
|
|
823
|
+
expect(res.status).toBe(200);
|
|
824
|
+
expect(await res.json()).toEqual({
|
|
825
|
+
scope: { kind: "agent", agentId: AGENT_ID },
|
|
826
|
+
});
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
it(`${label} is still 403 on another agent's other routes`, async () => {
|
|
830
|
+
const res = await crossApp().request("/agents/other-agent/envs", {
|
|
831
|
+
method: "POST",
|
|
832
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
833
|
+
});
|
|
834
|
+
expect(res.status).toBe(403);
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
});
|