permdock 0.4.0 → 0.5.0
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/README.md +37 -35
- package/dist/{schema-Bol6gqXA.js → 2020-C9VN-x8c.js} +5 -130
- package/dist/a2a/index.d.ts +6 -6
- package/dist/a2a/index.js +14 -17
- package/dist/{adapter-context-mlWsVGGg.js → adapter-context-CRhqyzCk.js} +1 -1
- package/dist/adoption-scan-B4R5fnMU.js +107 -0
- package/dist/agent/index.d.ts +3 -0
- package/dist/agent/index.js +1 -0
- package/dist/ai-sdk/index.d.ts +18 -35
- package/dist/ai-sdk/index.js +37 -23
- package/dist/{app-data-CV8pqqMi.js → app-data-MuyzVqX8.js} +2 -1
- package/dist/approval-header-CJACtHkv.js +23 -0
- package/dist/approval-prompt-Dg5LeAcT.js +62 -0
- package/dist/approval-prompt-Djo_PyZc.d.ts +57 -0
- package/dist/approvals/index.d.ts +5 -3
- package/dist/approvals/index.js +8 -5
- package/dist/approvals-DJ6Yd5z1.js +995 -0
- package/dist/arazzo-B6lebmiQ.js +36 -0
- package/dist/{arazzo-C4BcskbX.js → arazzo-BWhNF-k0.js} +20 -14
- package/dist/args-IIolxxKA.js +197 -0
- package/dist/astro/index.d.ts +43 -0
- package/dist/astro/index.js +27 -0
- package/dist/audience-Dg8XsRaO.js +55 -0
- package/dist/auth-DRLEizbL.js +178 -0
- package/dist/authzen/index.d.ts +26 -6
- package/dist/authzen/index.js +94 -79
- package/dist/automation-BMZPIVUM.d.ts +114 -0
- package/dist/automation-DVjOckjV.js +157 -0
- package/dist/better-auth/index.d.ts +8 -6
- package/dist/better-auth/index.js +9 -8
- package/dist/better-supabase/index.d.ts +357 -30
- package/dist/better-supabase/index.js +936 -194
- package/dist/{bind-BzqG8SMb.d.ts → bind--9EFVYWF.d.ts} +12 -10
- package/dist/{bind-kk-uyCwH.js → bind-CZzhx-Vj.js} +14 -10
- package/dist/body-CH5of8Ne.js +10 -0
- package/dist/body-text-QcXok_SI.js +52 -0
- package/dist/{build-instance-DIIV9DbC.js → build-instance-BMnAudHn.js} +4 -3
- package/dist/{canonical-json-CFKyFOke.js → canonical-json-BC8_nMTw.js} +5 -3
- package/dist/{capability-CksyCWDH.d.ts → capability-BSslw1QV.d.ts} +44 -25
- package/dist/{capability-txATtKV4.js → capability-N1K9IpqS.js} +68 -42
- package/dist/casbin-Cr7RLtA3.js +30 -0
- package/dist/casbin-Dyu2gnUD.js +234 -0
- package/dist/casl-64gpT-8x.js +24 -0
- package/dist/casl-DjEDhTCP.js +299 -0
- package/dist/catalog/index.d.ts +74 -2
- package/dist/catalog/index.js +51 -2
- package/dist/{catalog-tZUyOfOv.js → catalog-BECMPrwc.js} +51 -6
- package/dist/catalog-DjhMYYCq.js +1 -0
- package/dist/catalog-R_A__RsH.js +52 -0
- package/dist/{catalog-doc-DWtg0gGR.js → catalog-doc-C9wjsfNY.js} +45 -23
- package/dist/catalog-fingerprint-CCP0-yQS.js +153 -0
- package/dist/chat/index.d.ts +2 -0
- package/dist/chat/index.js +224 -0
- package/dist/chat-sdk/index.d.ts +87 -0
- package/dist/chat-sdk/index.js +197 -0
- package/dist/claims-map-7oQ3ue4X.js +13 -0
- package/dist/claims-map-Ca-QX8C6.d.ts +32 -0
- package/dist/claude-agent/index.d.ts +7 -14
- package/dist/claude-agent/index.js +24 -27
- package/dist/clerk/index.d.ts +9 -8
- package/dist/clerk/index.js +9 -14
- package/dist/cli/bin.js +36 -17
- package/dist/cli/index.d.ts +19 -5
- package/dist/cli/index.js +164 -4
- package/dist/cloud/index.d.ts +102 -63
- package/dist/cloud/index.js +239 -340
- package/dist/cloud-CIoHNevm.js +87 -0
- package/dist/codemod-Bmj9dWMH.js +37 -0
- package/dist/codemod-Cxvvc70T.js +2084 -0
- package/dist/{collect-2SrB7lfk.js → collect-CrZJJnEF.js} +75 -11
- package/dist/collect-DloHbDVa.js +55 -0
- package/dist/collect-FlXJlhus.js +1 -0
- package/dist/command-L8EstFNm.js +6 -0
- package/dist/compile/index.d.ts +1 -1
- package/dist/compile/index.js +1 -2
- package/dist/{compile-CcEu2ZQ1.js → compile-CMqMx_IN.js} +11 -8
- package/dist/{compile-CtTP7eur.d.ts → compile-Ckm2HqBZ.d.ts} +5 -3
- package/dist/completion-BqhRBJD8.js +143 -0
- package/dist/config-B46rm9sc.js +26 -0
- package/dist/{config-BIjREpyE.js → config-bs_kZeYq.js} +12 -7
- package/dist/{config-report--Xb9APJS.js → config-report-DUgj62Ai.js} +15 -5
- package/dist/{connection-DUsczpB5.js → connection-DjhIJSs2.js} +6 -5
- package/dist/{context-DieZVJXu.js → context-bbFHR-0D.js} +2 -2
- package/dist/convex/index.d.ts +11 -10
- package/dist/convex/index.js +6 -4
- package/dist/create-0VZ7Pdiu.d.ts +107 -0
- package/dist/create-BDS7wc3y.d.ts +138 -0
- package/dist/create-BFfm17UA.d.ts +110 -0
- package/dist/create-BWRSXgOF.js +403 -0
- package/dist/create-C3b4B5eF.js +130 -0
- package/dist/{create-BBO24vj6.js → create-DnrmLnLW.js} +31 -32
- package/dist/{credential-kue0qpbm.js → credential-DM2um3ha.js} +152 -31
- package/dist/credentials-DPyHmYz5.js +74 -0
- package/dist/delegation-DaXVdDhM.js +286 -0
- package/dist/delivery/index.d.ts +5 -0
- package/dist/delivery/index.js +5 -0
- package/dist/delivery-DCAgNyCn.js +499 -0
- package/dist/delivery-event-xX4obB8F.js +40 -0
- package/dist/{denied-text-DfRk15uw.d.ts → denied-text-D8jDHgsl.d.ts} +5 -5
- package/dist/{describe-C3ldA9os.d.ts → describe-BZUsixiL.d.ts} +2 -2
- package/dist/{describe-Cgkp7eP0.js → describe-BybMRY7A.js} +6 -2
- package/dist/device-BARQ8eYG.js +193 -0
- package/dist/diff-CxZo9pTq.js +37 -0
- package/dist/{diff-rpqoe7Ag.js → diff-DCePiN6M.js} +60 -54
- package/dist/doctor-CxiwSfqH.js +44 -0
- package/dist/{doctor-owlv6ddh.js → doctor-DFjUxf2f.js} +245 -50
- package/dist/drizzle/index.d.ts +5 -3
- package/dist/drizzle/index.js +2 -2
- package/dist/elysia/index.d.ts +16 -13
- package/dist/elysia/index.js +13 -12
- package/dist/env-DJZrPjRi.js +8 -0
- package/dist/errors-BDUwg2qC.js +27 -0
- package/dist/{errors-drnfxYtm.js → errors-BKy0EfhN.js} +5 -9
- package/dist/errors-BWXBMaKK.d.ts +11 -0
- package/dist/{errors-OD_4c__H.d.ts → errors-C2PXIv4c.d.ts} +2 -2
- package/dist/errors-CWTY6uih.js +143 -0
- package/dist/evaluate-_R8QkWaf.js +1732 -0
- package/dist/evaluations-16UqqQ6W.d.ts +32 -0
- package/dist/{evaluations-C9YJsv3H.js → evaluations-DZ2CbV5k.js} +145 -34
- package/dist/eve/index.d.ts +12 -16
- package/dist/eve/index.js +22 -28
- package/dist/event-time-CsegxXxB.js +23 -0
- package/dist/expiry-D4_YKRKD.js +52 -0
- package/dist/express/index.d.ts +13 -10
- package/dist/express/index.js +12 -11
- package/dist/fastify/index.d.ts +25 -22
- package/dist/fastify/index.js +13 -12
- package/dist/fetch-adapter-g0ypLkID.js +120 -0
- package/dist/fetch-adapter-qKJBuRqy.d.ts +31 -0
- package/dist/fields-D9tPVUUS.js +203 -0
- package/dist/files-C5ZI27eV.js +124 -0
- package/dist/{fixtures-h2eG2OZV.js → fixtures-B7XrLeY7.js} +8 -7
- package/dist/flags-C3DktBDY.js +86 -0
- package/dist/{from-snapshot-7REQZe0H.js → from-snapshot-D2lZt4Dd.js} +55 -52
- package/dist/{grantee-CXY-0C87.js → grantee-Cn1HyUsI.js} +3 -3
- package/dist/{grantee-conditions-MQs5NIuX.js → grantee-conditions-CcXf8hMM.js} +4 -3
- package/dist/{grantee-match-fxYp9Nmt.js → grantee-match-DHArqY82.js} +5 -4
- package/dist/h3/index.d.ts +37 -0
- package/dist/h3/index.js +35 -0
- package/dist/headers-BIjfMaoC.js +8 -0
- package/dist/headers-CIbDTUTK.d.ts +7 -0
- package/dist/{helpers-C9YmyBIa.js → helpers-DTcZOzhF.js} +45 -38
- package/dist/hono/index.d.ts +12 -9
- package/dist/hono/index.js +13 -12
- package/dist/hosted-BZIAYvon.js +315 -0
- package/dist/hosted-DBDX0M-P.d.ts +78 -0
- package/dist/{http-DPuDaChb.d.ts → http-B_LphE5Y.d.ts} +1 -2
- package/dist/http-DXJ37RDA.js +46 -0
- package/dist/{http-BEbdMZ05.js → http-ujKm0JXE.js} +1 -2
- package/dist/{import-UHk1N7vH.js → import-CNV7I2Dw.js} +25 -20
- package/dist/{import-BFIlhetD.js → import-DMmOG27O.js} +190 -53
- package/dist/index-BcHcPyIo.d.ts +110 -0
- package/dist/index-CaxrMD1A.d.ts +1 -0
- package/dist/index-Chrs5fQ-.d.ts +210 -0
- package/dist/index-CxGUswIC.d.ts +431 -0
- package/dist/index-D8EovfsP.d.ts +91 -0
- package/dist/{index-UdVQgq48.d.ts → index-DOVf7RJv.d.ts} +431 -81
- package/dist/{index-BwMe94Xq.d.ts → index-SGdIQyzc.d.ts} +34 -16
- package/dist/index.d.ts +19 -15
- package/dist/index.js +31 -21
- package/dist/init-DeMYsnYJ.js +96 -0
- package/dist/init-GG6Jh8HD.js +95 -0
- package/dist/{instance-options-plkrsAog.d.ts → instance-options-Omfd793B.d.ts} +2 -2
- package/dist/instance-snapshot-Cxq4ygFc.js +600 -0
- package/dist/instant-BluqDvyS.js +19 -0
- package/dist/internals-Dk8A8fX3.js +17 -0
- package/dist/{json-schema-DBkdUuCO.js → json-schema-B-vAwlde.js} +45 -6
- package/dist/json-schema-CN_iLFH2.d.ts +40 -0
- package/dist/jwt/index.d.ts +18 -11
- package/dist/jwt/index.js +156 -135
- package/dist/{kernel-CB3LrIJM.js → kernel-5JatXyND.js} +52 -24
- package/dist/kysely/index.d.ts +5 -3
- package/dist/kysely/index.js +2 -2
- package/dist/load-BxEtwlBS.js +1 -0
- package/dist/{load-C3_oTHyO.js → load-DFS_ry0W.js} +22 -6
- package/dist/{load-jose-BL6LH4bL.js → load-jose-DZ4dnjri.js} +1 -4
- package/dist/login-Dfe5RlYk.js +68 -0
- package/dist/login-imhuwvn3.js +254 -0
- package/dist/manifest-C8IzW3Do.js +42 -0
- package/dist/manifest-CtKR3GXL.js +370 -0
- package/dist/manifest-json-CAGzls-C.js +9 -0
- package/dist/{manifest-uDS9y5de.d.ts → manifest-mx5ghSFM.d.ts} +9 -2
- package/dist/manifest-schema-C0kK_8V0.js +399 -0
- package/dist/{map-claims-AVlBaJoa.js → map-claims-D3oRmTk0.js} +13 -10
- package/dist/{map-error-BE27PYkE.js → map-error-TCAdw-eW.js} +3 -3
- package/dist/{markers-CiwAOZtO.js → markers-C6joDI3o.js} +2 -1
- package/dist/mcp/index.d.ts +22 -12
- package/dist/mcp/index.js +57 -39
- package/dist/{migrate-BwlB5-Wk.js → migrate-BShP7Vf4.js} +53 -14
- package/dist/nest/index.d.ts +12 -9
- package/dist/nest/index.js +15 -14
- package/dist/next/index.d.ts +18 -21
- package/dist/next/index.js +13 -11
- package/dist/next/plugin.d.ts +1 -1
- package/dist/next/plugin.js +2 -2
- package/dist/node/index.d.ts +10 -10
- package/dist/node/index.js +7 -9
- package/dist/{ocsf-uYFXGVw2.js → ocsf-BFp-PZZA.js} +22 -11
- package/dist/openai/index.d.ts +9 -16
- package/dist/openai/index.js +17 -24
- package/dist/openapi/index.d.ts +3 -3
- package/dist/openapi/index.js +1 -1
- package/dist/{openapi-DBzHQbov.js → openapi-B7HZ0f-r.js} +8 -8
- package/dist/{openapi-BJdBmYDG.js → openapi-DTaEmTSW.js} +10 -9
- package/dist/openapi-ahr8rHu3.js +138 -0
- package/dist/operation-findings-DgATDpvh.js +36 -0
- package/dist/operations-CxybZErY.d.ts +59 -0
- package/dist/orpc/index.d.ts +13 -10
- package/dist/orpc/index.js +20 -15
- package/dist/otel/index.d.ts +3 -2
- package/dist/otel/index.js +30 -7
- package/dist/output-Dyw8fyPF.js +88 -0
- package/dist/output-files-B3IZJksC.js +1 -0
- package/dist/{output-files-CgIsvdSa.js → output-files-DhRBBE4n.js} +24 -26
- package/dist/{permission-keys-B26jeyUv.js → ownership-BatFgAA4.js} +1698 -869
- package/dist/{package-root-DtNnpmUd.js → package-root-Dk_ETdkp.js} +1 -1
- package/dist/{parse-CVFQHk6c.js → parse-Cxq0WxrA.js} +2 -2
- package/dist/{parse-local-manifest-C-q02Pal.js → parse-local-manifest-CVSs6J9I.js} +1 -1
- package/dist/{parse-local-manifest-DvYUR0QL.d.ts → parse-local-manifest-DgqtruPn.d.ts} +1 -1
- package/dist/parse-request-D_tUXG6M.js +26 -0
- package/dist/paths-DXSHAMIa.js +42 -0
- package/dist/pdp/index.d.ts +7 -3
- package/dist/pdp/index.js +45 -26
- package/dist/{peer-BOr_kJE1.js → peer-C8swJ2GH.js} +23 -4
- package/dist/peer-CRvC7Hg1.js +1 -0
- package/dist/permdock-B9JxkBwC.js +868 -0
- package/dist/permdock-D7eeLJER.d.ts +502 -0
- package/dist/{permission-registry-BsblpB-z.js → permission-registry-Br4jSNVi.js} +3 -3
- package/dist/permissions-B-wCP5ZC.js +30 -0
- package/dist/{permissions-CCJ6lmxB.js → permissions-CZKiUfFM.js} +4 -3
- package/dist/{permissions-COH-KRgT.d.ts → permissions-oEWfl-kl.d.ts} +1 -2
- package/dist/policies-CO1oOymN.js +295 -0
- package/dist/{policy-ur0BBK_e.d.ts → policy-Czo4n3Hs.d.ts} +2 -2
- package/dist/{hosted-CBHgkzDl.js → policy-DLZJ5gAp.js} +17 -273
- package/dist/policy-from-catalog-DzhEc3Y9.js +450 -0
- package/dist/policy-module-CTlwMvLI.js +89 -0
- package/dist/{policy-types-sp9P1iIV.d.ts → policy-types-DzwivBgR.d.ts} +434 -631
- package/dist/postgrest-CW0IqC-U.js +566 -0
- package/dist/postgrest-NUqZT-RB.d.ts +100 -0
- package/dist/powersync-BHfuehf3.js +62 -0
- package/dist/powersync-CbzkqL_M.js +1 -0
- package/dist/{powersync-cZe4kzhL.js → powersync-bYUDLOAO.js} +23 -24
- package/dist/preset/index.d.ts +146 -0
- package/dist/preset/index.js +1 -0
- package/dist/preset-DZdU0tne.js +1768 -0
- package/dist/principal-source-pHneukrx.js +103 -0
- package/dist/prisma/index.d.ts +6 -3
- package/dist/prisma/index.js +2 -2
- package/dist/{problem-CnWraVhC.js → problem-CTviyH5L.js} +13 -23
- package/dist/{problem-details-rIIo8B2C.d.ts → problem-details-DXlt-vks.d.ts} +1 -1
- package/dist/{problem-details-DECUnXAn.js → problem-details-cn5X30gZ.js} +43 -40
- package/dist/problem-types-CJ2325SZ.js +5 -0
- package/dist/{prompt-CXYhLfsS.js → prompt-DfZLJLIn.js} +2 -2
- package/dist/{protected-B3_P7xuw.d.ts → protected-DEPHc2xV.d.ts} +16 -8
- package/dist/{protected-BN7BUdY1.js → protected-DfjY5mLA.js} +21 -4
- package/dist/{provider-Bi-0HNL3.js → provider-CC-u3bYP.js} +2 -2
- package/dist/{provider-kwHC9CZD.d.ts → provider-DMVt6yYn.d.ts} +1 -1
- package/dist/{provider-store-Ctt-81Ni.js → provider-store-B5Yrkhzr.js} +2 -2
- package/dist/pull-BgIfno6w.js +256 -0
- package/dist/pull-ByOks_Tg.js +1 -0
- package/dist/push-CmFVBOeI.js +116 -0
- package/dist/pushed-policy-BImMTTIM.d.ts +57 -0
- package/dist/pushed-policy-DobP7AHI.js +238 -0
- package/dist/react/index.d.ts +8 -8
- package/dist/react/index.js +6 -6
- package/dist/react/provider-client.d.ts +1 -1
- package/dist/react/provider-client.js +1 -2
- package/dist/react-native/index.d.ts +24 -72
- package/dist/react-native/index.js +24 -22
- package/dist/{refs-f3imMjNW.js → refs-K6eWUJ0H.js} +1 -1
- package/dist/{relation-walk-BXnlLtg2.js → relation-walk-3OyDBF0S.js} +2 -2
- package/dist/{relations-CvmSpG67.js → relations-BnCWnJGI.js} +3 -3
- package/dist/renames-CGj7YwcQ.js +175 -0
- package/dist/{replay-COpmZUfx.d.ts → replay-D7iKcLFe.d.ts} +1 -1
- package/dist/{replay-BsOyTrdV.js → replay-Du1tJjG7.js} +2 -1
- package/dist/{request-context-BVM7aWNs.js → request-context-BTRTG7OT.js} +3 -3
- package/dist/request-schema-CMreVuFM.js +148 -0
- package/dist/resend/index.d.ts +126 -0
- package/dist/resend/index.js +379 -0
- package/dist/{resource-relations-D1Un3pcz.js → resource-relations-D_jw0BFx.js} +1 -1
- package/dist/{retire-BFcxL6un.js → retire-CALjIQ7e.js} +2 -2
- package/dist/{revocations-Cy06JNx2.d.ts → revocations-R07lFBnX.d.ts} +2 -0
- package/dist/rls-BA8an5Ap.js +336 -0
- package/dist/{rls-BEktUaqg.js → rls-j6t43t7n.js} +287 -689
- package/dist/{role-builder-B4GbKM-i.js → role-builder-CXbis5-X.js} +14 -207
- package/dist/{rpc-nYC3cR_u.js → rpc-aPyNi6tA.js} +7 -7
- package/dist/run-CNsApDpd.js +1 -0
- package/dist/run-DOfMtc9V.js +723 -0
- package/dist/run-ticket-BjhTOi6u.d.ts +124 -0
- package/dist/run-ticket-sxFI6LAk.js +337 -0
- package/dist/{runtime-D_tMXnsq.js → runtime-D3VTAiCA.js} +1 -1
- package/dist/{saas-DtaA4JuW.js → saas-FO-Q8oj0.js} +16 -14
- package/dist/scan-BOI5tJ-6.js +29 -0
- package/dist/{schema-DjQBcAP9.js → schema-B4v0Qrac.js} +102 -8
- package/dist/schema-yLuBWWsh.js +130 -0
- package/dist/scim/index.d.ts +2 -2
- package/dist/scim/index.js +1 -2
- package/dist/{scim-vFR1O5oG.js → scim-ClHtpyV3.js} +208 -76
- package/dist/{scopes-tvpa34ep.js → scopes-CUPSjo9q.js} +12 -7
- package/dist/server/index.d.ts +12 -143
- package/dist/server/index.js +12 -9
- package/dist/server-DqCT4CgZ.js +453 -0
- package/dist/{server-types-DdqBwZN_.d.ts → server-types-BLd4MnBX.d.ts} +51 -68
- package/dist/{sha256-BO0swRXL.js → sha256-D9ihqmvd.js} +8 -1
- package/dist/{shared-1uhI-e-b.js → shared-CD11nBbC.js} +18 -8
- package/dist/{shared-JhMeOsDF.js → shared-DvvFzYXe.js} +8 -9
- package/dist/signals-D3NcTKUS.js +32 -0
- package/dist/sink-BEZSlMS_.js +957 -0
- package/dist/sink-Bj_lDUDq.d.ts +225 -0
- package/dist/{skills-BZdRQkxs.js → skills-B6lVdD_W.js} +13 -6
- package/dist/skills-CTYwkl44.js +51 -0
- package/dist/snapshot-CXUWfsNy.js +269 -0
- package/dist/{snapshot-cache-Cod40D-X.js → snapshot-cache-DZHQ0KF6.js} +11 -5
- package/dist/{snapshot-cache-J5T7t_vD.d.ts → snapshot-cache-DqK9eDmM.d.ts} +7 -5
- package/dist/solid/index.d.ts +11 -12
- package/dist/solid/index.js +7 -7
- package/dist/solid-start/index.d.ts +52 -0
- package/dist/solid-start/index.js +49 -0
- package/dist/{sources-Dljy06zL.d.ts → sources-BV9j0DeZ.d.ts} +7 -13
- package/dist/{sources-B1-b-R0t.js → sources-vs5dGOsS.js} +125 -119
- package/dist/{sql-t8teWOfo.js → sql-B5T1IL83.js} +13 -1
- package/dist/src-DLfUWFAI.js +1851 -0
- package/dist/ssf/index.d.ts +4 -4
- package/dist/ssf/index.js +384 -47
- package/dist/standard-webhooks-6lg8v_is.d.ts +88 -0
- package/dist/standard-webhooks-DgFFD-Ga.js +228 -0
- package/dist/storage-C2bWdvJH.js +132 -0
- package/dist/{store-ILkLUj_o.js → store-4u_hxjWa.js} +40 -53
- package/dist/{store-options-CbUHDoSe.js → store-options-BqEakYRa.js} +26 -21
- package/dist/{stores-BHXdSbw9.js → stores-BbsD4fAR.js} +2 -2
- package/dist/{stores-Cxz_80g-.d.ts → stores-ClZUgeoP.d.ts} +7 -8
- package/dist/{stream-Dwsg6WwE.d.ts → stream-7-xmnf3U.d.ts} +2 -2
- package/dist/{stream-BMui98KK.js → stream-CvdUS9V1.js} +9 -2
- package/dist/style-DiKWnAvn.js +1 -0
- package/dist/{style-CTm1AXDT.js → style-Uao76wO7.js} +3 -2
- package/dist/subject-BGoM5o8B.js +17 -0
- package/dist/supabase/index.d.ts +23 -106
- package/dist/supabase/index.js +5 -4
- package/dist/supabase/middleware.d.ts +7 -6
- package/dist/supabase/middleware.js +13 -11
- package/dist/supabase-9pqI57pW.js +92 -0
- package/dist/{supabase-C1TWlnYt.js → supabase-Dxlp8A94.js} +98 -205
- package/dist/supabase-hook-CXcGpHqq.js +1 -0
- package/dist/{supabase-hook-Cva3ikXz.js → supabase-hook-DdDQhMlw.js} +951 -60
- package/dist/support-session-C6xJQYut.js +158 -0
- package/dist/svelte/Protected.svelte +4 -4
- package/dist/svelte/index.d.ts +7 -7
- package/dist/svelte/index.js +10 -10
- package/dist/svelte/runtime.d.ts +3 -3
- package/dist/svelte/runtime.js +3 -3
- package/dist/svelte/source.d.ts +7 -7
- package/dist/svelte/source.js +4 -4
- package/dist/sveltekit/index.d.ts +51 -0
- package/dist/sveltekit/index.js +27 -0
- package/dist/tanstack-start/index.d.ts +51 -0
- package/dist/tanstack-start/index.js +40 -0
- package/dist/target-Bw37TFNA.js +51 -0
- package/dist/{tenancy-DshjqY0n.js → tenancy-DN2QAx9z.js} +5 -5
- package/dist/terminal/index.d.ts +11 -9
- package/dist/terminal/index.js +23 -287
- package/dist/testing/index.d.ts +311 -38
- package/dist/testing/index.js +3475 -201
- package/dist/testing/saas/index.d.ts +9 -8
- package/dist/testing/saas/index.js +2 -2
- package/dist/testing/saas/permissions.d.ts +1 -1
- package/dist/testing/saas/permissions.js +1 -2
- package/dist/text-BMFRfBqc.js +8 -0
- package/dist/{text-diff-BdkkYSbi.js → text-diff-BQcUapOb.js} +1 -1
- package/dist/thenable-yD7SabqG.js +31 -0
- package/dist/time-DkWjdL98.js +71 -0
- package/dist/{token-Bx9qy30p.js → token-BLZcjQgt.js} +15 -6
- package/dist/{tool-refusal-CQ7OEia9.js → tool-refusal-CMLN5xhn.js} +2 -2
- package/dist/trpc/index.d.ts +12 -9
- package/dist/trpc/index.js +16 -14
- package/dist/{types-7rJVLWe5.d.ts → types-5Ut_pbKy.d.ts} +61 -11
- package/dist/{store-9KO4BlkB.d.ts → types-BNORmDM8.d.ts} +100 -75
- package/dist/types-BjV-fqcA.d.ts +57 -0
- package/dist/{types-CUcFZfpU.d.ts → types-BqAKcRNW.d.ts} +2 -1
- package/dist/types-C8-KNsFf.d.ts +107 -0
- package/dist/{types-Br9IXUBG.d.ts → types-CCpc2Dcv.d.ts} +77 -17
- package/dist/{types-DpWfQdSh.d.ts → types-CZTnPFzM.d.ts} +46 -11
- package/dist/{types-CqrjB9o6.d.ts → types-CdlxsYQh.d.ts} +23 -11
- package/dist/{types-CIlTojhq.d.ts → types-ClZaWzfb.d.ts} +9 -6
- package/dist/types-DX0LaKTb.d.ts +157 -0
- package/dist/types-DiQjupG2.d.ts +332 -0
- package/dist/types-DqzsnlSA.js +67 -0
- package/dist/types-_sHy8BQf.d.ts +65 -0
- package/dist/{types-ByUo56un.d.ts → types-fbZ7JEXr.d.ts} +23 -22
- package/dist/{types-DHrmBJLH.d.ts → types-tIgIeIKJ.d.ts} +7 -7
- package/dist/unplugin/index.d.ts +1 -1
- package/dist/unplugin/index.js +2 -2
- package/dist/usage-CHOtwL19.js +1 -0
- package/dist/usage-DHtE8j4E.js +32 -0
- package/dist/{usage-CG9nOYZw.js → usage-T5mXq98v.js} +54 -11
- package/dist/{validation-error-BSSw0l_8.d.ts → validation-error-Bg5Tpluk.d.ts} +1 -1
- package/dist/{validity-dVrUEPJt.js → validity-CZ7-y0Cj.js} +32 -15
- package/dist/{verifier-DMr_Zmb_.js → verifier-DHuh-okk.js} +18 -7
- package/dist/{verify-BztUB3GX.js → verify-DbCL9bnB.js} +645 -50
- package/dist/{version-BEogC-rB.js → version-CEbQ5UeM.js} +4 -3
- package/dist/version-DXkYUISx.js +1 -0
- package/dist/{views-CJsrBS1f.js → views-DTIIO-oQ.js} +22 -5
- package/dist/{permission-types-Dg39z9Kv.d.ts → vocabulary-D6lPdb4Z.d.ts} +65 -2
- package/dist/vue/index.d.ts +9 -10
- package/dist/vue/index.js +8 -8
- package/dist/waiter-BGUtGYoU.js +67 -0
- package/dist/waiter-CKy4MQSY.d.ts +36 -0
- package/dist/watch-Cb-NcaUw.js +1 -0
- package/dist/{watch-zeucB7eF.js → watch-CszoTJMq.js} +44 -5
- package/dist/webmcp/index.d.ts +2 -2
- package/dist/webmcp/index.js +3 -3
- package/dist/where-scope-Dv-WTP-w.js +306 -0
- package/dist/who-can-D0QoYbEb.js +364 -0
- package/dist/wire-delegation-Cv3W2eaM.js +36 -0
- package/dist/wire-denial-C7PaVYEM.js +27 -0
- package/dist/workflow/index.d.ts +5 -0
- package/dist/workflow/index.js +223 -0
- package/dist/workflow-sdk/index.d.ts +56 -0
- package/dist/workflow-sdk/index.js +78 -0
- package/dist/{wrap-Cr93qqdP.d.ts → wrap-Bd1Tejur.d.ts} +2 -1
- package/dist/{wrap-DWsU_5Bd.js → wrap-gX61EtpB.js} +7 -6
- package/package.json +136 -37
- package/schemas/access-event-v1.json +118 -0
- package/schemas/approval-request-v1.json +34 -1
- package/schemas/approval-signature-v1.json +94 -0
- package/schemas/automation-event-v1.json +113 -0
- package/schemas/capability-v1.json +84 -0
- package/schemas/catalog-event-v1.json +87 -0
- package/schemas/catalog-v1.json +123 -12
- package/schemas/cli-commands-v1.json +164 -0
- package/schemas/cli-result-v1.json +96 -0
- package/schemas/cloud-api-v1.json +5407 -0
- package/schemas/connector-v1.json +408 -0
- package/schemas/credential-event-v1.json +81 -0
- package/schemas/credential-v1.json +134 -0
- package/schemas/decision-event-v1.json +288 -0
- package/schemas/delivery-event-v1.json +117 -0
- package/schemas/directory-event-v1.json +85 -0
- package/schemas/evidence-export-v1.json +54 -0
- package/schemas/membership-event-v1.json +93 -0
- package/schemas/policy-v1.json +68 -0
- package/schemas/pushed-policy-v1.json +236 -0
- package/schemas/run-v1.json +162 -0
- package/schemas/snapshot-v1.json +138 -0
- package/schemas/supabase-claims-v1.json +4 -4
- package/schemas/supabase-manifest-v1.json +5 -1
- package/schemas/support-session-v1.json +111 -0
- package/skills/permdock/SKILL.md +16 -12
- package/skills/permdock-agents/SKILL.md +40 -23
- package/skills/permdock-agents/references/operations-automations-chat.md +221 -0
- package/skills/permdock-agents/references/runtimes.md +22 -18
- package/skills/permdock-approvals/SKILL.md +29 -13
- package/skills/permdock-approvals/references/delivery-and-waiters.md +172 -0
- package/skills/permdock-approvals/references/stores-and-resume.md +21 -7
- package/skills/permdock-audit/SKILL.md +3 -3
- package/skills/permdock-credentials/SKILL.md +5 -5
- package/skills/permdock-credentials/references/tokens-keys-links.md +14 -10
- package/skills/permdock-data/SKILL.md +6 -6
- package/skills/permdock-data/references/queries-and-rls.md +9 -8
- package/skills/permdock-data/references/relationships.md +3 -3
- package/skills/permdock-tenancy/SKILL.md +20 -7
- package/skills/permdock-tenancy/references/contacts-and-support-sessions.md +122 -0
- package/skills/permdock-tenancy/references/ownership-and-custom-roles.md +5 -4
- package/skills/permdock-wire/SKILL.md +17 -11
- package/skills/permdock-wire/references/adapters.md +139 -51
- package/skills/permdock-wire/references/policy-options.md +5 -5
- package/skills/permdock-wire/references/presets-and-recipes.md +58 -0
- package/dist/approvals-DZL76lKW.js +0 -240
- package/dist/arazzo-CD8d0WLb.js +0 -54
- package/dist/catalog-6yWAX6Ps.js +0 -2
- package/dist/catalog-WHG4kBq7.js +0 -48
- package/dist/cloud-C7bSDgX4.js +0 -50
- package/dist/cloud-PeBN3IEn.js +0 -118
- package/dist/collect-Bw2Jdq1q.js +0 -2
- package/dist/collect-CQx4hX4N.js +0 -83
- package/dist/commands-Cpip4ULx.js +0 -97
- package/dist/config-DLt6BvV7.js +0 -36
- package/dist/diff-Cnghh_Ip.js +0 -48
- package/dist/doctor-CuVJvhV_.js +0 -53
- package/dist/duration-xK2jGFVc.js +0 -25
- package/dist/errors-CWfoNg9N.js +0 -47
- package/dist/errors-DRnGZHzF.js +0 -30
- package/dist/expiry-BoBJ8Tvc.js +0 -7
- package/dist/fields-fwfXN01n.js +0 -1016
- package/dist/headers-BZSDieNT.js +0 -6
- package/dist/headers-Cf-zWvsO.d.ts +0 -6
- package/dist/ids-DC4V4CFu.js +0 -18
- package/dist/index-DgM7w96K.d.ts +0 -6
- package/dist/index-Dz7didpC.d.ts +0 -201
- package/dist/load-B_shHrmd.js +0 -2
- package/dist/manifest-BngWKryL.js +0 -770
- package/dist/may-use-BOn_dR_w.js +0 -78
- package/dist/openapi-DcQ1Kg40.js +0 -164
- package/dist/ownership-BgnbOJFT.js +0 -851
- package/dist/permdock-L26udHSk.js +0 -3646
- package/dist/powersync-BFSbSElQ.js +0 -62
- package/dist/powersync-DCDgZgfZ.js +0 -2
- package/dist/rls-55DQTuf5.js +0 -250
- package/dist/run-DMcNW_8T.js +0 -174
- package/dist/server-ZYEHtNHS.js +0 -268
- package/dist/sink-BTtmfPnE.js +0 -141
- package/dist/sink-CPN4zLfs.d.ts +0 -104
- package/dist/skills-Dle4ZoLF.js +0 -56
- package/dist/snapshot-Dc9CHPME.js +0 -1078
- package/dist/src-Bb7no-hS.js +0 -639
- package/dist/subject-C6dLxqHU.js +0 -24
- package/dist/supabase-Dbqr141s.js +0 -83
- package/dist/supabase-hook-BULg7Ut_.js +0 -2
- package/dist/thenable-QSmvt-vZ.js +0 -65
- package/dist/types-C0dee0Qf.d.ts +0 -197
- package/dist/types-DvupZE0w.d.ts +0 -9
- package/dist/usage-4G6t643F.js +0 -42
- package/dist/usage-iFLJ0-rr.js +0 -2
- package/dist/vocabulary-hhEFLFoL.d.ts +0 -65
- package/dist/watch-C8TpsAl4.js +0 -2
package/dist/testing/index.js
CHANGED
|
@@ -1,24 +1,44 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { t as require__2020 } from "../2020-C9VN-x8c.js";
|
|
2
|
+
import { r as isRecord } from "../compact-CAf557ZB.js";
|
|
3
|
+
import { n as freezeDeep } from "../freeze-CrU8nQeX.js";
|
|
4
|
+
import { r as principal } from "../refs-K6eWUJ0H.js";
|
|
5
|
+
import { n as role, r as allow } from "../role-builder-CXbis5-X.js";
|
|
6
|
+
import { E as memoryCustomRoleSource, _ as limits, s as snapshotFor, v as memoryLimitStore, x as approvalPoliciesFor } from "../src-DLfUWFAI.js";
|
|
4
7
|
import { t as memoryRevocationFeed } from "../revocations-Bhr0aMIC.js";
|
|
5
|
-
import {
|
|
6
|
-
import { _ as
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import { n as loadPrincipalByIdentity, t as loadPrincipal } from "../principal-source-pHneukrx.js";
|
|
9
|
+
import { _ as resource, c as listPermissions } from "../permission-registry-Br4jSNVi.js";
|
|
10
|
+
import { d as isoNow, f as nowMillis, p as nowSeconds, u as isoAt } from "../time-DkWjdL98.js";
|
|
11
|
+
import { r as parseAutomation } from "../automation-DVjOckjV.js";
|
|
12
|
+
import { a as normalizeMemberships, d as scopeList } from "../scopes-CUPSjo9q.js";
|
|
13
|
+
import { u as evaluateCondition } from "../delegation-DaXVdDhM.js";
|
|
14
|
+
import { a as authenticated } from "../grantee-Cn1HyUsI.js";
|
|
15
|
+
import { n as definePolicy } from "../policy-DLZJ5gAp.js";
|
|
10
16
|
import { t as byCodePoint } from "../compare-pfyVnBjE.js";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
17
|
+
import { a as mergeHostedGrants, o as parsePolicyDocument } from "../hosted-BZIAYvon.js";
|
|
18
|
+
import { t as definePermissions } from "../permissions-B-wCP5ZC.js";
|
|
19
|
+
import { _ as membershipsClaim, x as validateCustomRole } from "../evaluate-_R8QkWaf.js";
|
|
20
|
+
import { n as parseSnapshot } from "../snapshot-CXUWfsNy.js";
|
|
21
|
+
import { n as fromSnapshot } from "../from-snapshot-D2lZt4Dd.js";
|
|
22
|
+
import { t as getPermDock } from "../permdock-B9JxkBwC.js";
|
|
23
|
+
import { t as APPROVAL_HEADER } from "../types-DqzsnlSA.js";
|
|
24
|
+
import { i as memoryApprovalStore } from "../store-4u_hxjWa.js";
|
|
25
|
+
import { t as catalogFingerprint } from "../catalog-fingerprint-CCP0-yQS.js";
|
|
26
|
+
import { s as parseCredential } from "../credential-DM2um3ha.js";
|
|
27
|
+
import { s as problemFromDecision } from "../problem-CTviyH5L.js";
|
|
28
|
+
import { o as resolveApproval } from "../helpers-DTcZOzhF.js";
|
|
29
|
+
import { t as APPROVAL_REQUEST_SCHEMA } from "../request-schema-CMreVuFM.js";
|
|
30
|
+
import { t as catalogSchema } from "../schema-B4v0Qrac.js";
|
|
31
|
+
import { c as parseApiKey } from "../server-DqCT4CgZ.js";
|
|
32
|
+
import { n as discoverAuthorizationServer } from "../device-BARQ8eYG.js";
|
|
33
|
+
import { T as sha256Hex, n as directoryMembershipSource, r as scimHandler } from "../scim-ClHtpyV3.js";
|
|
34
|
+
import { a as approverHeaders, r as cloudEndpoints, t as cloud } from "../create-BWRSXgOF.js";
|
|
18
35
|
import { t as supabaseMembershipsBudget } from "../budget-NbEtbJC_.js";
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
36
|
+
import { c as quoteSqlTable, n as defaultSqlCommand, o as quoteSqlIdent, t as SQL_IDENT } from "../sql-B5T1IL83.js";
|
|
37
|
+
import { t as supabaseManifestSchema } from "../manifest-schema-C0kK_8V0.js";
|
|
38
|
+
import { t as PUSHED_POLICY_SCHEMA } from "../pushed-policy-DobP7AHI.js";
|
|
39
|
+
import { i as saasPermissions } from "../permissions-CZKiUfFM.js";
|
|
40
|
+
import { C as saasOrg, E as saasSeed, S as saasMemberships, a as saasProject, g as verifySaasSession, h as signSaasToken, k as saasPolicy, t as saasDoc, w as saasPrincipal, x as saasCustomRoles } from "../saas-FO-Q8oj0.js";
|
|
41
|
+
import { t as POLICY_NOT_PUBLISHED } from "../problem-types-CJ2325SZ.js";
|
|
22
42
|
import { afterAll, beforeAll, describe, expect, expectTypeOf, it, vi } from "vitest";
|
|
23
43
|
//#region src/testing/describe-policy.ts
|
|
24
44
|
function isOutcomeCell(value) {
|
|
@@ -51,7 +71,7 @@ function describePolicy(policy, config) {
|
|
|
51
71
|
const permissions = listPermissions(policy.permissions);
|
|
52
72
|
const permdocks = /* @__PURE__ */ new Map();
|
|
53
73
|
beforeAll(async () => {
|
|
54
|
-
for (const [name, user] of Object.entries(config.subjects)) permdocks.set(name, await
|
|
74
|
+
for (const [name, user] of Object.entries(config.subjects)) permdocks.set(name, await getPermDock(policy, user, config.options));
|
|
55
75
|
});
|
|
56
76
|
it("covers every permission", () => {
|
|
57
77
|
if (config.exhaustive === false) return;
|
|
@@ -63,7 +83,7 @@ function describePolicy(policy, config) {
|
|
|
63
83
|
describe(permission.key, () => {
|
|
64
84
|
if (!Object.values(spec).some((value) => !isOutcomeCell(value))) {
|
|
65
85
|
for (const [subjectName, cell] of Object.entries(spec)) it(`${subjectName}`, async () => {
|
|
66
|
-
const instance = permdocks.get(subjectName) ?? await
|
|
86
|
+
const instance = permdocks.get(subjectName) ?? await getPermDock(policy, config.subjects[subjectName], config.options);
|
|
67
87
|
const data = permission.kind === "instance" ? Object.values(config.fixtures ?? {})[0] : void 0;
|
|
68
88
|
assertCell(instance.decide(permission, data), cell);
|
|
69
89
|
assertDeniedBy(instance, permission, data, cell);
|
|
@@ -74,7 +94,7 @@ function describePolicy(policy, config) {
|
|
|
74
94
|
for (const [fixtureName, row] of Object.entries(spec)) {
|
|
75
95
|
if (isOutcomeCell(row)) continue;
|
|
76
96
|
for (const [subjectName, cell] of Object.entries(row)) it(`${fixtureName} / ${subjectName}`, async () => {
|
|
77
|
-
const instance = permdocks.get(subjectName) ?? await
|
|
97
|
+
const instance = permdocks.get(subjectName) ?? await getPermDock(policy, config.subjects[subjectName], config.options);
|
|
78
98
|
const fixture = config.fixtures?.[fixtureName];
|
|
79
99
|
assertCell(instance.decide(permission, fixture), cell);
|
|
80
100
|
assertDeniedBy(instance, permission, fixture, cell);
|
|
@@ -145,14 +165,13 @@ function statementSql(action, table, keyOnly) {
|
|
|
145
165
|
const quoted = quoteSqlTable(table);
|
|
146
166
|
const id = quoteSqlIdent("id");
|
|
147
167
|
const back = keyOnly ? id : "*";
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
case "
|
|
151
|
-
case "get": return `select ${back} from ${quoted} where ${id} = $1`;
|
|
168
|
+
const command = defaultSqlCommand(action) ?? "select";
|
|
169
|
+
switch (command) {
|
|
170
|
+
case "select": return `select ${back} from ${quoted} where ${id} = $1`;
|
|
152
171
|
case "update": return `update ${quoted} set ${id} = ${id} where ${id} = $1 returning ${back}`;
|
|
153
|
-
case "
|
|
172
|
+
case "insert": return `insert into ${quoted} (${id}) values ($1) returning ${back}`;
|
|
154
173
|
case "delete": return `delete from ${quoted} where ${id} = $1 returning ${back}`;
|
|
155
|
-
default: return
|
|
174
|
+
default: return command;
|
|
156
175
|
}
|
|
157
176
|
}
|
|
158
177
|
function valued(row) {
|
|
@@ -177,7 +196,7 @@ function dbOutcome(result) {
|
|
|
177
196
|
if (result.code === "42501") return "rejected";
|
|
178
197
|
return (result.rowCount ?? result.rows.length) > 0 ? "allowed" : "filtered";
|
|
179
198
|
}
|
|
180
|
-
async function
|
|
199
|
+
async function testRlsParity(policy, options) {
|
|
181
200
|
const dialect = options.dialect ?? "guc";
|
|
182
201
|
const gucPrefix = options.gucPrefix ?? "app";
|
|
183
202
|
const tenantClaim = options.tenantClaim ?? "tenant_id";
|
|
@@ -186,12 +205,12 @@ async function rlsParity(policy, options) {
|
|
|
186
205
|
const customRoles = options.customRoles ?? [];
|
|
187
206
|
const scopes = scopeList(policy.scopes);
|
|
188
207
|
async function runCase(fixture) {
|
|
189
|
-
const permdock = await
|
|
208
|
+
const permdock = await getPermDock(policy, toSubject(fixture.subject), { customRoles: memoryCustomRoleSource(customRoles) });
|
|
190
209
|
const granted = fixture.permission.kind === "collection" ? permdock.can(fixture.permission, fixture.row) : permdock.can(fixture.permission, fixture.row);
|
|
191
210
|
let fromClient;
|
|
192
211
|
if (options.snapshot === true) {
|
|
193
212
|
const snapshot = permdock.snapshot();
|
|
194
|
-
if (typeof snapshot !== "object" || snapshot instanceof Promise) throw new TypeError("PermDock:
|
|
213
|
+
if (typeof snapshot !== "object" || snapshot instanceof Promise) throw new TypeError("PermDock: testRlsParity needs an unsigned snapshot");
|
|
195
214
|
fromClient = fromSnapshot(parseSnapshot(JSON.stringify(snapshot))).can(fixture.permission, fixture.row);
|
|
196
215
|
}
|
|
197
216
|
await options.query("begin");
|
|
@@ -251,11 +270,11 @@ function subsetOf(small, large) {
|
|
|
251
270
|
* A partial `where()` (a closure grant it cannot compile) may be refused or
|
|
252
271
|
* return fewer rows, never more.
|
|
253
272
|
*/
|
|
254
|
-
async function
|
|
273
|
+
async function testOrmParity(policy, scenarios, options) {
|
|
255
274
|
const idField = options.id ?? "id";
|
|
256
275
|
async function runCase(scenario) {
|
|
257
|
-
const permdock = await
|
|
258
|
-
if (scenario.options?.relations !== void 0) await permdock.
|
|
276
|
+
const permdock = await getPermDock(policy, scenario.user, scenario.options);
|
|
277
|
+
if (scenario.options?.relations !== void 0) await permdock.prefetchRelations(scenario.permission, scenario.rows);
|
|
259
278
|
const expected = sortedIds(permdock.filter(scenario.permission, scenario.rows).map((row) => row[idField]));
|
|
260
279
|
const where = permdock.where(scenario.permission);
|
|
261
280
|
try {
|
|
@@ -292,7 +311,7 @@ async function ormParity(policy, scenarios, options) {
|
|
|
292
311
|
//#endregion
|
|
293
312
|
//#region src/testing/client-parity.ts
|
|
294
313
|
async function outcomes(policy, entry, options) {
|
|
295
|
-
const server = await
|
|
314
|
+
const server = await getPermDock(policy, entry.user, {
|
|
296
315
|
...entry.tenant === void 0 ? {} : { tenant: entry.tenant },
|
|
297
316
|
...options.customRoles === void 0 ? {} : { customRoles: options.customRoles }
|
|
298
317
|
});
|
|
@@ -317,6 +336,31 @@ function testClientParity(policy, cases, options = {}) {
|
|
|
317
336
|
});
|
|
318
337
|
}
|
|
319
338
|
//#endregion
|
|
339
|
+
//#region src/testing/legacy-parity.ts
|
|
340
|
+
/**
|
|
341
|
+
* Asserts that the policy answers each case as the application's legacy
|
|
342
|
+
* check does, or as the case's `expect` says where the difference is
|
|
343
|
+
* intended. Run it before enforcing a permission with `shadowMode`.
|
|
344
|
+
*/
|
|
345
|
+
function testLegacyParity(policy, legacy, cases) {
|
|
346
|
+
for (const entry of cases) it(`legacy parity: ${entry.name}`, async () => {
|
|
347
|
+
const permdock = await getPermDock(policy, entry.user);
|
|
348
|
+
const ours = permdock.can(entry.permission, entry.row);
|
|
349
|
+
const theirs = legacy({
|
|
350
|
+
permission: entry.permission,
|
|
351
|
+
data: entry.row,
|
|
352
|
+
subject: permdock.subject
|
|
353
|
+
});
|
|
354
|
+
expect({
|
|
355
|
+
permission: entry.permission.key,
|
|
356
|
+
granted: ours
|
|
357
|
+
}).toEqual({
|
|
358
|
+
permission: entry.permission.key,
|
|
359
|
+
granted: entry.expect ?? theirs
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
//#endregion
|
|
320
364
|
//#region src/testing/client-store.ts
|
|
321
365
|
const p = saasPermissions;
|
|
322
366
|
function snapshotOf(user, tenant, tenants) {
|
|
@@ -344,7 +388,7 @@ function leadSnapshot() {
|
|
|
344
388
|
]
|
|
345
389
|
});
|
|
346
390
|
}
|
|
347
|
-
function json(value, status = 200) {
|
|
391
|
+
function json$1(value, status = 200) {
|
|
348
392
|
return new Response(JSON.stringify(value), {
|
|
349
393
|
status,
|
|
350
394
|
headers: { "content-type": "application/json" }
|
|
@@ -391,7 +435,7 @@ function stubVerifier(tokens) {
|
|
|
391
435
|
claims: { snapshot },
|
|
392
436
|
header: {
|
|
393
437
|
alg: "ES256",
|
|
394
|
-
typ: "permdock
|
|
438
|
+
typ: "vnd.permdock.snapshot+jwt"
|
|
395
439
|
}
|
|
396
440
|
});
|
|
397
441
|
} };
|
|
@@ -442,7 +486,7 @@ function testClientStore(name, createStore) {
|
|
|
442
486
|
});
|
|
443
487
|
it("fetches another tenant with one query string and commits it only on success", async () => {
|
|
444
488
|
let fail = true;
|
|
445
|
-
const net = recorder(() => fail ? json({ error: "down" }, 503) : json(snapshotOf("erin", "globex")));
|
|
489
|
+
const net = recorder(() => fail ? json$1({ error: "down" }, 503) : json$1(snapshotOf("erin", "globex")));
|
|
446
490
|
const store = createStore({
|
|
447
491
|
snapshot: snapshotOf("erin", "acme"),
|
|
448
492
|
snapshotUrl: "/api/snapshot?v=1",
|
|
@@ -471,7 +515,7 @@ function testClientStore(name, createStore) {
|
|
|
471
515
|
});
|
|
472
516
|
const pending = store.get().refresh();
|
|
473
517
|
expect(store.get().status()).toBe("pending");
|
|
474
|
-
gate.resolve(json(snapshotOf("erin", "acme")));
|
|
518
|
+
gate.resolve(json$1(snapshotOf("erin", "acme")));
|
|
475
519
|
await pending;
|
|
476
520
|
expect(store.get().status()).toBe("ready");
|
|
477
521
|
});
|
|
@@ -486,7 +530,7 @@ function testClientStore(name, createStore) {
|
|
|
486
530
|
});
|
|
487
531
|
const pending = store.get().refresh();
|
|
488
532
|
store.get().clear();
|
|
489
|
-
gate.resolve(json(snapshotOf("erin", "acme")));
|
|
533
|
+
gate.resolve(json$1(snapshotOf("erin", "acme")));
|
|
490
534
|
await pending;
|
|
491
535
|
await settle();
|
|
492
536
|
expect(store.get().subject.principal).toBeNull();
|
|
@@ -504,7 +548,7 @@ function testClientStore(name, createStore) {
|
|
|
504
548
|
});
|
|
505
549
|
const pending = store.get().refresh({ tenant: "globex" });
|
|
506
550
|
store.replace(snapshotOf("bob", "acme"));
|
|
507
|
-
gate.resolve(json(snapshotOf("erin", "globex")));
|
|
551
|
+
gate.resolve(json$1(snapshotOf("erin", "globex")));
|
|
508
552
|
await pending;
|
|
509
553
|
await settle();
|
|
510
554
|
expect(store.get().subject.principal?.id).toBe("bob");
|
|
@@ -516,7 +560,7 @@ function testClientStore(name, createStore) {
|
|
|
516
560
|
const store = createStore({
|
|
517
561
|
snapshot: {
|
|
518
562
|
...snapshotOf("erin", "acme"),
|
|
519
|
-
expiresAt:
|
|
563
|
+
expiresAt: "1970-01-01T00:00:01.000Z"
|
|
520
564
|
},
|
|
521
565
|
tenant: "acme",
|
|
522
566
|
fetch: net.fetch
|
|
@@ -557,7 +601,7 @@ function testClientStore(name, createStore) {
|
|
|
557
601
|
snapshot: seed,
|
|
558
602
|
snapshotUrl: "/api/snapshot",
|
|
559
603
|
tenant: "acme",
|
|
560
|
-
fetch: recorder(() => json(next)).fetch,
|
|
604
|
+
fetch: recorder(() => json$1(next)).fetch,
|
|
561
605
|
verifier: stubVerifier(/* @__PURE__ */ new Map([[seed, snapshotOf("erin", "acme")], [next, snapshotOf("erin", "globex")]]))
|
|
562
606
|
});
|
|
563
607
|
await settle();
|
|
@@ -569,7 +613,7 @@ function testClientStore(name, createStore) {
|
|
|
569
613
|
it("caches endpoint answers per tenant and settles the store status", async () => {
|
|
570
614
|
const net = recorder((call) => {
|
|
571
615
|
const body = call.body;
|
|
572
|
-
return json({ evaluations: body.evaluations.map(() => ({
|
|
616
|
+
return json$1({ evaluations: body.evaluations.map(() => ({
|
|
573
617
|
decision: false,
|
|
574
618
|
context: { permdock: denied() }
|
|
575
619
|
})) });
|
|
@@ -600,7 +644,7 @@ function testClientStore(name, createStore) {
|
|
|
600
644
|
it("forgets endpoint answers when a new snapshot arrives", async () => {
|
|
601
645
|
const net = recorder((call) => {
|
|
602
646
|
const body = call.body;
|
|
603
|
-
return json({ evaluations: body.evaluations.map(() => ({
|
|
647
|
+
return json$1({ evaluations: body.evaluations.map(() => ({
|
|
604
648
|
decision: false,
|
|
605
649
|
context: { permdock: denied() }
|
|
606
650
|
})) });
|
|
@@ -677,7 +721,7 @@ function testClientStore(name, createStore) {
|
|
|
677
721
|
}
|
|
678
722
|
});
|
|
679
723
|
it("does not notify subscribers synchronously from a status read", () => {
|
|
680
|
-
const net = recorder(() => json({ evaluations: [] }));
|
|
724
|
+
const net = recorder(() => json$1({ evaluations: [] }));
|
|
681
725
|
const store = createStore({
|
|
682
726
|
snapshot: snapshotOf("gina", "acme"),
|
|
683
727
|
endpoint: "/api/permdock",
|
|
@@ -696,7 +740,7 @@ function testClientStore(name, createStore) {
|
|
|
696
740
|
//#endregion
|
|
697
741
|
//#region src/testing/snapshot-fixture.ts
|
|
698
742
|
async function snapshotFixture(policy, subject, options = {}) {
|
|
699
|
-
const instance = await
|
|
743
|
+
const instance = await getPermDock(policy, subject, options.tenant === void 0 ? {} : { tenant: options.tenant });
|
|
700
744
|
const target = options.simulated === true ? instance.simulate({}) : instance;
|
|
701
745
|
if (Array.isArray(target)) throw new Error("PermDock: snapshotFixture expected a PermDock instance");
|
|
702
746
|
const snapshot = options.include === void 0 && options.tenants === void 0 ? target.snapshot() : target.snapshot({
|
|
@@ -744,7 +788,7 @@ async function* ticks(org, signal) {
|
|
|
744
788
|
function demotionKey(user, org) {
|
|
745
789
|
return JSON.stringify([user, org]);
|
|
746
790
|
}
|
|
747
|
-
function createDomain$
|
|
791
|
+
function createDomain$2() {
|
|
748
792
|
const demoted = /* @__PURE__ */ new Set();
|
|
749
793
|
const principalOf = (sub, path) => {
|
|
750
794
|
const principal = saasPrincipal(sub, orgOf(path));
|
|
@@ -777,14 +821,14 @@ function createDomain$1() {
|
|
|
777
821
|
};
|
|
778
822
|
},
|
|
779
823
|
org: orgOf,
|
|
780
|
-
customRoles:
|
|
824
|
+
customRoles: memoryCustomRoleSource(saasCustomRoles),
|
|
781
825
|
store: memoryApprovalStore(),
|
|
782
|
-
limits: memoryLimitStore(),
|
|
826
|
+
limits: limits(memoryLimitStore()),
|
|
783
827
|
project: (id) => id === void 0 ? null : saasSeed.projects.find((row) => row.id === id) ?? null
|
|
784
828
|
};
|
|
785
829
|
}
|
|
786
830
|
async function approverSubject(user, tenant) {
|
|
787
|
-
return (await
|
|
831
|
+
return (await getPermDock(saasPolicy, saasPrincipal(user, tenant), { tenant })).subject;
|
|
788
832
|
}
|
|
789
833
|
//#endregion
|
|
790
834
|
//#region src/testing/http-adapter/wire.ts
|
|
@@ -991,7 +1035,7 @@ function testHttpAdapter(options) {
|
|
|
991
1035
|
it.skipIf(reason !== void 0)(reason === void 0 ? title : `${title} (${reason})`, run, timeout);
|
|
992
1036
|
};
|
|
993
1037
|
describe(`${options.name}: real-life HTTP scenarios`, () => {
|
|
994
|
-
const domain = createDomain$
|
|
1038
|
+
const domain = createDomain$2();
|
|
995
1039
|
const tokens = /* @__PURE__ */ new Map();
|
|
996
1040
|
let mounted;
|
|
997
1041
|
beforeAll(async () => {
|
|
@@ -1168,7 +1212,7 @@ function testHttpAdapter(options) {
|
|
|
1168
1212
|
});
|
|
1169
1213
|
expect(denied.status).toBe(403);
|
|
1170
1214
|
expect(field(denied.body, "type")).toBe("https://permdock.com/problems/denied");
|
|
1171
|
-
const bob = await
|
|
1215
|
+
const bob = await getPermDock(saasPolicy, saasPrincipal("bob", "acme"), { tenant: "acme" });
|
|
1172
1216
|
const permission = saasPolicy.permissions.project.delete;
|
|
1173
1217
|
const fromProtect = await readResult(problemFromDecision(bob.decide(permission, project$1("p2")), permission, bob.subject));
|
|
1174
1218
|
expect(denied.status).toBe(fromProtect.status);
|
|
@@ -1304,7 +1348,7 @@ function testHttpAdapter(options) {
|
|
|
1304
1348
|
});
|
|
1305
1349
|
const end = await untilEnd(stream);
|
|
1306
1350
|
expect(end.problem).toMatchObject({
|
|
1307
|
-
type: "
|
|
1351
|
+
type: "about:blank",
|
|
1308
1352
|
status: 401,
|
|
1309
1353
|
detail: "session-revoked"
|
|
1310
1354
|
});
|
|
@@ -1364,7 +1408,7 @@ function testHttpAdapter(options) {
|
|
|
1364
1408
|
expect((await stream.next())?.event).toBe("message");
|
|
1365
1409
|
const end = await untilEnd(stream);
|
|
1366
1410
|
expect(end.problem).toMatchObject({
|
|
1367
|
-
type: "
|
|
1411
|
+
type: "about:blank",
|
|
1368
1412
|
detail: "expired"
|
|
1369
1413
|
});
|
|
1370
1414
|
expect(end.ended).toBe(true);
|
|
@@ -1470,12 +1514,12 @@ const SCENARIOS = [
|
|
|
1470
1514
|
expected: "approval-required"
|
|
1471
1515
|
}
|
|
1472
1516
|
];
|
|
1473
|
-
function loadProject(args) {
|
|
1517
|
+
function loadProject$1(args) {
|
|
1474
1518
|
const id = typeof args === "object" && args !== null ? args.id : void 0;
|
|
1475
1519
|
if (id === "throws") throw new Error("project store unavailable");
|
|
1476
1520
|
return saasSeed.projects.find((row) => row.id === id) ?? null;
|
|
1477
1521
|
}
|
|
1478
|
-
function createDomain() {
|
|
1522
|
+
function createDomain$1() {
|
|
1479
1523
|
return {
|
|
1480
1524
|
policy: saasPolicy,
|
|
1481
1525
|
subject: (user, org) => {
|
|
@@ -1485,11 +1529,11 @@ function createDomain() {
|
|
|
1485
1529
|
tools: {
|
|
1486
1530
|
project_read: {
|
|
1487
1531
|
permission: saasPermissions.project.read,
|
|
1488
|
-
data: loadProject
|
|
1532
|
+
data: loadProject$1
|
|
1489
1533
|
},
|
|
1490
1534
|
api_keys_revoke_all: { permission: saasPermissions.apiKey.revokeAll }
|
|
1491
1535
|
},
|
|
1492
|
-
customRoles:
|
|
1536
|
+
customRoles: memoryCustomRoleSource(saasCustomRoles),
|
|
1493
1537
|
store: memoryApprovalStore()
|
|
1494
1538
|
};
|
|
1495
1539
|
}
|
|
@@ -1499,7 +1543,7 @@ function createDomain() {
|
|
|
1499
1543
|
*/
|
|
1500
1544
|
function testAgentAdapter(options) {
|
|
1501
1545
|
describe(`${options.name}: agent adapter scenarios`, () => {
|
|
1502
|
-
const domain = createDomain();
|
|
1546
|
+
const domain = createDomain$1();
|
|
1503
1547
|
let mounted;
|
|
1504
1548
|
beforeAll(async () => {
|
|
1505
1549
|
mounted = await options.mount(domain);
|
|
@@ -1517,12 +1561,201 @@ function testAgentAdapter(options) {
|
|
|
1517
1561
|
});
|
|
1518
1562
|
}
|
|
1519
1563
|
//#endregion
|
|
1564
|
+
//#region src/testing/agent-runtime.ts
|
|
1565
|
+
const alice = {
|
|
1566
|
+
user: "alice",
|
|
1567
|
+
org: "acme"
|
|
1568
|
+
};
|
|
1569
|
+
const erin = {
|
|
1570
|
+
user: "erin",
|
|
1571
|
+
org: "globex"
|
|
1572
|
+
};
|
|
1573
|
+
const mallory = {
|
|
1574
|
+
user: "mallory",
|
|
1575
|
+
org: "acme"
|
|
1576
|
+
};
|
|
1577
|
+
const OPERATIONS = [
|
|
1578
|
+
{
|
|
1579
|
+
name: "granted",
|
|
1580
|
+
title: "grants a member reading a project in their org",
|
|
1581
|
+
context: alice,
|
|
1582
|
+
operation: "project_read",
|
|
1583
|
+
input: { id: "p1" },
|
|
1584
|
+
expected: "granted"
|
|
1585
|
+
},
|
|
1586
|
+
{
|
|
1587
|
+
name: "denied",
|
|
1588
|
+
title: "denies a user with no membership",
|
|
1589
|
+
context: mallory,
|
|
1590
|
+
operation: "project_read",
|
|
1591
|
+
input: { id: "p1" },
|
|
1592
|
+
expected: "denied"
|
|
1593
|
+
},
|
|
1594
|
+
{
|
|
1595
|
+
name: "cross-tenant",
|
|
1596
|
+
title: "denies a row from another org",
|
|
1597
|
+
context: alice,
|
|
1598
|
+
operation: "project_read",
|
|
1599
|
+
input: { id: "g1" },
|
|
1600
|
+
expected: "denied"
|
|
1601
|
+
},
|
|
1602
|
+
{
|
|
1603
|
+
name: "anonymous",
|
|
1604
|
+
title: "denies a call with no user",
|
|
1605
|
+
context: {
|
|
1606
|
+
user: null,
|
|
1607
|
+
org: "acme"
|
|
1608
|
+
},
|
|
1609
|
+
operation: "project_read",
|
|
1610
|
+
input: { id: "p1" },
|
|
1611
|
+
expected: "denied"
|
|
1612
|
+
},
|
|
1613
|
+
{
|
|
1614
|
+
name: "subject-throws",
|
|
1615
|
+
title: "denies when the subject resolver throws",
|
|
1616
|
+
context: {
|
|
1617
|
+
user: AGENT_THROWING_USER,
|
|
1618
|
+
org: "acme"
|
|
1619
|
+
},
|
|
1620
|
+
operation: "project_read",
|
|
1621
|
+
input: { id: "p1" },
|
|
1622
|
+
expected: "denied"
|
|
1623
|
+
},
|
|
1624
|
+
{
|
|
1625
|
+
name: "loader-throws",
|
|
1626
|
+
title: "denies when the row loader throws",
|
|
1627
|
+
context: alice,
|
|
1628
|
+
operation: "project_read",
|
|
1629
|
+
input: { id: AGENT_THROWING_ROW },
|
|
1630
|
+
expected: "denied"
|
|
1631
|
+
},
|
|
1632
|
+
{
|
|
1633
|
+
name: "missing-row",
|
|
1634
|
+
title: "denies when the row does not exist",
|
|
1635
|
+
context: alice,
|
|
1636
|
+
operation: "project_read",
|
|
1637
|
+
input: { id: "missing" },
|
|
1638
|
+
expected: "denied"
|
|
1639
|
+
},
|
|
1640
|
+
{
|
|
1641
|
+
name: "approval-required",
|
|
1642
|
+
title: "asks for approval before revoking every API key",
|
|
1643
|
+
context: alice,
|
|
1644
|
+
operation: "api_keys_revoke_all",
|
|
1645
|
+
input: {},
|
|
1646
|
+
expected: "approval-required"
|
|
1647
|
+
},
|
|
1648
|
+
{
|
|
1649
|
+
name: "unmapped-operation",
|
|
1650
|
+
title: "denies an operation the map does not name",
|
|
1651
|
+
context: alice,
|
|
1652
|
+
operation: "drop_database",
|
|
1653
|
+
input: {},
|
|
1654
|
+
expected: "denied"
|
|
1655
|
+
}
|
|
1656
|
+
];
|
|
1657
|
+
function loadProject(args) {
|
|
1658
|
+
const id = typeof args === "object" && args !== null ? args.id : void 0;
|
|
1659
|
+
if (id === "throws") throw new Error("project store unavailable");
|
|
1660
|
+
return saasSeed.projects.find((row) => row.id === id) ?? null;
|
|
1661
|
+
}
|
|
1662
|
+
function createDomain() {
|
|
1663
|
+
return {
|
|
1664
|
+
policy: saasPolicy,
|
|
1665
|
+
subject: (user, org) => {
|
|
1666
|
+
if (user === "throws") throw new Error("session store unavailable");
|
|
1667
|
+
return user === null ? null : saasPrincipal(user, org);
|
|
1668
|
+
},
|
|
1669
|
+
operations: {
|
|
1670
|
+
project_read: {
|
|
1671
|
+
permission: saasPermissions.project.read,
|
|
1672
|
+
data: loadProject
|
|
1673
|
+
},
|
|
1674
|
+
project_search: {
|
|
1675
|
+
permission: saasPermissions.project.list,
|
|
1676
|
+
results: saasPermissions.project.read
|
|
1677
|
+
},
|
|
1678
|
+
api_keys_revoke_all: { permission: saasPermissions.apiKey.revokeAll }
|
|
1679
|
+
},
|
|
1680
|
+
models: {
|
|
1681
|
+
standard: saasPermissions.integration.read,
|
|
1682
|
+
frontier: saasPermissions.analytics.read
|
|
1683
|
+
},
|
|
1684
|
+
customRoles: memoryCustomRoleSource(saasCustomRoles),
|
|
1685
|
+
store: memoryApprovalStore()
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
function projects(...ids) {
|
|
1689
|
+
return ids.flatMap((id) => saasSeed.projects.filter((row) => row.id === id));
|
|
1690
|
+
}
|
|
1691
|
+
/**
|
|
1692
|
+
* Runs the `AgentPermDock` contract against a runtime: operations decided by
|
|
1693
|
+
* name, the listing hint, the model gate, the results filter and gateway
|
|
1694
|
+
* attribution. Every failure denies, and no method throws.
|
|
1695
|
+
*/
|
|
1696
|
+
function testAgentRuntime(options) {
|
|
1697
|
+
describe(`${options.name}: agent runtime contract`, () => {
|
|
1698
|
+
const domain = createDomain();
|
|
1699
|
+
let mounted;
|
|
1700
|
+
beforeAll(async () => {
|
|
1701
|
+
mounted = await options.mount(domain);
|
|
1702
|
+
});
|
|
1703
|
+
afterAll(async () => {
|
|
1704
|
+
await mounted.close?.();
|
|
1705
|
+
});
|
|
1706
|
+
const agent = () => mounted.agent;
|
|
1707
|
+
const scenario = (name, title, run) => {
|
|
1708
|
+
const reason = options.skip?.[name];
|
|
1709
|
+
it.skipIf(reason !== void 0)(reason === void 0 ? title : `${title} (${reason})`, run);
|
|
1710
|
+
};
|
|
1711
|
+
for (const entry of OPERATIONS) scenario(entry.name, entry.title, async () => {
|
|
1712
|
+
const verdict = await agent().decideOperation(entry.operation, entry.input, entry.context);
|
|
1713
|
+
expect(verdict.outcome).toBe(entry.expected);
|
|
1714
|
+
});
|
|
1715
|
+
scenario("allowed-operations", "lists the operations a subject could be granted, and none for a stranger", async () => {
|
|
1716
|
+
const listed = await agent().allowedOperations(alice);
|
|
1717
|
+
expect(listed.has("project_read")).toBe(true);
|
|
1718
|
+
expect(listed.has("drop_database")).toBe(false);
|
|
1719
|
+
expect([...await agent().allowedOperations(mallory)]).toEqual([]);
|
|
1720
|
+
});
|
|
1721
|
+
scenario("model-gate", "grants a model by its permission and denies one the map does not name", async () => {
|
|
1722
|
+
expect((await agent().decideModel("frontier", erin)).outcome).toBe("granted");
|
|
1723
|
+
expect((await agent().decideModel("frontier", alice)).outcome).toBe("denied");
|
|
1724
|
+
expect((await agent().decideModel("unlisted", erin)).outcome).toBe("denied");
|
|
1725
|
+
expect((await agent().decideModel("standard", {
|
|
1726
|
+
user: AGENT_THROWING_USER,
|
|
1727
|
+
org: "acme"
|
|
1728
|
+
})).outcome).toBe("denied");
|
|
1729
|
+
});
|
|
1730
|
+
scenario("permitted-models", "returns the granted models in declaration order", async () => {
|
|
1731
|
+
expect(await agent().permittedModels(erin)).toEqual(["standard", "frontier"]);
|
|
1732
|
+
expect(await agent().permittedModels(alice)).toEqual(["standard"]);
|
|
1733
|
+
expect(await agent().permittedModels(mallory)).toEqual([]);
|
|
1734
|
+
});
|
|
1735
|
+
scenario("results-filter", "keeps the rows the results permission grants, and none for an unknown operation", async () => {
|
|
1736
|
+
const rows = projects("p1", "g1", "p2");
|
|
1737
|
+
expect((await agent().filterResults("project_search", rows, alice)).map((row) => row.id)).toEqual(["p1", "p2"]);
|
|
1738
|
+
expect(await agent().filterResults("project_search", rows, mallory)).toEqual([]);
|
|
1739
|
+
expect(await agent().filterResults("drop_database", rows, alice)).toEqual([]);
|
|
1740
|
+
});
|
|
1741
|
+
scenario("gateway-options", "attributes gateway usage to the principal, and to nobody for an anonymous call", async () => {
|
|
1742
|
+
const attributed = await agent().gatewayOptions(alice);
|
|
1743
|
+
expect(attributed.gateway.user).toBe("alice");
|
|
1744
|
+
const anonymous = await agent().gatewayOptions({
|
|
1745
|
+
user: null,
|
|
1746
|
+
org: "acme"
|
|
1747
|
+
});
|
|
1748
|
+
expect(anonymous.gateway.user).toBeUndefined();
|
|
1749
|
+
});
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
//#endregion
|
|
1520
1753
|
//#region src/testing/authzen.ts
|
|
1521
1754
|
function sortKey(value) {
|
|
1522
1755
|
return JSON.stringify(Object.fromEntries(Object.entries(value).toSorted(([a], [b]) => byCodePoint(a, b))));
|
|
1523
1756
|
}
|
|
1524
1757
|
/** Runs AuthZEN 1.0 interop vectors against an Authorization API endpoint. */
|
|
1525
|
-
function
|
|
1758
|
+
function testAuthzen(handle, options) {
|
|
1526
1759
|
const origin = options.origin ?? "https://pdp.example.com";
|
|
1527
1760
|
const post = async (path, body) => {
|
|
1528
1761
|
const response = await handle(new Request(`${origin}${path}`, {
|
|
@@ -1628,7 +1861,7 @@ const authzenTodoPolicy = definePolicy(authzenTodoPermissions, {
|
|
|
1628
1861
|
role("evil_genius", [...everyone, allow(todo.can_update_todo)]),
|
|
1629
1862
|
role("pep", [])
|
|
1630
1863
|
],
|
|
1631
|
-
|
|
1864
|
+
subject: (user) => {
|
|
1632
1865
|
const id = user !== null && typeof user === "object" && "id" in user ? user.id : void 0;
|
|
1633
1866
|
if (id === "pep") return {
|
|
1634
1867
|
id: "pep",
|
|
@@ -1776,6 +2009,7 @@ function authzenTodoVectors() {
|
|
|
1776
2009
|
function sampleApproval(token) {
|
|
1777
2010
|
return {
|
|
1778
2011
|
v: 1,
|
|
2012
|
+
id: `apr_${token}`,
|
|
1779
2013
|
token,
|
|
1780
2014
|
permission: "post.delete",
|
|
1781
2015
|
scope: "post:delete",
|
|
@@ -1794,8 +2028,8 @@ function sampleApproval(token) {
|
|
|
1794
2028
|
}
|
|
1795
2029
|
},
|
|
1796
2030
|
detail: "post.delete requires human approval.",
|
|
1797
|
-
createdAt: (
|
|
1798
|
-
expiresAt:
|
|
2031
|
+
createdAt: isoNow(),
|
|
2032
|
+
expiresAt: isoAt(nowMillis() + 36e5),
|
|
1799
2033
|
status: "pending"
|
|
1800
2034
|
};
|
|
1801
2035
|
}
|
|
@@ -1813,7 +2047,7 @@ const secondApprover = {
|
|
|
1813
2047
|
},
|
|
1814
2048
|
context: {}
|
|
1815
2049
|
};
|
|
1816
|
-
const admin = {
|
|
2050
|
+
const admin$2 = {
|
|
1817
2051
|
kind: "role",
|
|
1818
2052
|
role: "admin",
|
|
1819
2053
|
scope: "global"
|
|
@@ -1905,7 +2139,7 @@ function testApprovalStore(store, options = {}) {
|
|
|
1905
2139
|
by: approver
|
|
1906
2140
|
}))).rejects.toThrow(/not pending/);
|
|
1907
2141
|
await store.create(sampleApproval("stale-token"));
|
|
1908
|
-
const expired = await store.expire(
|
|
2142
|
+
const expired = await store.expire({ epochMilliseconds: nowMillis() + 72e5 });
|
|
1909
2143
|
expect(expired).toBeGreaterThanOrEqual(1);
|
|
1910
2144
|
});
|
|
1911
2145
|
it("pages list results with limit and an opaque cursor", async () => {
|
|
@@ -1920,7 +2154,7 @@ function testApprovalStore(store, options = {}) {
|
|
|
1920
2154
|
id: "u_pager",
|
|
1921
2155
|
roles: ["member"]
|
|
1922
2156
|
} },
|
|
1923
|
-
createdAt:
|
|
2157
|
+
createdAt: isoAt(base + index * 1e3)
|
|
1924
2158
|
})));
|
|
1925
2159
|
const first = await store.list({
|
|
1926
2160
|
principalId: "u_pager",
|
|
@@ -1972,9 +2206,9 @@ function testApprovalStore(store, options = {}) {
|
|
|
1972
2206
|
};
|
|
1973
2207
|
const shapes = [
|
|
1974
2208
|
["self-human", void 0],
|
|
1975
|
-
["self-by", { by: admin }],
|
|
2209
|
+
["self-by", { by: admin$2 }],
|
|
1976
2210
|
["self-distinct", {
|
|
1977
|
-
by: admin,
|
|
2211
|
+
by: admin$2,
|
|
1978
2212
|
distinct: true
|
|
1979
2213
|
}]
|
|
1980
2214
|
];
|
|
@@ -1992,7 +2226,7 @@ function testApprovalStore(store, options = {}) {
|
|
|
1992
2226
|
await store.create({
|
|
1993
2227
|
...sampleApproval("self-optout"),
|
|
1994
2228
|
approvers: {
|
|
1995
|
-
by: admin,
|
|
2229
|
+
by: admin$2,
|
|
1996
2230
|
distinct: false
|
|
1997
2231
|
}
|
|
1998
2232
|
});
|
|
@@ -2006,7 +2240,7 @@ function testApprovalStore(store, options = {}) {
|
|
|
2006
2240
|
await store.create({
|
|
2007
2241
|
...sampleApproval("quorum-token"),
|
|
2008
2242
|
approvers: {
|
|
2009
|
-
by: admin,
|
|
2243
|
+
by: admin$2,
|
|
2010
2244
|
quorum: 2
|
|
2011
2245
|
}
|
|
2012
2246
|
});
|
|
@@ -2037,7 +2271,7 @@ function testApprovalStore(store, options = {}) {
|
|
|
2037
2271
|
await store.create({
|
|
2038
2272
|
...sampleApproval("vouched-token"),
|
|
2039
2273
|
approvers: {
|
|
2040
|
-
by: admin,
|
|
2274
|
+
by: admin$2,
|
|
2041
2275
|
quorum: 2
|
|
2042
2276
|
}
|
|
2043
2277
|
});
|
|
@@ -2075,7 +2309,7 @@ function testApprovalStore(store, options = {}) {
|
|
|
2075
2309
|
await store.create({
|
|
2076
2310
|
...sampleApproval("veto-token"),
|
|
2077
2311
|
approvers: {
|
|
2078
|
-
by: admin,
|
|
2312
|
+
by: admin$2,
|
|
2079
2313
|
quorum: 2
|
|
2080
2314
|
}
|
|
2081
2315
|
});
|
|
@@ -2095,7 +2329,7 @@ function testApprovalStore(store, options = {}) {
|
|
|
2095
2329
|
context: {}
|
|
2096
2330
|
};
|
|
2097
2331
|
const escalation = {
|
|
2098
|
-
after:
|
|
2332
|
+
after: 3600,
|
|
2099
2333
|
to: {
|
|
2100
2334
|
kind: "role",
|
|
2101
2335
|
role: "auditor",
|
|
@@ -2105,7 +2339,7 @@ function testApprovalStore(store, options = {}) {
|
|
|
2105
2339
|
await store.create({
|
|
2106
2340
|
...sampleApproval("fresh-escalation"),
|
|
2107
2341
|
approvers: {
|
|
2108
|
-
by: admin,
|
|
2342
|
+
by: admin$2,
|
|
2109
2343
|
escalation
|
|
2110
2344
|
}
|
|
2111
2345
|
});
|
|
@@ -2115,9 +2349,9 @@ function testApprovalStore(store, options = {}) {
|
|
|
2115
2349
|
}))).rejects.toThrow(/eligible/u);
|
|
2116
2350
|
await store.create({
|
|
2117
2351
|
...sampleApproval("waited-escalation"),
|
|
2118
|
-
createdAt: (
|
|
2352
|
+
createdAt: isoAt(nowMillis() - 72e5),
|
|
2119
2353
|
approvers: {
|
|
2120
|
-
by: admin,
|
|
2354
|
+
by: admin$2,
|
|
2121
2355
|
escalation
|
|
2122
2356
|
}
|
|
2123
2357
|
});
|
|
@@ -2136,7 +2370,7 @@ function testApprovalStore(store, options = {}) {
|
|
|
2136
2370
|
stages: [{ by: {
|
|
2137
2371
|
kind: "user",
|
|
2138
2372
|
id: "u_10"
|
|
2139
|
-
} }, { by: admin }]
|
|
2373
|
+
} }, { by: admin$2 }]
|
|
2140
2374
|
}
|
|
2141
2375
|
});
|
|
2142
2376
|
await expect(Promise.resolve().then(() => store.resolve("stages-token", {
|
|
@@ -2206,49 +2440,2608 @@ function testApprovalPolicySource(source, options) {
|
|
|
2206
2440
|
expect(other.filter((entry) => entry.tenant !== void 0)).toEqual([]);
|
|
2207
2441
|
});
|
|
2208
2442
|
}
|
|
2443
|
+
const reviewItem = {
|
|
2444
|
+
v: 1,
|
|
2445
|
+
id: "rev:u_1:post.delete",
|
|
2446
|
+
review: "rev",
|
|
2447
|
+
principal: "u_1",
|
|
2448
|
+
permission: "post.delete",
|
|
2449
|
+
via: ["member"],
|
|
2450
|
+
status: "pending"
|
|
2451
|
+
};
|
|
2452
|
+
/**
|
|
2453
|
+
* Checks an `AccessReviewStore`: create is kept, items page, a decision is
|
|
2454
|
+
* recorded once, only a revoked item can be marked applied, and close is once.
|
|
2455
|
+
*/
|
|
2456
|
+
function testAccessReviewStore(store) {
|
|
2457
|
+
it("records a review, pages its items and stores one decision", async () => {
|
|
2458
|
+
const review = {
|
|
2459
|
+
v: 1,
|
|
2460
|
+
id: "rev",
|
|
2461
|
+
tenant: "o1",
|
|
2462
|
+
permissions: ["post.delete"],
|
|
2463
|
+
reviewers: {
|
|
2464
|
+
kind: "user",
|
|
2465
|
+
id: "u_9"
|
|
2466
|
+
},
|
|
2467
|
+
createdBy: "u_owner",
|
|
2468
|
+
createdAt: "2026-10-13T12:00:00.000Z",
|
|
2469
|
+
dueAt: "2026-12-31T00:00:00.000Z",
|
|
2470
|
+
status: "open",
|
|
2471
|
+
complete: true
|
|
2472
|
+
};
|
|
2473
|
+
await store.create(review, [reviewItem, {
|
|
2474
|
+
...reviewItem,
|
|
2475
|
+
id: "rev:u_2:post.delete",
|
|
2476
|
+
principal: "u_2"
|
|
2477
|
+
}]);
|
|
2478
|
+
await store.create(review, []);
|
|
2479
|
+
expect((await store.get("rev"))?.status).toBe("open");
|
|
2480
|
+
const page = await store.items({
|
|
2481
|
+
review: "rev",
|
|
2482
|
+
limit: 1
|
|
2483
|
+
});
|
|
2484
|
+
expect(page.items).toHaveLength(1);
|
|
2485
|
+
expect(page.next).toEqual(expect.any(String));
|
|
2486
|
+
const kept = await store.decide("rev", reviewItem.id, {
|
|
2487
|
+
status: "kept",
|
|
2488
|
+
decidedBy: "u_9",
|
|
2489
|
+
decidedAt: "2026-10-14T12:00:00.000Z"
|
|
2490
|
+
});
|
|
2491
|
+
expect(kept.status).toBe("kept");
|
|
2492
|
+
await expect(Promise.resolve().then(() => store.decide("rev", reviewItem.id, {
|
|
2493
|
+
status: "revoked",
|
|
2494
|
+
decidedBy: "u_9",
|
|
2495
|
+
decidedAt: "2026-10-14T12:00:00.000Z"
|
|
2496
|
+
}))).rejects.toThrow(/decided/u);
|
|
2497
|
+
const revoked = await store.decide("rev", "rev:u_2:post.delete", {
|
|
2498
|
+
status: "revoked",
|
|
2499
|
+
decidedBy: "u_9",
|
|
2500
|
+
decidedAt: "2026-10-14T12:00:00.000Z"
|
|
2501
|
+
});
|
|
2502
|
+
expect(revoked.status).toBe("revoked");
|
|
2503
|
+
await expect(Promise.resolve().then(() => store.markApplied("rev", reviewItem.id, "2026-10-15T00:00:00.000Z"))).rejects.toThrow(/revoked/u);
|
|
2504
|
+
const applied = await store.markApplied("rev", "rev:u_2:post.delete", "2026-10-15T00:00:00.000Z");
|
|
2505
|
+
expect(applied.appliedAt).toBe("2026-10-15T00:00:00.000Z");
|
|
2506
|
+
expect((await store.close("rev")).status).toBe("closed");
|
|
2507
|
+
await expect(Promise.resolve().then(() => store.close("rev"))).rejects.toThrow(/closed/u);
|
|
2508
|
+
});
|
|
2509
|
+
}
|
|
2510
|
+
/**
|
|
2511
|
+
* Checks an `ApprovalDelegationSource`: entries are plain JSON, name the
|
|
2512
|
+
* delegate, and are active at `at`. A query for another delegate is empty.
|
|
2513
|
+
*/
|
|
2514
|
+
function testApprovalDelegationSource(source, options) {
|
|
2515
|
+
it("answers with active plain delegations for the delegate", async () => {
|
|
2516
|
+
const at = options.at ?? isoNow();
|
|
2517
|
+
const entries = await source.delegationsFor({
|
|
2518
|
+
delegate: options.delegate,
|
|
2519
|
+
...options.tenant === void 0 ? {} : { tenant: options.tenant },
|
|
2520
|
+
at
|
|
2521
|
+
});
|
|
2522
|
+
expect(Array.isArray(entries)).toBe(true);
|
|
2523
|
+
expect(entries.some((entry) => entry.delegate === options.delegate && entry.delegator === options.delegator)).toBe(true);
|
|
2524
|
+
const instant = Date.parse(at);
|
|
2525
|
+
for (const entry of entries) {
|
|
2526
|
+
expect(JSON.parse(JSON.stringify(entry))).toEqual(entry);
|
|
2527
|
+
expect(entry.delegate).toBe(options.delegate);
|
|
2528
|
+
expect(Date.parse(entry.from)).toBeLessThanOrEqual(instant);
|
|
2529
|
+
expect(Date.parse(entry.until)).toBeGreaterThan(instant);
|
|
2530
|
+
}
|
|
2531
|
+
expect(await source.delegationsFor({
|
|
2532
|
+
delegate: "__permdock_unknown__",
|
|
2533
|
+
...options.tenant === void 0 ? {} : { tenant: options.tenant },
|
|
2534
|
+
at
|
|
2535
|
+
})).toEqual([]);
|
|
2536
|
+
});
|
|
2537
|
+
}
|
|
2538
|
+
/**
|
|
2539
|
+
* Checks an `IdempotencyStore`: a stored response comes back as written
|
|
2540
|
+
* until it expires, keys never share an entry, and a later `set` replaces an
|
|
2541
|
+
* earlier one.
|
|
2542
|
+
*/
|
|
2543
|
+
function testIdempotencyStore(store) {
|
|
2544
|
+
it("replays a stored response per key and keeps keys apart", async () => {
|
|
2545
|
+
const key = `u_1\n${crypto.randomUUID()}`;
|
|
2546
|
+
const other = `u_2\n${crypto.randomUUID()}`;
|
|
2547
|
+
const response = {
|
|
2548
|
+
fingerprint: "approve apr_1 sha-256:abc",
|
|
2549
|
+
status: 200,
|
|
2550
|
+
headers: [["content-type", "application/json"]],
|
|
2551
|
+
body: "{\"status\":\"approved\"}"
|
|
2552
|
+
};
|
|
2553
|
+
const later = nowMillis() + 36e5;
|
|
2554
|
+
expect(await store.get(key)).toBeUndefined();
|
|
2555
|
+
await store.set(key, response, later);
|
|
2556
|
+
expect(await store.get(key)).toEqual(response);
|
|
2557
|
+
expect(await store.get(other)).toBeUndefined();
|
|
2558
|
+
const replaced = {
|
|
2559
|
+
...response,
|
|
2560
|
+
status: 201
|
|
2561
|
+
};
|
|
2562
|
+
await store.set(key, replaced, later);
|
|
2563
|
+
expect(await store.get(key)).toEqual(replaced);
|
|
2564
|
+
});
|
|
2565
|
+
}
|
|
2209
2566
|
//#endregion
|
|
2210
|
-
//#region src/testing/conformance/
|
|
2211
|
-
|
|
2212
|
-
{
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2567
|
+
//#region src/testing/conformance/runtime-stores.ts
|
|
2568
|
+
function testLimitStore(store) {
|
|
2569
|
+
it("counts down synchronously and fails closed when exhausted", async () => {
|
|
2570
|
+
const input = {
|
|
2571
|
+
key: "report.export",
|
|
2572
|
+
subjectId: "u_1",
|
|
2573
|
+
count: 2,
|
|
2574
|
+
per: 3600,
|
|
2575
|
+
now: { epochMilliseconds: 17e11 }
|
|
2576
|
+
};
|
|
2577
|
+
const first = await store.consume(input);
|
|
2578
|
+
expect(first.remaining).toBeGreaterThanOrEqual(0);
|
|
2579
|
+
const peeked = store.remaining(input);
|
|
2580
|
+
if (peeked !== void 0) {
|
|
2581
|
+
expect(typeof peeked.remaining).toBe("number");
|
|
2582
|
+
expect(peeked !== null && typeof peeked === "object" && "then" in peeked && typeof peeked.then === "function").toBe(false);
|
|
2583
|
+
}
|
|
2584
|
+
await store.consume(input);
|
|
2585
|
+
const exhausted = await store.consume(input);
|
|
2586
|
+
expect(exhausted.remaining).toBeLessThan(0);
|
|
2587
|
+
});
|
|
2588
|
+
}
|
|
2589
|
+
function testDecisionSink(sink) {
|
|
2590
|
+
it("accepts batches and never propagates write errors", async () => {
|
|
2591
|
+
await expect(Promise.resolve(sink.write([{
|
|
2592
|
+
type: "membership",
|
|
2593
|
+
at: isoNow(),
|
|
2594
|
+
source: "app",
|
|
2595
|
+
operation: "changed",
|
|
2596
|
+
principal: { id: "u_1" },
|
|
2597
|
+
roles: {
|
|
2598
|
+
added: ["admin"],
|
|
2599
|
+
removed: ["member"]
|
|
2600
|
+
}
|
|
2601
|
+
}]))).resolves.toBeUndefined();
|
|
2602
|
+
await expect(Promise.resolve(sink.write([]))).resolves.toBeUndefined();
|
|
2603
|
+
if (sink.flush !== void 0) {
|
|
2604
|
+
await expect(Promise.resolve(sink.flush())).resolves.toBeUndefined();
|
|
2605
|
+
await expect(Promise.resolve(sink.flush())).resolves.toBeUndefined();
|
|
2606
|
+
}
|
|
2607
|
+
});
|
|
2608
|
+
}
|
|
2609
|
+
function testSnapshotSource(source) {
|
|
2610
|
+
it("round-trips a snapshot", async () => {
|
|
2611
|
+
const snapshot = await source.get();
|
|
2612
|
+
expect(snapshot === null || snapshot === void 0).toBe(false);
|
|
2613
|
+
if (typeof snapshot === "object" && snapshot !== null && "v" in snapshot) expect(snapshot.v).toBe(1);
|
|
2614
|
+
if (source.subscribe !== void 0) source.subscribe(() => void 0)();
|
|
2615
|
+
});
|
|
2616
|
+
}
|
|
2617
|
+
function testPolicySource(source, options = {}) {
|
|
2618
|
+
const readCurrent = () => {
|
|
2619
|
+
const document = source.current();
|
|
2620
|
+
expect(document !== null && typeof document === "object" && "then" in document && typeof document.then === "function").toBe(false);
|
|
2621
|
+
return document;
|
|
2622
|
+
};
|
|
2623
|
+
it("returns null or a v1 policy document synchronously", () => {
|
|
2624
|
+
const document = readCurrent();
|
|
2625
|
+
if (document !== null) expect(parsePolicyDocument(document)).toEqual(document);
|
|
2626
|
+
});
|
|
2627
|
+
it("refreshes without rejecting and keeps current() synchronous", async () => {
|
|
2628
|
+
await expect(Promise.resolve(source.refresh())).resolves.toBeUndefined();
|
|
2629
|
+
const document = readCurrent();
|
|
2630
|
+
if (document !== null) expect(document.v).toBe(1);
|
|
2631
|
+
});
|
|
2632
|
+
if (options.policy !== void 0) {
|
|
2633
|
+
const policy = options.policy;
|
|
2634
|
+
it("merges only grants on hostable permissions", () => {
|
|
2635
|
+
const document = readCurrent();
|
|
2636
|
+
const merged = mergeHostedGrants(policy, document);
|
|
2637
|
+
for (const grant of merged.policy.grants) if (grant.hosted !== void 0) expect(policy.hostable).toContain(grant.permission.key);
|
|
2638
|
+
for (const dropped of merged.dropped) expect(dropped.kind).toBe("hosted-grant-dropped");
|
|
2639
|
+
});
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
//#endregion
|
|
2643
|
+
//#region src/testing/cloud-api/platform.ts
|
|
2644
|
+
const CATALOG = "https://permdock.com/schemas/catalog-v1.json";
|
|
2645
|
+
const MANIFEST = "https://permdock.com/schemas/supabase-manifest-v1.json";
|
|
2646
|
+
const PUSHED_POLICY = "https://permdock.com/schemas/pushed-policy-v1.json";
|
|
2647
|
+
const OAUTH_FORM$1 = "application/x-www-form-urlencoded";
|
|
2648
|
+
/** The scopes the platform issuer grants a person's login. */
|
|
2649
|
+
const PLATFORM_SCOPES = {
|
|
2650
|
+
"projects:read": "List and read the projects the person belongs to",
|
|
2651
|
+
"policy:read": "Read a project's pushed catalog, manifest and policy",
|
|
2652
|
+
"catalog:write": "Push a catalog and policy with permdock cloud push"
|
|
2653
|
+
};
|
|
2654
|
+
const ref$1 = (name) => ({ $ref: `#/components/schemas/${name}` });
|
|
2655
|
+
const RATE_LIMIT_HEADERS = {
|
|
2656
|
+
RateLimit: {
|
|
2657
|
+
description: "Remaining quota per policy, draft-ietf-httpapi-ratelimit-headers-11 (`\"read\";r=59;t=30`)",
|
|
2658
|
+
schema: { type: "string" }
|
|
2227
2659
|
},
|
|
2228
|
-
{
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
score: null,
|
|
2232
|
-
title: null,
|
|
2233
|
-
archived: null,
|
|
2234
|
-
note: null
|
|
2660
|
+
"RateLimit-Policy": {
|
|
2661
|
+
description: "The quota policy, draft-ietf-httpapi-ratelimit-headers-11 (`\"read\";q=60;w=60`)",
|
|
2662
|
+
schema: { type: "string" }
|
|
2235
2663
|
}
|
|
2236
|
-
|
|
2237
|
-
const
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2664
|
+
};
|
|
2665
|
+
const limited = (description, schema) => ({
|
|
2666
|
+
description,
|
|
2667
|
+
headers: RATE_LIMIT_HEADERS,
|
|
2668
|
+
content: { "application/json": { schema } }
|
|
2669
|
+
});
|
|
2670
|
+
const problem$1 = (description, headers) => ({
|
|
2671
|
+
description,
|
|
2672
|
+
...headers === void 0 ? {} : { headers },
|
|
2673
|
+
content: { "application/problem+json": { schema: ref$1("Problem") } }
|
|
2674
|
+
});
|
|
2675
|
+
const oauthJson = (description, schema) => ({
|
|
2676
|
+
description,
|
|
2677
|
+
content: { "application/json": { schema } }
|
|
2678
|
+
});
|
|
2679
|
+
const TOO_MANY = problem$1("Rate limited; retry after Retry-After seconds", {
|
|
2680
|
+
...RATE_LIMIT_HEADERS,
|
|
2681
|
+
"Retry-After": {
|
|
2682
|
+
description: "Seconds to wait (RFC 9110)",
|
|
2683
|
+
schema: { type: "string" }
|
|
2684
|
+
}
|
|
2685
|
+
});
|
|
2686
|
+
const READ_SECURITY = (scope) => [{ platform: [scope] }, { cloudToken: [] }];
|
|
2687
|
+
const read = (scope, summary, responses) => ({ get: {
|
|
2688
|
+
security: READ_SECURITY(scope),
|
|
2689
|
+
summary,
|
|
2690
|
+
responses: {
|
|
2691
|
+
...responses,
|
|
2692
|
+
"401": problem$1("Missing, expired or revoked token"),
|
|
2693
|
+
"403": problem$1("The token may not read this project; a PERMDOCK_CLOUD_TOKEN reads only its own environment"),
|
|
2694
|
+
"429": TOO_MANY
|
|
2695
|
+
}
|
|
2696
|
+
} });
|
|
2697
|
+
const PLATFORM_SCHEMAS = {
|
|
2698
|
+
AuthorizationServerMetadata: {
|
|
2699
|
+
type: "object",
|
|
2700
|
+
description: "RFC 8414 metadata of the platform issuer",
|
|
2701
|
+
required: [
|
|
2702
|
+
"issuer",
|
|
2703
|
+
"authorization_endpoint",
|
|
2704
|
+
"token_endpoint",
|
|
2705
|
+
"device_authorization_endpoint",
|
|
2706
|
+
"revocation_endpoint",
|
|
2707
|
+
"registration_endpoint",
|
|
2708
|
+
"scopes_supported",
|
|
2709
|
+
"code_challenge_methods_supported"
|
|
2710
|
+
],
|
|
2711
|
+
properties: {
|
|
2712
|
+
issuer: { type: "string" },
|
|
2713
|
+
authorization_endpoint: { type: "string" },
|
|
2714
|
+
device_authorization_endpoint: { type: "string" },
|
|
2715
|
+
token_endpoint: { type: "string" },
|
|
2716
|
+
revocation_endpoint: { type: "string" },
|
|
2717
|
+
registration_endpoint: { type: "string" },
|
|
2718
|
+
scopes_supported: {
|
|
2719
|
+
type: "array",
|
|
2720
|
+
items: { type: "string" },
|
|
2721
|
+
contains: { const: "policy:read" }
|
|
2722
|
+
},
|
|
2723
|
+
code_challenge_methods_supported: {
|
|
2724
|
+
type: "array",
|
|
2725
|
+
items: { const: "S256" },
|
|
2726
|
+
minItems: 1
|
|
2727
|
+
},
|
|
2728
|
+
grant_types_supported: {
|
|
2729
|
+
type: "array",
|
|
2730
|
+
items: { type: "string" }
|
|
2731
|
+
},
|
|
2732
|
+
response_types_supported: {
|
|
2733
|
+
type: "array",
|
|
2734
|
+
items: { type: "string" }
|
|
2735
|
+
},
|
|
2736
|
+
token_endpoint_auth_methods_supported: {
|
|
2737
|
+
type: "array",
|
|
2738
|
+
items: { type: "string" }
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
},
|
|
2742
|
+
ClientRegistration: {
|
|
2743
|
+
type: "object",
|
|
2744
|
+
description: "RFC 7591 client information response",
|
|
2745
|
+
required: ["client_id", "redirect_uris"],
|
|
2746
|
+
properties: {
|
|
2747
|
+
client_id: {
|
|
2748
|
+
type: "string",
|
|
2749
|
+
minLength: 1
|
|
2750
|
+
},
|
|
2751
|
+
client_id_issued_at: { type: "integer" },
|
|
2752
|
+
redirect_uris: {
|
|
2753
|
+
type: "array",
|
|
2754
|
+
items: { type: "string" }
|
|
2755
|
+
},
|
|
2756
|
+
token_endpoint_auth_method: { type: "string" },
|
|
2757
|
+
grant_types: {
|
|
2758
|
+
type: "array",
|
|
2759
|
+
items: { type: "string" }
|
|
2760
|
+
},
|
|
2761
|
+
client_name: { type: "string" },
|
|
2762
|
+
scope: { type: "string" }
|
|
2763
|
+
}
|
|
2764
|
+
},
|
|
2765
|
+
Project: {
|
|
2766
|
+
type: "object",
|
|
2767
|
+
description: "A Cloud project. A project is one environment: `id` is the `{env}` of /v1/environments/{env}.",
|
|
2768
|
+
required: [
|
|
2769
|
+
"id",
|
|
2770
|
+
"name",
|
|
2771
|
+
"environment"
|
|
2772
|
+
],
|
|
2773
|
+
properties: {
|
|
2774
|
+
id: {
|
|
2775
|
+
type: "string",
|
|
2776
|
+
minLength: 1
|
|
2777
|
+
},
|
|
2778
|
+
name: { type: "string" },
|
|
2779
|
+
environment: {
|
|
2780
|
+
type: "string",
|
|
2781
|
+
minLength: 1
|
|
2782
|
+
},
|
|
2783
|
+
issuer: {
|
|
2784
|
+
type: "string",
|
|
2785
|
+
description: "The environment's own issuer for app tokens"
|
|
2786
|
+
},
|
|
2787
|
+
catalog: {
|
|
2788
|
+
type: "object",
|
|
2789
|
+
required: ["version", "fingerprint"],
|
|
2790
|
+
properties: {
|
|
2791
|
+
version: {
|
|
2792
|
+
type: "integer",
|
|
2793
|
+
minimum: 1
|
|
2794
|
+
},
|
|
2795
|
+
fingerprint: { type: "string" }
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
},
|
|
2800
|
+
ProjectPolicy: {
|
|
2801
|
+
type: "object",
|
|
2802
|
+
description: "What the project's last cloud push sent, with JSON member order kept as pushed, so permdock cloud pull writes the bytes the offline commands write.",
|
|
2803
|
+
required: [
|
|
2804
|
+
"v",
|
|
2805
|
+
"project",
|
|
2806
|
+
"version",
|
|
2807
|
+
"fingerprint",
|
|
2808
|
+
"permdockVersion",
|
|
2809
|
+
"catalog"
|
|
2810
|
+
],
|
|
2811
|
+
properties: {
|
|
2812
|
+
v: { const: 1 },
|
|
2813
|
+
project: {
|
|
2814
|
+
type: "string",
|
|
2815
|
+
minLength: 1
|
|
2816
|
+
},
|
|
2817
|
+
version: {
|
|
2818
|
+
type: "integer",
|
|
2819
|
+
minimum: 1
|
|
2820
|
+
},
|
|
2821
|
+
fingerprint: {
|
|
2822
|
+
type: "string",
|
|
2823
|
+
description: "catalogFingerprint(catalog); equals catalog.fingerprint"
|
|
2824
|
+
},
|
|
2825
|
+
permdockVersion: {
|
|
2826
|
+
type: "string",
|
|
2827
|
+
description: "The permdock version that pushed"
|
|
2828
|
+
},
|
|
2829
|
+
catalog: { $ref: CATALOG },
|
|
2830
|
+
manifest: { $ref: MANIFEST },
|
|
2831
|
+
policy: { $ref: PUSHED_POLICY }
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
};
|
|
2835
|
+
const PROJECT_ID = {
|
|
2836
|
+
name: "id",
|
|
2837
|
+
in: "path",
|
|
2838
|
+
required: true,
|
|
2839
|
+
description: "The project id, the same id as its environment",
|
|
2840
|
+
schema: {
|
|
2841
|
+
type: "string",
|
|
2842
|
+
minLength: 1
|
|
2843
|
+
}
|
|
2844
|
+
};
|
|
2845
|
+
/** Routes of the platform issuer and its read API; they sit at the API root, outside /v1/environments/{env}. */
|
|
2846
|
+
const PLATFORM_PATHS = {
|
|
2847
|
+
"/.well-known/oauth-authorization-server": { get: {
|
|
2848
|
+
security: [],
|
|
2849
|
+
summary: "RFC 8414 metadata of the platform issuer. For the issuer https://permdock.com/api it is served at https://permdock.com/.well-known/oauth-authorization-server/api (section 3.1) and here.",
|
|
2850
|
+
responses: { "200": oauthJson("The metadata", ref$1("AuthorizationServerMetadata")) }
|
|
2851
|
+
} },
|
|
2852
|
+
"/oauth/authorize": { get: {
|
|
2853
|
+
security: [],
|
|
2854
|
+
summary: "Authorization code with PKCE S256 for public clients, including the RFC 8252 loopback redirect http://127.0.0.1:{port}/callback",
|
|
2855
|
+
parameters: [
|
|
2856
|
+
...[
|
|
2857
|
+
"response_type",
|
|
2858
|
+
"client_id",
|
|
2859
|
+
"redirect_uri",
|
|
2860
|
+
"code_challenge"
|
|
2861
|
+
].map((name) => ({
|
|
2862
|
+
name,
|
|
2863
|
+
in: "query",
|
|
2864
|
+
required: true,
|
|
2865
|
+
schema: { type: "string" }
|
|
2866
|
+
})),
|
|
2867
|
+
{
|
|
2868
|
+
name: "code_challenge_method",
|
|
2869
|
+
in: "query",
|
|
2870
|
+
required: true,
|
|
2871
|
+
schema: { const: "S256" }
|
|
2872
|
+
},
|
|
2873
|
+
{
|
|
2874
|
+
name: "state",
|
|
2875
|
+
in: "query",
|
|
2876
|
+
schema: { type: "string" }
|
|
2877
|
+
},
|
|
2878
|
+
{
|
|
2879
|
+
name: "scope",
|
|
2880
|
+
in: "query",
|
|
2881
|
+
schema: { type: "string" }
|
|
2882
|
+
}
|
|
2883
|
+
],
|
|
2884
|
+
responses: {
|
|
2885
|
+
"302": { description: "Redirect to the client with a code" },
|
|
2886
|
+
"400": oauthJson("invalid_request, including plain PKCE", ref$1("OAuthError"))
|
|
2887
|
+
}
|
|
2888
|
+
} },
|
|
2889
|
+
"/oauth/device_authorization": { post: {
|
|
2890
|
+
security: [{ publicClient: [] }],
|
|
2891
|
+
summary: "RFC 8628 device authorization for permdock login",
|
|
2892
|
+
requestBody: {
|
|
2893
|
+
required: true,
|
|
2894
|
+
content: { [OAUTH_FORM$1]: { schema: {
|
|
2895
|
+
type: "object",
|
|
2896
|
+
required: ["client_id"]
|
|
2897
|
+
} } }
|
|
2898
|
+
},
|
|
2899
|
+
responses: {
|
|
2900
|
+
"200": oauthJson("The codes", {
|
|
2901
|
+
type: "object",
|
|
2902
|
+
required: [
|
|
2903
|
+
"device_code",
|
|
2904
|
+
"user_code",
|
|
2905
|
+
"verification_uri",
|
|
2906
|
+
"expires_in"
|
|
2907
|
+
],
|
|
2908
|
+
properties: {
|
|
2909
|
+
device_code: { type: "string" },
|
|
2910
|
+
user_code: { type: "string" },
|
|
2911
|
+
verification_uri: { type: "string" },
|
|
2912
|
+
verification_uri_complete: { type: "string" },
|
|
2913
|
+
expires_in: { type: "integer" },
|
|
2914
|
+
interval: { type: "integer" }
|
|
2915
|
+
}
|
|
2916
|
+
}),
|
|
2917
|
+
"400": oauthJson("invalid_request or invalid_client", ref$1("OAuthError"))
|
|
2918
|
+
}
|
|
2919
|
+
} },
|
|
2920
|
+
"/oauth/token": { post: {
|
|
2921
|
+
security: [{ publicClient: [] }],
|
|
2922
|
+
summary: "authorization_code with code_verifier, device_code and refresh_token for public clients",
|
|
2923
|
+
requestBody: {
|
|
2924
|
+
required: true,
|
|
2925
|
+
content: { [OAUTH_FORM$1]: { schema: {
|
|
2926
|
+
type: "object",
|
|
2927
|
+
required: ["grant_type"],
|
|
2928
|
+
properties: { grant_type: { enum: [
|
|
2929
|
+
"authorization_code",
|
|
2930
|
+
"urn:ietf:params:oauth:grant-type:device_code",
|
|
2931
|
+
"refresh_token"
|
|
2932
|
+
] } }
|
|
2933
|
+
} } }
|
|
2934
|
+
},
|
|
2935
|
+
responses: {
|
|
2936
|
+
"200": oauthJson("The tokens", ref$1("TokenResponse")),
|
|
2937
|
+
"400": oauthJson("An RFC 6749 or RFC 8628 error", ref$1("OAuthError"))
|
|
2938
|
+
}
|
|
2939
|
+
} },
|
|
2940
|
+
"/oauth/revoke": { post: {
|
|
2941
|
+
security: [{ publicClient: [] }],
|
|
2942
|
+
summary: "RFC 7009 revocation; permdock logout sends the refresh token. An unknown token also answers 200.",
|
|
2943
|
+
requestBody: {
|
|
2944
|
+
required: true,
|
|
2945
|
+
content: { [OAUTH_FORM$1]: { schema: {
|
|
2946
|
+
type: "object",
|
|
2947
|
+
required: ["token"],
|
|
2948
|
+
properties: {
|
|
2949
|
+
token: { type: "string" },
|
|
2950
|
+
token_type_hint: { enum: ["access_token", "refresh_token"] }
|
|
2951
|
+
}
|
|
2952
|
+
} } }
|
|
2953
|
+
},
|
|
2954
|
+
responses: {
|
|
2955
|
+
"200": { description: "Revoked, or the token was not known" },
|
|
2956
|
+
"400": oauthJson("invalid_request", ref$1("OAuthError"))
|
|
2957
|
+
}
|
|
2958
|
+
} },
|
|
2959
|
+
"/oauth/register": { post: {
|
|
2960
|
+
security: [],
|
|
2961
|
+
summary: "RFC 7591 dynamic client registration for MCP clients of https://permdock.com/mcp: public clients with loopback or https redirect URIs",
|
|
2962
|
+
requestBody: {
|
|
2963
|
+
required: true,
|
|
2964
|
+
content: { "application/json": { schema: {
|
|
2965
|
+
type: "object",
|
|
2966
|
+
required: ["redirect_uris"],
|
|
2967
|
+
properties: {
|
|
2968
|
+
redirect_uris: {
|
|
2969
|
+
type: "array",
|
|
2970
|
+
items: { type: "string" },
|
|
2971
|
+
minItems: 1
|
|
2972
|
+
},
|
|
2973
|
+
token_endpoint_auth_method: { const: "none" },
|
|
2974
|
+
client_name: { type: "string" },
|
|
2975
|
+
scope: { type: "string" }
|
|
2976
|
+
}
|
|
2977
|
+
} } }
|
|
2978
|
+
},
|
|
2979
|
+
responses: {
|
|
2980
|
+
"201": oauthJson("The registered client", ref$1("ClientRegistration")),
|
|
2981
|
+
"400": oauthJson("invalid_redirect_uri or invalid_client_metadata", ref$1("OAuthError"))
|
|
2982
|
+
}
|
|
2983
|
+
} },
|
|
2984
|
+
"/v1/projects": read("projects:read", "The projects the token reads", { "200": limited("The projects", {
|
|
2985
|
+
type: "object",
|
|
2986
|
+
required: ["items"],
|
|
2987
|
+
properties: {
|
|
2988
|
+
items: {
|
|
2989
|
+
type: "array",
|
|
2990
|
+
items: ref$1("Project")
|
|
2991
|
+
},
|
|
2992
|
+
next: { type: "string" }
|
|
2993
|
+
}
|
|
2994
|
+
}) }),
|
|
2995
|
+
"/v1/projects/{id}": {
|
|
2996
|
+
parameters: [PROJECT_ID],
|
|
2997
|
+
...read("projects:read", "One project", {
|
|
2998
|
+
"200": limited("The project", ref$1("Project")),
|
|
2999
|
+
"404": problem$1("No such project")
|
|
3000
|
+
})
|
|
3001
|
+
},
|
|
3002
|
+
"/v1/projects/{id}/policy": {
|
|
3003
|
+
parameters: [PROJECT_ID],
|
|
3004
|
+
...read("policy:read", "The pushed catalog, manifest and policy, for permdock cloud pull and rls generate --project", {
|
|
3005
|
+
"200": limited("The pushed policy", ref$1("ProjectPolicy")),
|
|
3006
|
+
"404": problem$1(`No such project, or \`type\` ${POLICY_NOT_PUBLISHED}: nothing pushed yet`)
|
|
3007
|
+
})
|
|
3008
|
+
}
|
|
3009
|
+
};
|
|
3010
|
+
const PLATFORM_SECURITY_SCHEMES = {
|
|
3011
|
+
platform: {
|
|
3012
|
+
type: "oauth2",
|
|
3013
|
+
description: "A person's login at the platform issuer https://permdock.com/api: permdock login (device flow, RFC 8628, or the RFC 8252 loopback with PKCE) or an MCP client registered with RFC 7591.",
|
|
3014
|
+
flows: { authorizationCode: {
|
|
3015
|
+
authorizationUrl: "https://permdock.com/api/oauth/authorize",
|
|
3016
|
+
tokenUrl: "https://permdock.com/api/oauth/token",
|
|
3017
|
+
refreshUrl: "https://permdock.com/api/oauth/token",
|
|
3018
|
+
scopes: PLATFORM_SCOPES
|
|
3019
|
+
} }
|
|
3020
|
+
},
|
|
3021
|
+
cloudToken: {
|
|
3022
|
+
type: "http",
|
|
3023
|
+
scheme: "bearer",
|
|
3024
|
+
description: "PERMDOCK_CLOUD_TOKEN: a read-only Cloud key pinned to one environment, for CI. It reads that project and its policy, and nothing else."
|
|
3025
|
+
}
|
|
3026
|
+
};
|
|
3027
|
+
//#endregion
|
|
3028
|
+
//#region src/testing/cloud-api/platform-checks.ts
|
|
3029
|
+
const ITEM = /^"[^"]+"(?:;[a-z]+=-?\d+)+$/u;
|
|
3030
|
+
/** Whether a RateLimit or RateLimit-Policy field value lists items with the parameters `keys` (draft-ietf-httpapi-ratelimit-headers-11). */
|
|
3031
|
+
function rateLimitField(value, keys) {
|
|
3032
|
+
if (value === null || value.trim() === "") return false;
|
|
3033
|
+
return value.split(",").every((raw) => {
|
|
3034
|
+
const item = raw.trim();
|
|
3035
|
+
return ITEM.test(item) && keys.every((key) => item.includes(`;${key}=`));
|
|
3036
|
+
});
|
|
3037
|
+
}
|
|
3038
|
+
function rateLimited(response) {
|
|
3039
|
+
return rateLimitField(response.headers.get("ratelimit"), ["r", "t"]) && rateLimitField(response.headers.get("ratelimit-policy"), ["q", "w"]);
|
|
3040
|
+
}
|
|
3041
|
+
function problemType(response, body) {
|
|
3042
|
+
return (response.headers.get("content-type") ?? "").startsWith("application/problem+json") && isRecord(body) ? body["type"] : void 0;
|
|
3043
|
+
}
|
|
3044
|
+
/** The platform issuer and project read API checks of `testCloudApi`. */
|
|
3045
|
+
function testCloudPlatform(url, environment, options, fetchFn) {
|
|
3046
|
+
const project = options.project ?? environment;
|
|
3047
|
+
const read = (path, token) => fetchFn(`${url}${path}`, token === null ? {} : { headers: { authorization: `Bearer ${token}` } });
|
|
3048
|
+
const form = (path, fields) => fetchFn(`${url}${path}`, {
|
|
3049
|
+
method: "POST",
|
|
3050
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
3051
|
+
body: new URLSearchParams(fields).toString()
|
|
3052
|
+
});
|
|
3053
|
+
it("publishes RFC 8414 metadata for the platform issuer", async () => {
|
|
3054
|
+
const metadata = await discoverAuthorizationServer(url, { fetch: (input, init) => fetchFn(String(input), init) });
|
|
3055
|
+
expect(metadata).not.toBeNull();
|
|
3056
|
+
expect(metadata).toMatchObject({
|
|
3057
|
+
issuer: url,
|
|
3058
|
+
code_challenge_methods_supported: ["S256"]
|
|
3059
|
+
});
|
|
3060
|
+
const scopes = metadata?.["scopes_supported"];
|
|
3061
|
+
expect(scopes).toEqual(expect.arrayContaining(Object.keys(PLATFORM_SCOPES)));
|
|
3062
|
+
});
|
|
3063
|
+
it("lists and reads the project and its policy with RateLimit fields", async () => {
|
|
3064
|
+
const list = await read("/v1/projects", options.token);
|
|
3065
|
+
expect(list.status).toBe(200);
|
|
3066
|
+
expect(rateLimited(list)).toBe(true);
|
|
3067
|
+
const listed = await list.json();
|
|
3068
|
+
const items = isRecord(listed) && Array.isArray(listed["items"]) ? listed["items"] : [];
|
|
3069
|
+
expect(items).toContainEqual(expect.objectContaining({ id: project }));
|
|
3070
|
+
const one = await read(`/v1/projects/${encodeURIComponent(project)}`, options.token);
|
|
3071
|
+
expect(one.status).toBe(200);
|
|
3072
|
+
expect(rateLimited(one)).toBe(true);
|
|
3073
|
+
expect(await one.json()).toMatchObject({
|
|
3074
|
+
id: project,
|
|
3075
|
+
environment: project
|
|
3076
|
+
});
|
|
3077
|
+
const policy = await read(`/v1/projects/${encodeURIComponent(project)}/policy`, options.token);
|
|
3078
|
+
const body = await policy.json();
|
|
3079
|
+
if (policy.status === 404) {
|
|
3080
|
+
expect(problemType(policy, body)).toBe(POLICY_NOT_PUBLISHED);
|
|
3081
|
+
return;
|
|
3082
|
+
}
|
|
3083
|
+
expect(policy.status).toBe(200);
|
|
3084
|
+
expect(rateLimited(policy)).toBe(true);
|
|
3085
|
+
expect(isRecord(body)).toBe(true);
|
|
3086
|
+
const catalog = isRecord(body) ? body["catalog"] : void 0;
|
|
3087
|
+
expect(isRecord(catalog)).toBe(true);
|
|
3088
|
+
const fingerprint = isRecord(catalog) ? catalogFingerprint(catalog) : "";
|
|
3089
|
+
expect(body).toMatchObject({
|
|
3090
|
+
v: 1,
|
|
3091
|
+
project,
|
|
3092
|
+
fingerprint
|
|
3093
|
+
});
|
|
3094
|
+
});
|
|
3095
|
+
it("answers policy-not-published as Problem Details before the first push", async (context) => {
|
|
3096
|
+
if (options.unpublished === void 0) {
|
|
3097
|
+
context.skip();
|
|
3098
|
+
return;
|
|
3099
|
+
}
|
|
3100
|
+
const response = await read(`/v1/projects/${encodeURIComponent(options.unpublished.project)}/policy`, options.unpublished.token);
|
|
3101
|
+
expect(response.status).toBe(404);
|
|
3102
|
+
expect(problemType(response, await response.json())).toBe(POLICY_NOT_PUBLISHED);
|
|
3103
|
+
});
|
|
3104
|
+
it("answers 401 Problem Details to the read API without a token", async () => {
|
|
3105
|
+
const response = await read(`/v1/projects/${encodeURIComponent(project)}`, null);
|
|
3106
|
+
expect(response.status).toBe(401);
|
|
3107
|
+
expect(response.headers.get("content-type")).toMatch(/^application\/problem\+json/u);
|
|
3108
|
+
});
|
|
3109
|
+
it("refuses a catalog push with the read-only token", async () => {
|
|
3110
|
+
const response = await fetchFn(`${url}/v1/environments/${encodeURIComponent(project)}/catalog`, {
|
|
3111
|
+
method: "POST",
|
|
3112
|
+
headers: {
|
|
3113
|
+
authorization: `Bearer ${options.token}`,
|
|
3114
|
+
"content-type": "application/json"
|
|
3115
|
+
},
|
|
3116
|
+
body: JSON.stringify({
|
|
3117
|
+
fingerprint: "x",
|
|
3118
|
+
catalog: {}
|
|
3119
|
+
})
|
|
3120
|
+
});
|
|
3121
|
+
expect([401, 403]).toContain(response.status);
|
|
3122
|
+
});
|
|
3123
|
+
it("answers RFC 7009 revocation of an unknown token with 200", async () => {
|
|
3124
|
+
const response = await form("/oauth/revoke", {
|
|
3125
|
+
token: `unknown-${globalThis.crypto.randomUUID()}`,
|
|
3126
|
+
token_type_hint: "refresh_token",
|
|
3127
|
+
client_id: "permdock-cli"
|
|
3128
|
+
});
|
|
3129
|
+
expect(response.status).toBe(200);
|
|
3130
|
+
});
|
|
3131
|
+
it("registers an MCP client with a loopback redirect and refuses a plain http one", async (context) => {
|
|
3132
|
+
if (options.registration !== true) {
|
|
3133
|
+
context.skip();
|
|
3134
|
+
return;
|
|
3135
|
+
}
|
|
3136
|
+
const register = (redirect) => fetchFn(`${url}/oauth/register`, {
|
|
3137
|
+
method: "POST",
|
|
3138
|
+
headers: { "content-type": "application/json" },
|
|
3139
|
+
body: JSON.stringify({
|
|
3140
|
+
redirect_uris: [redirect],
|
|
3141
|
+
token_endpoint_auth_method: "none",
|
|
3142
|
+
client_name: "PermDock contract test"
|
|
3143
|
+
})
|
|
3144
|
+
});
|
|
3145
|
+
const loopback = await register("http://127.0.0.1:33418/callback");
|
|
3146
|
+
expect(loopback.status).toBe(201);
|
|
3147
|
+
expect(await loopback.json()).toMatchObject({ client_id: expect.any(String) });
|
|
3148
|
+
expect((await register("http://mcp.example.invalid/callback")).status).toBe(400);
|
|
3149
|
+
});
|
|
3150
|
+
}
|
|
3151
|
+
//#endregion
|
|
3152
|
+
//#region src/testing/cloud-api/connector-schema.ts
|
|
3153
|
+
var import__2020 = require__2020();
|
|
3154
|
+
/**
|
|
3155
|
+
* `schemas/connector-v1.json`: the body of a PermDock Cloud connector, one
|
|
3156
|
+
* `oneOf` branch per kind with a closed `config`. No branch holds a secret.
|
|
3157
|
+
*/
|
|
3158
|
+
const CONNECTOR_SCHEMA = freezeDeep({
|
|
3159
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
3160
|
+
$id: "https://permdock.com/schemas/connector-v1.json",
|
|
3161
|
+
title: "PermDock Cloud connector",
|
|
3162
|
+
description: "A connector on PermDock Cloud's /connectors route, wire format v1: the closed list of kinds, one branch per kind. Trusted issuers have their own route (/trusted-issuers).",
|
|
3163
|
+
oneOf: [
|
|
3164
|
+
{
|
|
3165
|
+
description: "CloudEvents batches signed with Standard Webhooks.",
|
|
3166
|
+
type: "object",
|
|
3167
|
+
required: [
|
|
3168
|
+
"kind",
|
|
3169
|
+
"name",
|
|
3170
|
+
"config"
|
|
3171
|
+
],
|
|
3172
|
+
properties: {
|
|
3173
|
+
id: {
|
|
3174
|
+
type: "string",
|
|
3175
|
+
minLength: 1,
|
|
3176
|
+
maxLength: 256
|
|
3177
|
+
},
|
|
3178
|
+
kind: { const: "webhook" },
|
|
3179
|
+
name: {
|
|
3180
|
+
type: "string",
|
|
3181
|
+
minLength: 1,
|
|
3182
|
+
maxLength: 200
|
|
3183
|
+
},
|
|
3184
|
+
config: {
|
|
3185
|
+
type: "object",
|
|
3186
|
+
additionalProperties: false,
|
|
3187
|
+
required: ["url"],
|
|
3188
|
+
properties: {
|
|
3189
|
+
url: {
|
|
3190
|
+
type: "string",
|
|
3191
|
+
pattern: "^https://"
|
|
3192
|
+
},
|
|
3193
|
+
types: {
|
|
3194
|
+
type: "array",
|
|
3195
|
+
items: {
|
|
3196
|
+
type: "string",
|
|
3197
|
+
minLength: 1
|
|
3198
|
+
},
|
|
3199
|
+
uniqueItems: true,
|
|
3200
|
+
description: "CloudEvents types to deliver; all when absent."
|
|
3201
|
+
}
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3205
|
+
},
|
|
3206
|
+
{
|
|
3207
|
+
description: "OTLP/HTTP log records and spans with the permdock.* attributes.",
|
|
3208
|
+
type: "object",
|
|
3209
|
+
required: [
|
|
3210
|
+
"kind",
|
|
3211
|
+
"name",
|
|
3212
|
+
"config"
|
|
3213
|
+
],
|
|
3214
|
+
properties: {
|
|
3215
|
+
id: {
|
|
3216
|
+
type: "string",
|
|
3217
|
+
minLength: 1,
|
|
3218
|
+
maxLength: 256
|
|
3219
|
+
},
|
|
3220
|
+
kind: { const: "otlp" },
|
|
3221
|
+
name: {
|
|
3222
|
+
type: "string",
|
|
3223
|
+
minLength: 1,
|
|
3224
|
+
maxLength: 200
|
|
3225
|
+
},
|
|
3226
|
+
config: {
|
|
3227
|
+
type: "object",
|
|
3228
|
+
additionalProperties: false,
|
|
3229
|
+
required: ["endpoint"],
|
|
3230
|
+
properties: {
|
|
3231
|
+
endpoint: {
|
|
3232
|
+
type: "string",
|
|
3233
|
+
pattern: "^https://"
|
|
3234
|
+
},
|
|
3235
|
+
headers: {
|
|
3236
|
+
type: "object",
|
|
3237
|
+
additionalProperties: { type: "string" }
|
|
3238
|
+
},
|
|
3239
|
+
signals: {
|
|
3240
|
+
type: "array",
|
|
3241
|
+
items: { enum: ["logs", "traces"] },
|
|
3242
|
+
minItems: 1,
|
|
3243
|
+
uniqueItems: true
|
|
3244
|
+
}
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
},
|
|
3249
|
+
{
|
|
3250
|
+
description: "The OCSF projection posted to a SIEM ingest API.",
|
|
3251
|
+
type: "object",
|
|
3252
|
+
required: [
|
|
3253
|
+
"kind",
|
|
3254
|
+
"name",
|
|
3255
|
+
"config"
|
|
3256
|
+
],
|
|
3257
|
+
properties: {
|
|
3258
|
+
id: {
|
|
3259
|
+
type: "string",
|
|
3260
|
+
minLength: 1,
|
|
3261
|
+
maxLength: 256
|
|
3262
|
+
},
|
|
3263
|
+
kind: { const: "siem" },
|
|
3264
|
+
name: {
|
|
3265
|
+
type: "string",
|
|
3266
|
+
minLength: 1,
|
|
3267
|
+
maxLength: 200
|
|
3268
|
+
},
|
|
3269
|
+
config: {
|
|
3270
|
+
type: "object",
|
|
3271
|
+
additionalProperties: false,
|
|
3272
|
+
required: ["destination", "endpoint"],
|
|
3273
|
+
properties: {
|
|
3274
|
+
destination: { enum: [
|
|
3275
|
+
"splunk-hec",
|
|
3276
|
+
"sentinel",
|
|
3277
|
+
"security-lake",
|
|
3278
|
+
"google-secops",
|
|
3279
|
+
"elastic",
|
|
3280
|
+
"datadog"
|
|
3281
|
+
] },
|
|
3282
|
+
endpoint: {
|
|
3283
|
+
type: "string",
|
|
3284
|
+
pattern: "^https://"
|
|
3285
|
+
}
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
}
|
|
3289
|
+
},
|
|
3290
|
+
{
|
|
3291
|
+
description: "A scheduled decision-log export.",
|
|
3292
|
+
type: "object",
|
|
3293
|
+
required: [
|
|
3294
|
+
"kind",
|
|
3295
|
+
"name",
|
|
3296
|
+
"config"
|
|
3297
|
+
],
|
|
3298
|
+
properties: {
|
|
3299
|
+
id: {
|
|
3300
|
+
type: "string",
|
|
3301
|
+
minLength: 1,
|
|
3302
|
+
maxLength: 256
|
|
3303
|
+
},
|
|
3304
|
+
kind: { const: "evidence" },
|
|
3305
|
+
name: {
|
|
3306
|
+
type: "string",
|
|
3307
|
+
minLength: 1,
|
|
3308
|
+
maxLength: 200
|
|
3309
|
+
},
|
|
3310
|
+
config: {
|
|
3311
|
+
type: "object",
|
|
3312
|
+
additionalProperties: false,
|
|
3313
|
+
required: ["format", "destination"],
|
|
3314
|
+
properties: {
|
|
3315
|
+
format: { enum: [
|
|
3316
|
+
"jws",
|
|
3317
|
+
"ocsf",
|
|
3318
|
+
"csv"
|
|
3319
|
+
] },
|
|
3320
|
+
destination: {
|
|
3321
|
+
type: "string",
|
|
3322
|
+
pattern: "^https://"
|
|
3323
|
+
},
|
|
3324
|
+
schedule: { enum: ["hourly", "daily"] }
|
|
3325
|
+
}
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
3328
|
+
},
|
|
3329
|
+
{
|
|
3330
|
+
description: "A hosted SCIM 2.0 relay for one tenant, replayed to the application's scimHandler.",
|
|
3331
|
+
type: "object",
|
|
3332
|
+
required: [
|
|
3333
|
+
"kind",
|
|
3334
|
+
"name",
|
|
3335
|
+
"config"
|
|
3336
|
+
],
|
|
3337
|
+
properties: {
|
|
3338
|
+
id: {
|
|
3339
|
+
type: "string",
|
|
3340
|
+
minLength: 1,
|
|
3341
|
+
maxLength: 256
|
|
3342
|
+
},
|
|
3343
|
+
kind: { const: "scim" },
|
|
3344
|
+
name: {
|
|
3345
|
+
type: "string",
|
|
3346
|
+
minLength: 1,
|
|
3347
|
+
maxLength: 200
|
|
3348
|
+
},
|
|
3349
|
+
config: {
|
|
3350
|
+
type: "object",
|
|
3351
|
+
additionalProperties: false,
|
|
3352
|
+
required: ["tenant", "target"],
|
|
3353
|
+
properties: {
|
|
3354
|
+
tenant: {
|
|
3355
|
+
type: "string",
|
|
3356
|
+
minLength: 1,
|
|
3357
|
+
maxLength: 256
|
|
3358
|
+
},
|
|
3359
|
+
target: {
|
|
3360
|
+
type: "string",
|
|
3361
|
+
pattern: "^https://"
|
|
3362
|
+
},
|
|
3363
|
+
groupRoles: {
|
|
3364
|
+
type: "object",
|
|
3365
|
+
description: "Group id to declared assignable role.",
|
|
3366
|
+
additionalProperties: {
|
|
3367
|
+
type: "string",
|
|
3368
|
+
minLength: 1,
|
|
3369
|
+
maxLength: 256
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
}
|
|
3373
|
+
}
|
|
3374
|
+
}
|
|
3375
|
+
},
|
|
3376
|
+
{
|
|
3377
|
+
description: "An SSF transmitter whose SETs the Cloud receives (POST /ssf/receive/:connector, or poll).",
|
|
3378
|
+
type: "object",
|
|
3379
|
+
required: [
|
|
3380
|
+
"kind",
|
|
3381
|
+
"name",
|
|
3382
|
+
"config"
|
|
3383
|
+
],
|
|
3384
|
+
properties: {
|
|
3385
|
+
id: {
|
|
3386
|
+
type: "string",
|
|
3387
|
+
minLength: 1,
|
|
3388
|
+
maxLength: 256
|
|
3389
|
+
},
|
|
3390
|
+
kind: { const: "caep-transmitter" },
|
|
3391
|
+
name: {
|
|
3392
|
+
type: "string",
|
|
3393
|
+
minLength: 1,
|
|
3394
|
+
maxLength: 200
|
|
3395
|
+
},
|
|
3396
|
+
config: {
|
|
3397
|
+
type: "object",
|
|
3398
|
+
additionalProperties: false,
|
|
3399
|
+
required: ["issuer", "delivery"],
|
|
3400
|
+
properties: {
|
|
3401
|
+
issuer: {
|
|
3402
|
+
type: "string",
|
|
3403
|
+
pattern: "^https://"
|
|
3404
|
+
},
|
|
3405
|
+
jwks_uri: {
|
|
3406
|
+
type: "string",
|
|
3407
|
+
pattern: "^https://"
|
|
3408
|
+
},
|
|
3409
|
+
delivery: { enum: ["push", "poll"] },
|
|
3410
|
+
poll_endpoint: {
|
|
3411
|
+
type: "string",
|
|
3412
|
+
pattern: "^https://"
|
|
3413
|
+
}
|
|
3414
|
+
}
|
|
3415
|
+
}
|
|
3416
|
+
}
|
|
3417
|
+
},
|
|
3418
|
+
{
|
|
3419
|
+
description: "An OIDC upstream the Cloud issuer federates to with authorization code plus PKCE.",
|
|
3420
|
+
type: "object",
|
|
3421
|
+
required: [
|
|
3422
|
+
"kind",
|
|
3423
|
+
"name",
|
|
3424
|
+
"config"
|
|
3425
|
+
],
|
|
3426
|
+
properties: {
|
|
3427
|
+
id: {
|
|
3428
|
+
type: "string",
|
|
3429
|
+
minLength: 1,
|
|
3430
|
+
maxLength: 256
|
|
3431
|
+
},
|
|
3432
|
+
kind: { const: "federated-upstream" },
|
|
3433
|
+
name: {
|
|
3434
|
+
type: "string",
|
|
3435
|
+
minLength: 1,
|
|
3436
|
+
maxLength: 200
|
|
3437
|
+
},
|
|
3438
|
+
config: {
|
|
3439
|
+
type: "object",
|
|
3440
|
+
additionalProperties: false,
|
|
3441
|
+
required: ["discovery", "client_id"],
|
|
3442
|
+
properties: {
|
|
3443
|
+
discovery: {
|
|
3444
|
+
type: "string",
|
|
3445
|
+
pattern: "^https://"
|
|
3446
|
+
},
|
|
3447
|
+
client_id: {
|
|
3448
|
+
type: "string",
|
|
3449
|
+
minLength: 1,
|
|
3450
|
+
maxLength: 256
|
|
3451
|
+
},
|
|
3452
|
+
scopes: {
|
|
3453
|
+
type: "array",
|
|
3454
|
+
items: {
|
|
3455
|
+
type: "string",
|
|
3456
|
+
minLength: 1
|
|
3457
|
+
},
|
|
3458
|
+
uniqueItems: true
|
|
3459
|
+
}
|
|
3460
|
+
}
|
|
3461
|
+
}
|
|
3462
|
+
}
|
|
3463
|
+
},
|
|
3464
|
+
{
|
|
3465
|
+
description: "Approval cards through the Vercel Chat SDK.",
|
|
3466
|
+
type: "object",
|
|
3467
|
+
required: [
|
|
3468
|
+
"kind",
|
|
3469
|
+
"name",
|
|
3470
|
+
"config"
|
|
3471
|
+
],
|
|
3472
|
+
properties: {
|
|
3473
|
+
id: {
|
|
3474
|
+
type: "string",
|
|
3475
|
+
minLength: 1,
|
|
3476
|
+
maxLength: 256
|
|
3477
|
+
},
|
|
3478
|
+
kind: { const: "chat" },
|
|
3479
|
+
name: {
|
|
3480
|
+
type: "string",
|
|
3481
|
+
minLength: 1,
|
|
3482
|
+
maxLength: 200
|
|
3483
|
+
},
|
|
3484
|
+
config: {
|
|
3485
|
+
type: "object",
|
|
3486
|
+
additionalProperties: false,
|
|
3487
|
+
required: ["platform", "workspace"],
|
|
3488
|
+
properties: {
|
|
3489
|
+
platform: { enum: ["slack", "teams"] },
|
|
3490
|
+
workspace: {
|
|
3491
|
+
type: "string",
|
|
3492
|
+
minLength: 1,
|
|
3493
|
+
maxLength: 256
|
|
3494
|
+
}
|
|
3495
|
+
}
|
|
3496
|
+
}
|
|
3497
|
+
}
|
|
3498
|
+
},
|
|
3499
|
+
{
|
|
3500
|
+
description: "The Cloud's hosted email sender for approval and delivery messages.",
|
|
3501
|
+
type: "object",
|
|
3502
|
+
required: [
|
|
3503
|
+
"kind",
|
|
3504
|
+
"name",
|
|
3505
|
+
"config"
|
|
3506
|
+
],
|
|
3507
|
+
properties: {
|
|
3508
|
+
id: {
|
|
3509
|
+
type: "string",
|
|
3510
|
+
minLength: 1,
|
|
3511
|
+
maxLength: 256
|
|
3512
|
+
},
|
|
3513
|
+
kind: { const: "email" },
|
|
3514
|
+
name: {
|
|
3515
|
+
type: "string",
|
|
3516
|
+
minLength: 1,
|
|
3517
|
+
maxLength: 200
|
|
3518
|
+
},
|
|
3519
|
+
config: {
|
|
3520
|
+
type: "object",
|
|
3521
|
+
additionalProperties: false,
|
|
3522
|
+
required: ["from"],
|
|
3523
|
+
properties: { from: {
|
|
3524
|
+
type: "string",
|
|
3525
|
+
pattern: "^[^@\\s]+@[^@\\s]+$"
|
|
3526
|
+
} }
|
|
3527
|
+
}
|
|
3528
|
+
}
|
|
3529
|
+
},
|
|
3530
|
+
{
|
|
3531
|
+
description: "Vercel OIDC federation tokens accepted as hosted ADS callers.",
|
|
3532
|
+
type: "object",
|
|
3533
|
+
required: [
|
|
3534
|
+
"kind",
|
|
3535
|
+
"name",
|
|
3536
|
+
"config"
|
|
3537
|
+
],
|
|
3538
|
+
properties: {
|
|
3539
|
+
id: {
|
|
3540
|
+
type: "string",
|
|
3541
|
+
minLength: 1,
|
|
3542
|
+
maxLength: 256
|
|
3543
|
+
},
|
|
3544
|
+
kind: { const: "vercel-oidc" },
|
|
3545
|
+
name: {
|
|
3546
|
+
type: "string",
|
|
3547
|
+
minLength: 1,
|
|
3548
|
+
maxLength: 200
|
|
3549
|
+
},
|
|
3550
|
+
config: {
|
|
3551
|
+
type: "object",
|
|
3552
|
+
additionalProperties: false,
|
|
3553
|
+
required: ["team_id"],
|
|
3554
|
+
properties: {
|
|
3555
|
+
team_id: {
|
|
3556
|
+
type: "string",
|
|
3557
|
+
minLength: 1,
|
|
3558
|
+
maxLength: 256
|
|
3559
|
+
},
|
|
3560
|
+
project_id: {
|
|
3561
|
+
type: "string",
|
|
3562
|
+
minLength: 1,
|
|
3563
|
+
maxLength: 256
|
|
3564
|
+
},
|
|
3565
|
+
environment: { enum: [
|
|
3566
|
+
"production",
|
|
3567
|
+
"preview",
|
|
3568
|
+
"development"
|
|
3569
|
+
] }
|
|
3570
|
+
}
|
|
3571
|
+
}
|
|
3572
|
+
}
|
|
3573
|
+
}
|
|
3574
|
+
]
|
|
3575
|
+
});
|
|
3576
|
+
//#endregion
|
|
3577
|
+
//#region src/testing/cloud-api/document.ts
|
|
3578
|
+
const ENV = "/v1/environments/{env}";
|
|
3579
|
+
const COMPACT_JWS = String.raw`^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+$`;
|
|
3580
|
+
const APPROVAL_REQUEST = "https://permdock.com/schemas/approval-request-v1.json";
|
|
3581
|
+
const CONNECTOR = "https://permdock.com/schemas/connector-v1.json";
|
|
3582
|
+
const ref = (name) => ({ $ref: `#/components/schemas/${name}` });
|
|
3583
|
+
const problem = (description) => ({
|
|
3584
|
+
description,
|
|
3585
|
+
content: { "application/problem+json": { schema: ref("Problem") } }
|
|
3586
|
+
});
|
|
3587
|
+
const json = (description, schema) => ({
|
|
3588
|
+
description,
|
|
3589
|
+
content: { "application/json": { schema } }
|
|
3590
|
+
});
|
|
3591
|
+
const jwt = (description) => ({
|
|
3592
|
+
description,
|
|
3593
|
+
content: { "application/jwt": { schema: ref("CompactJws") } }
|
|
3594
|
+
});
|
|
3595
|
+
const empty = (description) => ({ description });
|
|
3596
|
+
const body = (schema, type = "application/json") => ({
|
|
3597
|
+
required: true,
|
|
3598
|
+
content: { [type]: { schema } }
|
|
3599
|
+
});
|
|
3600
|
+
const UNAUTHORISED = problem("Missing or unknown key");
|
|
3601
|
+
const FORBIDDEN = problem("A key of another kind");
|
|
3602
|
+
const keyed = (scheme) => (operation) => ({
|
|
3603
|
+
security: [{ [scheme]: [] }],
|
|
3604
|
+
...operation,
|
|
3605
|
+
responses: {
|
|
3606
|
+
...operation.responses,
|
|
3607
|
+
"401": UNAUTHORISED,
|
|
3608
|
+
"403": FORBIDDEN
|
|
3609
|
+
}
|
|
3610
|
+
});
|
|
3611
|
+
const client = keyed("client");
|
|
3612
|
+
const admin$1 = keyed("admin");
|
|
3613
|
+
const open = (operation) => ({
|
|
3614
|
+
security: [],
|
|
3615
|
+
...operation
|
|
3616
|
+
});
|
|
3617
|
+
const header = (name, description) => ({
|
|
3618
|
+
name,
|
|
3619
|
+
in: "header",
|
|
3620
|
+
required: true,
|
|
3621
|
+
description,
|
|
3622
|
+
schema: {
|
|
3623
|
+
type: "string",
|
|
3624
|
+
minLength: 1
|
|
3625
|
+
}
|
|
3626
|
+
});
|
|
3627
|
+
const APPROVAL_TOKEN = header("PermDock-Approval", "The approval token; never in the URL");
|
|
3628
|
+
const ID = { $ref: "#/components/parameters/id" };
|
|
3629
|
+
const items = (schema) => ({
|
|
3630
|
+
type: "object",
|
|
3631
|
+
required: ["items"],
|
|
3632
|
+
properties: {
|
|
3633
|
+
items: {
|
|
3634
|
+
type: "array",
|
|
3635
|
+
items: schema
|
|
3636
|
+
},
|
|
3637
|
+
next: { type: "string" }
|
|
3638
|
+
}
|
|
3639
|
+
});
|
|
3640
|
+
function crud(name, schema, input) {
|
|
3641
|
+
return {
|
|
3642
|
+
[`${ENV}/${name}`]: {
|
|
3643
|
+
get: admin$1({
|
|
3644
|
+
summary: `List ${name}`,
|
|
3645
|
+
responses: { "200": json("The list", items(schema)) }
|
|
3646
|
+
}),
|
|
3647
|
+
post: admin$1({
|
|
3648
|
+
summary: `Create one of ${name}`,
|
|
3649
|
+
requestBody: body(input),
|
|
3650
|
+
responses: {
|
|
3651
|
+
"201": json("Stored", schema),
|
|
3652
|
+
"400": problem("Not a valid body")
|
|
3653
|
+
}
|
|
3654
|
+
})
|
|
3655
|
+
},
|
|
3656
|
+
[`${ENV}/${name}/{id}`]: {
|
|
3657
|
+
parameters: [ID],
|
|
3658
|
+
get: admin$1({
|
|
3659
|
+
summary: `Read one of ${name}`,
|
|
3660
|
+
responses: {
|
|
3661
|
+
"200": json("The item", schema),
|
|
3662
|
+
"404": problem("Unknown id")
|
|
3663
|
+
}
|
|
3664
|
+
}),
|
|
3665
|
+
delete: admin$1({
|
|
3666
|
+
summary: `Remove one of ${name}`,
|
|
3667
|
+
responses: {
|
|
3668
|
+
"204": empty("Removed"),
|
|
3669
|
+
"404": problem("Unknown id")
|
|
3670
|
+
}
|
|
3671
|
+
})
|
|
3672
|
+
}
|
|
3673
|
+
};
|
|
3674
|
+
}
|
|
3675
|
+
const withId = (schema) => ({ allOf: [schema, {
|
|
3676
|
+
type: "object",
|
|
3677
|
+
required: ["id"],
|
|
3678
|
+
properties: { id: { type: "string" } }
|
|
3679
|
+
}] });
|
|
3680
|
+
const SCHEMAS = {
|
|
3681
|
+
Problem: {
|
|
3682
|
+
type: "object",
|
|
3683
|
+
description: "RFC 9457 Problem Details",
|
|
3684
|
+
required: [
|
|
3685
|
+
"type",
|
|
3686
|
+
"title",
|
|
3687
|
+
"status"
|
|
3688
|
+
],
|
|
3689
|
+
properties: {
|
|
3690
|
+
type: { type: "string" },
|
|
3691
|
+
title: { type: "string" },
|
|
3692
|
+
status: {
|
|
3693
|
+
type: "integer",
|
|
3694
|
+
minimum: 400,
|
|
3695
|
+
maximum: 599
|
|
3696
|
+
},
|
|
3697
|
+
detail: { type: "string" },
|
|
3698
|
+
instance: { type: "string" },
|
|
3699
|
+
code: { type: "string" },
|
|
3700
|
+
reason: { type: "string" }
|
|
3701
|
+
}
|
|
3702
|
+
},
|
|
3703
|
+
CompactJws: {
|
|
3704
|
+
type: "string",
|
|
3705
|
+
pattern: COMPACT_JWS
|
|
3706
|
+
},
|
|
3707
|
+
ApprovalRequest: { $ref: APPROVAL_REQUEST },
|
|
3708
|
+
ApprovalPage: items({ $ref: APPROVAL_REQUEST }),
|
|
3709
|
+
Verdict: {
|
|
3710
|
+
type: "object",
|
|
3711
|
+
required: ["status", "by"],
|
|
3712
|
+
properties: {
|
|
3713
|
+
status: { enum: ["approved", "rejected"] },
|
|
3714
|
+
by: { type: "object" },
|
|
3715
|
+
note: { type: "string" }
|
|
3716
|
+
}
|
|
3717
|
+
},
|
|
3718
|
+
CatalogPush: { oneOf: [{
|
|
3719
|
+
type: "object",
|
|
3720
|
+
required: [
|
|
3721
|
+
"version",
|
|
3722
|
+
"fingerprint",
|
|
3723
|
+
"findings"
|
|
3724
|
+
],
|
|
3725
|
+
properties: {
|
|
3726
|
+
version: {
|
|
3727
|
+
type: "integer",
|
|
3728
|
+
minimum: 1
|
|
3729
|
+
},
|
|
3730
|
+
fingerprint: {
|
|
3731
|
+
type: "string",
|
|
3732
|
+
minLength: 1
|
|
3733
|
+
},
|
|
3734
|
+
findings: {
|
|
3735
|
+
type: "array",
|
|
3736
|
+
items: { type: "object" }
|
|
3737
|
+
}
|
|
3738
|
+
}
|
|
3739
|
+
}, {
|
|
3740
|
+
type: "object",
|
|
3741
|
+
required: [
|
|
3742
|
+
"version",
|
|
3743
|
+
"fingerprint",
|
|
3744
|
+
"unchanged"
|
|
3745
|
+
],
|
|
3746
|
+
properties: {
|
|
3747
|
+
version: {
|
|
3748
|
+
type: "integer",
|
|
3749
|
+
minimum: 1
|
|
3750
|
+
},
|
|
3751
|
+
fingerprint: {
|
|
3752
|
+
type: "string",
|
|
3753
|
+
minLength: 1
|
|
3754
|
+
},
|
|
3755
|
+
unchanged: { const: true }
|
|
3756
|
+
}
|
|
3757
|
+
}] },
|
|
3758
|
+
HostedGrant: {
|
|
3759
|
+
type: "object",
|
|
3760
|
+
required: ["permission", "to"],
|
|
3761
|
+
properties: {
|
|
3762
|
+
id: { type: "string" },
|
|
3763
|
+
permission: {
|
|
3764
|
+
type: "string",
|
|
3765
|
+
minLength: 1
|
|
3766
|
+
},
|
|
3767
|
+
to: { type: "object" }
|
|
3768
|
+
}
|
|
3769
|
+
},
|
|
3770
|
+
Connector: { $ref: CONNECTOR },
|
|
3771
|
+
TrustedIssuer: {
|
|
3772
|
+
type: "object",
|
|
3773
|
+
required: [
|
|
3774
|
+
"issuer",
|
|
3775
|
+
"audiences",
|
|
3776
|
+
"purposes"
|
|
3777
|
+
],
|
|
3778
|
+
properties: {
|
|
3779
|
+
id: { type: "string" },
|
|
3780
|
+
issuer: {
|
|
3781
|
+
type: "string",
|
|
3782
|
+
pattern: "^https://"
|
|
3783
|
+
},
|
|
3784
|
+
jwks_uri: {
|
|
3785
|
+
type: "string",
|
|
3786
|
+
pattern: "^https://"
|
|
3787
|
+
},
|
|
3788
|
+
discovery: {
|
|
3789
|
+
type: "string",
|
|
3790
|
+
pattern: "^https://"
|
|
3791
|
+
},
|
|
3792
|
+
audiences: {
|
|
3793
|
+
type: "array",
|
|
3794
|
+
minItems: 1,
|
|
3795
|
+
items: { type: "string" }
|
|
3796
|
+
},
|
|
3797
|
+
purposes: {
|
|
3798
|
+
type: "array",
|
|
3799
|
+
minItems: 1,
|
|
3800
|
+
uniqueItems: true,
|
|
3801
|
+
items: { enum: [
|
|
3802
|
+
"approver",
|
|
3803
|
+
"subject",
|
|
3804
|
+
"token-exchange"
|
|
3805
|
+
] }
|
|
3806
|
+
}
|
|
3807
|
+
},
|
|
3808
|
+
oneOf: [{
|
|
3809
|
+
required: ["jwks_uri"],
|
|
3810
|
+
properties: { jwks_uri: true }
|
|
3811
|
+
}, {
|
|
3812
|
+
required: ["discovery"],
|
|
3813
|
+
properties: { discovery: true }
|
|
3814
|
+
}]
|
|
3815
|
+
},
|
|
3816
|
+
TrustedPep: {
|
|
3817
|
+
type: "object",
|
|
3818
|
+
properties: {
|
|
3819
|
+
id: { type: "string" },
|
|
3820
|
+
vercel: {
|
|
3821
|
+
type: "object",
|
|
3822
|
+
required: ["team_id"],
|
|
3823
|
+
additionalProperties: false,
|
|
3824
|
+
properties: {
|
|
3825
|
+
team_id: {
|
|
3826
|
+
type: "string",
|
|
3827
|
+
minLength: 1
|
|
3828
|
+
},
|
|
3829
|
+
project_id: {
|
|
3830
|
+
type: "string",
|
|
3831
|
+
minLength: 1
|
|
3832
|
+
},
|
|
3833
|
+
environment: { enum: [
|
|
3834
|
+
"production",
|
|
3835
|
+
"preview",
|
|
3836
|
+
"development"
|
|
3837
|
+
] }
|
|
3838
|
+
}
|
|
3839
|
+
},
|
|
3840
|
+
client_id: {
|
|
3841
|
+
type: "string",
|
|
3842
|
+
minLength: 1
|
|
3843
|
+
}
|
|
3844
|
+
},
|
|
3845
|
+
oneOf: [{
|
|
3846
|
+
required: ["vercel"],
|
|
3847
|
+
properties: { vercel: true }
|
|
3848
|
+
}, {
|
|
3849
|
+
required: ["client_id"],
|
|
3850
|
+
properties: { client_id: true }
|
|
3851
|
+
}]
|
|
3852
|
+
},
|
|
3853
|
+
ApproverGroup: {
|
|
3854
|
+
type: "object",
|
|
3855
|
+
required: ["name", "members"],
|
|
3856
|
+
properties: {
|
|
3857
|
+
id: { type: "string" },
|
|
3858
|
+
name: {
|
|
3859
|
+
type: "string",
|
|
3860
|
+
minLength: 1
|
|
3861
|
+
},
|
|
3862
|
+
members: {
|
|
3863
|
+
type: "array",
|
|
3864
|
+
items: {
|
|
3865
|
+
type: "string",
|
|
3866
|
+
minLength: 1
|
|
3867
|
+
}
|
|
3868
|
+
}
|
|
3869
|
+
}
|
|
3870
|
+
},
|
|
3871
|
+
Assignment: {
|
|
3872
|
+
type: "object",
|
|
3873
|
+
required: ["tenant", "role"],
|
|
3874
|
+
properties: {
|
|
3875
|
+
id: { type: "string" },
|
|
3876
|
+
principal: { type: "string" },
|
|
3877
|
+
group: { type: "string" },
|
|
3878
|
+
tenant: { type: "string" },
|
|
3879
|
+
team: { type: "string" },
|
|
3880
|
+
role: {
|
|
3881
|
+
type: "string",
|
|
3882
|
+
minLength: 1
|
|
3883
|
+
}
|
|
3884
|
+
},
|
|
3885
|
+
oneOf: [{
|
|
3886
|
+
required: ["principal"],
|
|
3887
|
+
properties: { principal: true }
|
|
3888
|
+
}, {
|
|
3889
|
+
required: ["group"],
|
|
3890
|
+
properties: { group: true }
|
|
3891
|
+
}]
|
|
3892
|
+
},
|
|
3893
|
+
Created: {
|
|
3894
|
+
type: "object",
|
|
3895
|
+
required: ["id"],
|
|
3896
|
+
properties: { id: {
|
|
3897
|
+
type: "string",
|
|
3898
|
+
minLength: 1
|
|
3899
|
+
} }
|
|
3900
|
+
},
|
|
3901
|
+
Jwks: {
|
|
3902
|
+
type: "object",
|
|
3903
|
+
required: ["keys"],
|
|
3904
|
+
properties: { keys: {
|
|
3905
|
+
type: "array",
|
|
3906
|
+
items: { type: "object" }
|
|
3907
|
+
} }
|
|
3908
|
+
},
|
|
3909
|
+
Metadata: {
|
|
3910
|
+
type: "object",
|
|
3911
|
+
additionalProperties: true
|
|
3912
|
+
},
|
|
3913
|
+
TokenResponse: {
|
|
3914
|
+
type: "object",
|
|
3915
|
+
required: [
|
|
3916
|
+
"access_token",
|
|
3917
|
+
"token_type",
|
|
3918
|
+
"expires_in"
|
|
3919
|
+
],
|
|
3920
|
+
properties: {
|
|
3921
|
+
access_token: { type: "string" },
|
|
3922
|
+
issued_token_type: { type: "string" },
|
|
3923
|
+
token_type: { const: "Bearer" },
|
|
3924
|
+
expires_in: {
|
|
3925
|
+
type: "integer",
|
|
3926
|
+
minimum: 1
|
|
3927
|
+
},
|
|
3928
|
+
refresh_token: { type: "string" },
|
|
3929
|
+
scope: { type: "string" }
|
|
3930
|
+
}
|
|
3931
|
+
},
|
|
3932
|
+
OAuthError: {
|
|
3933
|
+
type: "object",
|
|
3934
|
+
required: ["error"],
|
|
3935
|
+
properties: {
|
|
3936
|
+
error: { type: "string" },
|
|
3937
|
+
error_description: { type: "string" }
|
|
3938
|
+
}
|
|
3939
|
+
},
|
|
3940
|
+
SsfStream: {
|
|
3941
|
+
type: "object",
|
|
3942
|
+
required: ["stream_id"],
|
|
3943
|
+
properties: { stream_id: { type: "string" } }
|
|
3944
|
+
},
|
|
3945
|
+
SetPoll: {
|
|
3946
|
+
type: "object",
|
|
3947
|
+
required: ["sets"],
|
|
3948
|
+
properties: {
|
|
3949
|
+
sets: {
|
|
3950
|
+
type: "object",
|
|
3951
|
+
additionalProperties: ref("CompactJws")
|
|
3952
|
+
},
|
|
3953
|
+
moreAvailable: { type: "boolean" }
|
|
3954
|
+
}
|
|
3955
|
+
}
|
|
3956
|
+
};
|
|
3957
|
+
const APPROVALS = {
|
|
3958
|
+
[`${ENV}/approvals`]: {
|
|
3959
|
+
post: client({
|
|
3960
|
+
summary: "approvals.create",
|
|
3961
|
+
requestBody: body(ref("ApprovalRequest")),
|
|
3962
|
+
responses: {
|
|
3963
|
+
"200": json("The record already stored for this token", ref("ApprovalRequest")),
|
|
3964
|
+
"201": json("Stored", ref("ApprovalRequest")),
|
|
3965
|
+
"400": problem("Not a v1 approval request")
|
|
3966
|
+
}
|
|
3967
|
+
}),
|
|
3968
|
+
get: client({
|
|
3969
|
+
summary: "approvals.list",
|
|
3970
|
+
parameters: [...[
|
|
3971
|
+
"status",
|
|
3972
|
+
"principalId",
|
|
3973
|
+
"actorId",
|
|
3974
|
+
"tenant",
|
|
3975
|
+
"session",
|
|
3976
|
+
"cursor"
|
|
3977
|
+
].map((name) => ({
|
|
3978
|
+
name,
|
|
3979
|
+
in: "query",
|
|
3980
|
+
schema: { type: "string" }
|
|
3981
|
+
})), {
|
|
3982
|
+
name: "limit",
|
|
3983
|
+
in: "query",
|
|
3984
|
+
schema: {
|
|
3985
|
+
type: "integer",
|
|
3986
|
+
minimum: 1
|
|
3987
|
+
}
|
|
3988
|
+
}],
|
|
3989
|
+
responses: { "200": json("One page", ref("ApprovalPage")) }
|
|
3990
|
+
})
|
|
3991
|
+
},
|
|
3992
|
+
[`${ENV}/approvals/by-token`]: { get: client({
|
|
3993
|
+
summary: "approvals.get",
|
|
3994
|
+
parameters: [APPROVAL_TOKEN],
|
|
3995
|
+
responses: {
|
|
3996
|
+
"200": json("The record", ref("ApprovalRequest")),
|
|
3997
|
+
"404": problem("Unknown token")
|
|
3998
|
+
}
|
|
3999
|
+
}) },
|
|
4000
|
+
[`${ENV}/approvals/{id}`]: {
|
|
4001
|
+
parameters: [ID],
|
|
4002
|
+
get: client({
|
|
4003
|
+
summary: "approvals.getById",
|
|
4004
|
+
responses: {
|
|
4005
|
+
"200": json("The record", ref("ApprovalRequest")),
|
|
4006
|
+
"404": problem("Unknown id")
|
|
4007
|
+
}
|
|
4008
|
+
})
|
|
4009
|
+
},
|
|
4010
|
+
[`${ENV}/approvals/by-token/resolve`]: { post: client({
|
|
4011
|
+
summary: "approvals.resolve",
|
|
4012
|
+
parameters: [
|
|
4013
|
+
APPROVAL_TOKEN,
|
|
4014
|
+
{
|
|
4015
|
+
...header("PermDock-Approver", "The approver at+jwt; absent only for a system rejection"),
|
|
4016
|
+
required: false
|
|
4017
|
+
},
|
|
4018
|
+
{
|
|
4019
|
+
name: "If-Match",
|
|
4020
|
+
in: "header",
|
|
4021
|
+
schema: { type: "string" }
|
|
4022
|
+
}
|
|
4023
|
+
],
|
|
4024
|
+
requestBody: body(ref("Verdict")),
|
|
4025
|
+
responses: {
|
|
4026
|
+
"200": json("The resolved record", ref("ApprovalRequest")),
|
|
4027
|
+
"404": problem("approval-not-found"),
|
|
4028
|
+
"409": problem("Not pending, or a stale ETag"),
|
|
4029
|
+
"410": problem("Expired")
|
|
4030
|
+
}
|
|
4031
|
+
}) },
|
|
4032
|
+
[`${ENV}/approvals/by-token/consume`]: { post: client({
|
|
4033
|
+
summary: "approvals.consume",
|
|
4034
|
+
parameters: [APPROVAL_TOKEN],
|
|
4035
|
+
responses: {
|
|
4036
|
+
"200": json("The approved record with consumedAt", ref("ApprovalRequest")),
|
|
4037
|
+
"409": problem("Not approved, already consumed or expired")
|
|
4038
|
+
}
|
|
4039
|
+
}) },
|
|
4040
|
+
[`${ENV}/approvals/expire`]: { post: client({
|
|
4041
|
+
summary: "approvals.expire",
|
|
4042
|
+
responses: { "200": json("Requests expired", {
|
|
4043
|
+
type: "object",
|
|
4044
|
+
required: ["expired"],
|
|
4045
|
+
properties: { expired: {
|
|
4046
|
+
type: "integer",
|
|
4047
|
+
minimum: 0
|
|
4048
|
+
} }
|
|
4049
|
+
}) }
|
|
4050
|
+
}) },
|
|
4051
|
+
[`${ENV}/approvals/cancel`]: { post: client({
|
|
4052
|
+
summary: "approvals.cancel",
|
|
4053
|
+
requestBody: body({
|
|
4054
|
+
type: "object",
|
|
4055
|
+
required: ["filter", "by"],
|
|
4056
|
+
properties: {
|
|
4057
|
+
filter: { type: "object" },
|
|
4058
|
+
by: {
|
|
4059
|
+
type: "string",
|
|
4060
|
+
minLength: 1
|
|
4061
|
+
},
|
|
4062
|
+
note: { type: "string" }
|
|
4063
|
+
}
|
|
4064
|
+
}),
|
|
4065
|
+
responses: { "200": json("Requests cancelled", {
|
|
4066
|
+
type: "object",
|
|
4067
|
+
required: ["cancelled"],
|
|
4068
|
+
properties: { cancelled: {
|
|
4069
|
+
type: "integer",
|
|
4070
|
+
minimum: 0
|
|
4071
|
+
} }
|
|
4072
|
+
}) }
|
|
4073
|
+
}) }
|
|
4074
|
+
};
|
|
4075
|
+
const RUNTIME = {
|
|
4076
|
+
[`${ENV}/decisions`]: { post: client({
|
|
4077
|
+
summary: "sink.write and sink.flush",
|
|
4078
|
+
requestBody: body({
|
|
4079
|
+
type: "array",
|
|
4080
|
+
items: { type: "object" }
|
|
4081
|
+
}, "application/cloudevents-batch+json"),
|
|
4082
|
+
responses: {
|
|
4083
|
+
"202": empty("Accepted"),
|
|
4084
|
+
"400": problem("Not a CloudEvents batch")
|
|
4085
|
+
}
|
|
4086
|
+
}) },
|
|
4087
|
+
[`${ENV}/snapshot`]: { get: client({
|
|
4088
|
+
summary: "snapshotsFor(token).get",
|
|
4089
|
+
parameters: [header("PermDock-Subject", "An at+jwt naming the subject, exp at most five minutes after iat")],
|
|
4090
|
+
responses: { "200": jwt("A vnd.permdock.snapshot+jwt") }
|
|
4091
|
+
}) },
|
|
4092
|
+
[`${ENV}/catalog`]: {
|
|
4093
|
+
post: {
|
|
4094
|
+
...client({
|
|
4095
|
+
summary: "catalog.push and permdock cloud push",
|
|
4096
|
+
requestBody: body({
|
|
4097
|
+
type: "object",
|
|
4098
|
+
required: ["fingerprint", "catalog"],
|
|
4099
|
+
properties: {
|
|
4100
|
+
fingerprint: {
|
|
4101
|
+
type: "string",
|
|
4102
|
+
minLength: 1
|
|
4103
|
+
},
|
|
4104
|
+
catalog: { type: "object" },
|
|
4105
|
+
policy: { $ref: PUSHED_POLICY },
|
|
4106
|
+
manifest: { $ref: "https://permdock.com/schemas/supabase-manifest-v1.json" },
|
|
4107
|
+
permdockVersion: {
|
|
4108
|
+
type: "string",
|
|
4109
|
+
description: "The permdock version that pushed"
|
|
4110
|
+
}
|
|
4111
|
+
}
|
|
4112
|
+
}),
|
|
4113
|
+
responses: {
|
|
4114
|
+
"200": json("The fingerprint is already published", ref("CatalogPush")),
|
|
4115
|
+
"201": json("A new version and its drift findings", ref("CatalogPush")),
|
|
4116
|
+
"400": problem("Not a catalog, or a fingerprint that does not match it")
|
|
4117
|
+
}
|
|
4118
|
+
}),
|
|
4119
|
+
security: [{ client: [] }, { platform: ["catalog:write"] }]
|
|
4120
|
+
},
|
|
4121
|
+
get: client({
|
|
4122
|
+
summary: "The latest catalog",
|
|
4123
|
+
responses: {
|
|
4124
|
+
"200": json("The catalog", { type: "object" }),
|
|
4125
|
+
"404": problem("Nothing pushed yet")
|
|
4126
|
+
}
|
|
4127
|
+
})
|
|
4128
|
+
},
|
|
4129
|
+
[`${ENV}/catalog/versions`]: { get: client({
|
|
4130
|
+
summary: "Catalog versions",
|
|
4131
|
+
responses: { "200": json("Versions, newest first", items({
|
|
4132
|
+
type: "object",
|
|
4133
|
+
required: ["version", "fingerprint"],
|
|
4134
|
+
properties: {
|
|
4135
|
+
version: {
|
|
4136
|
+
type: "integer",
|
|
4137
|
+
minimum: 1
|
|
4138
|
+
},
|
|
4139
|
+
fingerprint: { type: "string" }
|
|
4140
|
+
}
|
|
4141
|
+
})) }
|
|
4142
|
+
}) },
|
|
4143
|
+
[`${ENV}/deliveries`]: { post: client({
|
|
4144
|
+
summary: "cloudDeliveryTransport",
|
|
4145
|
+
parameters: [header("Idempotency-Key", "The router's per-recipient key")],
|
|
4146
|
+
requestBody: body({
|
|
4147
|
+
type: "object",
|
|
4148
|
+
required: [
|
|
4149
|
+
"channel",
|
|
4150
|
+
"delivery",
|
|
4151
|
+
"target",
|
|
4152
|
+
"message"
|
|
4153
|
+
],
|
|
4154
|
+
properties: {
|
|
4155
|
+
channel: { type: "string" },
|
|
4156
|
+
delivery: { type: "string" },
|
|
4157
|
+
target: {
|
|
4158
|
+
type: "object",
|
|
4159
|
+
required: ["address", "permission"]
|
|
4160
|
+
},
|
|
4161
|
+
message: { type: "object" }
|
|
4162
|
+
}
|
|
4163
|
+
}),
|
|
4164
|
+
responses: {
|
|
4165
|
+
"202": json("Accepted", ref("Created")),
|
|
4166
|
+
"400": problem("invalid-message"),
|
|
4167
|
+
"409": problem("A delivery with this key is in flight"),
|
|
4168
|
+
"422": problem("invalid-message"),
|
|
4169
|
+
"429": problem("rate-limited")
|
|
4170
|
+
}
|
|
4171
|
+
}) },
|
|
4172
|
+
[`${ENV}/sign`]: { post: client({
|
|
4173
|
+
summary: "cloudSigner",
|
|
4174
|
+
requestBody: body({
|
|
4175
|
+
type: "object",
|
|
4176
|
+
required: [
|
|
4177
|
+
"typ",
|
|
4178
|
+
"claims",
|
|
4179
|
+
"aud"
|
|
4180
|
+
],
|
|
4181
|
+
properties: {
|
|
4182
|
+
typ: { type: "string" },
|
|
4183
|
+
claims: { type: "object" },
|
|
4184
|
+
aud: { anyOf: [{ type: "string" }, {
|
|
4185
|
+
type: "array",
|
|
4186
|
+
items: { type: "string" }
|
|
4187
|
+
}] },
|
|
4188
|
+
exp: { type: "integer" },
|
|
4189
|
+
iat: { type: "integer" },
|
|
4190
|
+
jti: { type: "string" }
|
|
4191
|
+
}
|
|
4192
|
+
}),
|
|
4193
|
+
responses: {
|
|
4194
|
+
"200": jwt("The compact JWS of the requested typ"),
|
|
4195
|
+
"400": problem("Missing audience or claims"),
|
|
4196
|
+
"422": problem("A typ the client key may not sign")
|
|
4197
|
+
}
|
|
4198
|
+
}) },
|
|
4199
|
+
[`${ENV}/policy`]: { get: client({
|
|
4200
|
+
summary: "policies.refresh",
|
|
4201
|
+
responses: {
|
|
4202
|
+
"200": jwt("A vnd.permdock.policy+jwt"),
|
|
4203
|
+
"404": problem("No hosted grants")
|
|
4204
|
+
}
|
|
4205
|
+
}) }
|
|
4206
|
+
};
|
|
4207
|
+
const AUTHORING = {
|
|
4208
|
+
[`${ENV}/hosted-grants`]: {
|
|
4209
|
+
get: admin$1({
|
|
4210
|
+
summary: "List hosted grants",
|
|
4211
|
+
responses: { "200": json("The grants", items(ref("HostedGrant"))) }
|
|
4212
|
+
}),
|
|
4213
|
+
post: admin$1({
|
|
4214
|
+
summary: "Author a hosted grant",
|
|
4215
|
+
requestBody: body(ref("HostedGrant")),
|
|
4216
|
+
responses: {
|
|
4217
|
+
"201": json("The stored grant", withId(ref("HostedGrant"))),
|
|
4218
|
+
"422": problem("A grant mergeHostedGrants would drop; reason names why")
|
|
4219
|
+
}
|
|
4220
|
+
})
|
|
4221
|
+
},
|
|
4222
|
+
[`${ENV}/hosted-grants/{id}`]: {
|
|
4223
|
+
parameters: [ID],
|
|
4224
|
+
delete: admin$1({
|
|
4225
|
+
summary: "Remove a hosted grant",
|
|
4226
|
+
responses: {
|
|
4227
|
+
"204": empty("Removed"),
|
|
4228
|
+
"404": problem("Unknown id")
|
|
4229
|
+
}
|
|
4230
|
+
})
|
|
4231
|
+
},
|
|
4232
|
+
[`${ENV}/directory/principals`]: {
|
|
4233
|
+
get: admin$1({
|
|
4234
|
+
summary: "List principals",
|
|
4235
|
+
responses: { "200": json("The list", items({ type: "object" })) }
|
|
4236
|
+
}),
|
|
4237
|
+
post: admin$1({
|
|
4238
|
+
summary: "Create a principal",
|
|
4239
|
+
requestBody: body({
|
|
4240
|
+
type: "object",
|
|
4241
|
+
required: ["subject"]
|
|
4242
|
+
}),
|
|
4243
|
+
responses: { "201": json("Created", ref("Created")) }
|
|
4244
|
+
})
|
|
4245
|
+
},
|
|
4246
|
+
[`${ENV}/directory/groups`]: {
|
|
4247
|
+
get: admin$1({
|
|
4248
|
+
summary: "List groups",
|
|
4249
|
+
responses: { "200": json("The list", items({ type: "object" })) }
|
|
4250
|
+
}),
|
|
4251
|
+
post: admin$1({
|
|
4252
|
+
summary: "Create a group",
|
|
4253
|
+
requestBody: body({
|
|
4254
|
+
type: "object",
|
|
4255
|
+
required: ["name"]
|
|
4256
|
+
}),
|
|
4257
|
+
responses: { "201": json("Created", ref("Created")) }
|
|
4258
|
+
})
|
|
4259
|
+
},
|
|
4260
|
+
...crud("directory/assignments", ref("Assignment"), ref("Assignment")),
|
|
4261
|
+
...crud("connectors", withId(ref("Connector")), ref("Connector")),
|
|
4262
|
+
...crud("trusted-issuers", withId(ref("TrustedIssuer")), ref("TrustedIssuer")),
|
|
4263
|
+
...crud("trusted-peps", withId(ref("TrustedPep")), ref("TrustedPep")),
|
|
4264
|
+
...crud("approver-groups", withId(ref("ApproverGroup")), ref("ApproverGroup")),
|
|
4265
|
+
[`${ENV}/snapshots/invalidate`]: { post: admin$1({
|
|
4266
|
+
summary: "Invalidate edge snapshots and send CAEP session-revoked",
|
|
4267
|
+
requestBody: body({
|
|
4268
|
+
type: "object",
|
|
4269
|
+
required: ["reason"],
|
|
4270
|
+
properties: {
|
|
4271
|
+
principal: { type: "string" },
|
|
4272
|
+
session: { type: "string" },
|
|
4273
|
+
tenant: { type: "string" },
|
|
4274
|
+
reason: {
|
|
4275
|
+
type: "string",
|
|
4276
|
+
minLength: 1
|
|
4277
|
+
}
|
|
4278
|
+
}
|
|
4279
|
+
}),
|
|
4280
|
+
responses: { "202": empty("Accepted") }
|
|
4281
|
+
}) },
|
|
4282
|
+
[`${ENV}/export`]: { get: keyed("export")({
|
|
4283
|
+
summary: "The decision log",
|
|
4284
|
+
parameters: [{
|
|
4285
|
+
name: "format",
|
|
4286
|
+
in: "query",
|
|
4287
|
+
required: true,
|
|
4288
|
+
schema: { enum: [
|
|
4289
|
+
"jws",
|
|
4290
|
+
"ocsf",
|
|
4291
|
+
"csv",
|
|
4292
|
+
"otlp"
|
|
4293
|
+
] }
|
|
4294
|
+
}],
|
|
4295
|
+
responses: { "200": {
|
|
4296
|
+
description: "The export",
|
|
4297
|
+
content: {
|
|
4298
|
+
"application/jwt": { schema: ref("CompactJws") },
|
|
4299
|
+
"application/json": { schema: { type: "array" } },
|
|
4300
|
+
"text/csv": { schema: { type: "string" } }
|
|
4301
|
+
}
|
|
4302
|
+
} }
|
|
4303
|
+
}) }
|
|
4304
|
+
};
|
|
4305
|
+
const OAUTH_FORM = "application/x-www-form-urlencoded";
|
|
4306
|
+
const IDENTITY = {
|
|
4307
|
+
[`${ENV}/oauth/authorize`]: { get: open({
|
|
4308
|
+
summary: "Authorization code with PKCE; S256 only",
|
|
4309
|
+
parameters: [
|
|
4310
|
+
...[
|
|
4311
|
+
"response_type",
|
|
4312
|
+
"client_id",
|
|
4313
|
+
"redirect_uri",
|
|
4314
|
+
"code_challenge"
|
|
4315
|
+
].map((name) => ({
|
|
4316
|
+
name,
|
|
4317
|
+
in: "query",
|
|
4318
|
+
required: true,
|
|
4319
|
+
schema: { type: "string" }
|
|
4320
|
+
})),
|
|
4321
|
+
{
|
|
4322
|
+
name: "code_challenge_method",
|
|
4323
|
+
in: "query",
|
|
4324
|
+
required: true,
|
|
4325
|
+
schema: { const: "S256" }
|
|
4326
|
+
},
|
|
4327
|
+
{
|
|
4328
|
+
name: "state",
|
|
4329
|
+
in: "query",
|
|
4330
|
+
schema: { type: "string" }
|
|
4331
|
+
},
|
|
4332
|
+
{
|
|
4333
|
+
name: "scope",
|
|
4334
|
+
in: "query",
|
|
4335
|
+
schema: { type: "string" }
|
|
4336
|
+
}
|
|
4337
|
+
],
|
|
4338
|
+
responses: {
|
|
4339
|
+
"302": empty("Redirect to the client with a code"),
|
|
4340
|
+
"400": json("invalid_request, including plain PKCE", ref("OAuthError"))
|
|
4341
|
+
}
|
|
4342
|
+
}) },
|
|
4343
|
+
[`${ENV}/oauth/device_authorization`]: { post: {
|
|
4344
|
+
security: [{ oauthClient: [] }, { publicClient: [] }],
|
|
4345
|
+
summary: "RFC 8628 device authorization",
|
|
4346
|
+
requestBody: body({
|
|
4347
|
+
type: "object",
|
|
4348
|
+
required: ["client_id"]
|
|
4349
|
+
}, OAUTH_FORM),
|
|
4350
|
+
responses: {
|
|
4351
|
+
"200": json("The codes", {
|
|
4352
|
+
type: "object",
|
|
4353
|
+
required: [
|
|
4354
|
+
"device_code",
|
|
4355
|
+
"user_code",
|
|
4356
|
+
"verification_uri",
|
|
4357
|
+
"expires_in"
|
|
4358
|
+
],
|
|
4359
|
+
properties: {
|
|
4360
|
+
device_code: { type: "string" },
|
|
4361
|
+
user_code: { type: "string" },
|
|
4362
|
+
verification_uri: { type: "string" },
|
|
4363
|
+
verification_uri_complete: { type: "string" },
|
|
4364
|
+
expires_in: { type: "integer" },
|
|
4365
|
+
interval: { type: "integer" }
|
|
4366
|
+
}
|
|
4367
|
+
}),
|
|
4368
|
+
"400": json("invalid_request or invalid_client", ref("OAuthError"))
|
|
4369
|
+
}
|
|
4370
|
+
} },
|
|
4371
|
+
[`${ENV}/oauth/token`]: { post: {
|
|
4372
|
+
security: [{ oauthClient: [] }, { publicClient: [] }],
|
|
4373
|
+
summary: "authorization_code, device_code, client_credentials and token exchange (confidential only), refresh_token",
|
|
4374
|
+
requestBody: body({
|
|
4375
|
+
type: "object",
|
|
4376
|
+
required: ["grant_type"],
|
|
4377
|
+
properties: { grant_type: { enum: [
|
|
4378
|
+
"authorization_code",
|
|
4379
|
+
"urn:ietf:params:oauth:grant-type:device_code",
|
|
4380
|
+
"client_credentials",
|
|
4381
|
+
"urn:ietf:params:oauth:grant-type:token-exchange",
|
|
4382
|
+
"refresh_token"
|
|
4383
|
+
] } }
|
|
4384
|
+
}, OAUTH_FORM),
|
|
4385
|
+
responses: {
|
|
4386
|
+
"200": json("The tokens", ref("TokenResponse")),
|
|
4387
|
+
"400": json("An RFC 6749 or RFC 8628 error", ref("OAuthError")),
|
|
4388
|
+
"401": json("invalid_client", ref("OAuthError"))
|
|
4389
|
+
}
|
|
4390
|
+
} },
|
|
4391
|
+
[`${ENV}/.well-known/openid-configuration`]: { get: open({
|
|
4392
|
+
summary: "Issuer metadata",
|
|
4393
|
+
responses: { "200": json("The metadata", ref("Metadata")) }
|
|
4394
|
+
}) },
|
|
4395
|
+
[`${ENV}/.well-known/jwks.json`]: { get: open({
|
|
4396
|
+
summary: "The environment's JWK Set",
|
|
4397
|
+
responses: { "200": json("The JWK Set", ref("Jwks")) }
|
|
4398
|
+
}) }
|
|
4399
|
+
};
|
|
4400
|
+
const ads = (summary) => ({ post: {
|
|
4401
|
+
security: [{ adsCaller: [] }],
|
|
4402
|
+
summary,
|
|
4403
|
+
requestBody: body({ type: "object" }),
|
|
4404
|
+
responses: {
|
|
4405
|
+
"200": json("The AuthZEN response", { type: "object" }),
|
|
4406
|
+
"400": problem("Not an AuthZEN request"),
|
|
4407
|
+
"401": problem("No verified caller")
|
|
4408
|
+
}
|
|
4409
|
+
} });
|
|
4410
|
+
const ADS = {
|
|
4411
|
+
[`${ENV}/access/v1/evaluation`]: ads("AuthZEN evaluation"),
|
|
4412
|
+
[`${ENV}/access/v1/evaluations`]: ads("AuthZEN evaluations"),
|
|
4413
|
+
[`${ENV}/access/v1/search/subject`]: ads("AuthZEN subject search"),
|
|
4414
|
+
[`${ENV}/access/v1/search/resource`]: ads("AuthZEN resource search"),
|
|
4415
|
+
[`${ENV}/access/v1/search/action`]: ads("AuthZEN action search"),
|
|
4416
|
+
[`${ENV}/.well-known/authzen-configuration`]: { get: open({
|
|
4417
|
+
summary: "AuthZEN metadata",
|
|
4418
|
+
responses: { "200": json("The metadata", ref("Metadata")) }
|
|
4419
|
+
}) }
|
|
4420
|
+
};
|
|
4421
|
+
const ssf = (operation) => ({
|
|
4422
|
+
security: [{ ssfReceiver: [] }],
|
|
4423
|
+
...operation
|
|
4424
|
+
});
|
|
4425
|
+
const STREAM_ID = {
|
|
4426
|
+
name: "stream_id",
|
|
4427
|
+
in: "query",
|
|
4428
|
+
schema: { type: "string" }
|
|
4429
|
+
};
|
|
4430
|
+
const SSF = {
|
|
4431
|
+
[`${ENV}/ssf/receive/{connector}`]: {
|
|
4432
|
+
parameters: [{
|
|
4433
|
+
name: "connector",
|
|
4434
|
+
in: "path",
|
|
4435
|
+
required: true,
|
|
4436
|
+
schema: { type: "string" }
|
|
4437
|
+
}],
|
|
4438
|
+
post: {
|
|
4439
|
+
security: [{ connectorStream: [] }],
|
|
4440
|
+
summary: "RFC 8935 push into the Cloud's SSF receiver",
|
|
4441
|
+
requestBody: body(ref("CompactJws"), "application/secevent+jwt"),
|
|
4442
|
+
responses: {
|
|
4443
|
+
"202": empty("Accepted"),
|
|
4444
|
+
"400": json("An RFC 8935 error", {
|
|
4445
|
+
type: "object",
|
|
4446
|
+
required: ["err"]
|
|
4447
|
+
}),
|
|
4448
|
+
"401": empty("Unknown stream credential")
|
|
4449
|
+
}
|
|
4450
|
+
}
|
|
4451
|
+
},
|
|
4452
|
+
[`${ENV}/.well-known/ssf-configuration`]: { get: open({
|
|
4453
|
+
summary: "SSF transmitter metadata",
|
|
4454
|
+
responses: { "200": json("The metadata", ref("Metadata")) }
|
|
4455
|
+
}) },
|
|
4456
|
+
[`${ENV}/ssf/streams`]: {
|
|
4457
|
+
get: ssf({
|
|
4458
|
+
summary: "Read streams",
|
|
4459
|
+
parameters: [STREAM_ID],
|
|
4460
|
+
responses: { "200": json("The stream", ref("SsfStream")) }
|
|
4461
|
+
}),
|
|
4462
|
+
post: ssf({
|
|
4463
|
+
summary: "Create a stream",
|
|
4464
|
+
requestBody: body({ type: "object" }),
|
|
4465
|
+
responses: { "201": json("The stream", ref("SsfStream")) }
|
|
4466
|
+
}),
|
|
4467
|
+
patch: ssf({
|
|
4468
|
+
summary: "Update a stream",
|
|
4469
|
+
requestBody: body(ref("SsfStream")),
|
|
4470
|
+
responses: { "200": json("The stream", ref("SsfStream")) }
|
|
4471
|
+
}),
|
|
4472
|
+
delete: ssf({
|
|
4473
|
+
summary: "Delete a stream",
|
|
4474
|
+
parameters: [STREAM_ID],
|
|
4475
|
+
responses: { "204": empty("Deleted") }
|
|
4476
|
+
})
|
|
4477
|
+
},
|
|
4478
|
+
[`${ENV}/ssf/status`]: {
|
|
4479
|
+
get: ssf({
|
|
4480
|
+
summary: "A stream's status",
|
|
4481
|
+
parameters: [STREAM_ID],
|
|
4482
|
+
responses: { "200": json("The status", {
|
|
4483
|
+
type: "object",
|
|
4484
|
+
required: ["status"]
|
|
4485
|
+
}) }
|
|
4486
|
+
}),
|
|
4487
|
+
post: ssf({
|
|
4488
|
+
summary: "Set a stream's status",
|
|
4489
|
+
requestBody: body({
|
|
4490
|
+
type: "object",
|
|
4491
|
+
required: ["stream_id", "status"]
|
|
4492
|
+
}),
|
|
4493
|
+
responses: { "200": json("The status", {
|
|
4494
|
+
type: "object",
|
|
4495
|
+
required: ["status"]
|
|
4496
|
+
}) }
|
|
4497
|
+
})
|
|
4498
|
+
},
|
|
4499
|
+
[`${ENV}/ssf/verify`]: { post: ssf({
|
|
4500
|
+
summary: "Send a verification event",
|
|
4501
|
+
requestBody: body({
|
|
4502
|
+
type: "object",
|
|
4503
|
+
required: ["stream_id"]
|
|
4504
|
+
}),
|
|
4505
|
+
responses: { "204": empty("Queued") }
|
|
4506
|
+
}) },
|
|
4507
|
+
[`${ENV}/ssf/poll`]: { post: ssf({
|
|
4508
|
+
summary: "RFC 8936 poll",
|
|
4509
|
+
requestBody: body({ type: "object" }),
|
|
4510
|
+
responses: {
|
|
4511
|
+
"200": json("Pending SETs", ref("SetPoll")),
|
|
4512
|
+
"400": json("invalid_request", {
|
|
4513
|
+
type: "object",
|
|
4514
|
+
required: ["err"]
|
|
4515
|
+
})
|
|
4516
|
+
}
|
|
4517
|
+
}) }
|
|
4518
|
+
};
|
|
4519
|
+
const scim = (summary) => ({
|
|
4520
|
+
security: [{ scimBearer: [] }],
|
|
4521
|
+
summary,
|
|
4522
|
+
responses: {
|
|
4523
|
+
"200": {
|
|
4524
|
+
description: "The SCIM response",
|
|
4525
|
+
content: { "application/scim+json": { schema: { type: "object" } } }
|
|
4526
|
+
},
|
|
4527
|
+
"401": empty("Not the connection's bearer")
|
|
4528
|
+
}
|
|
4529
|
+
});
|
|
4530
|
+
const SCIM = { "/scim/v2/{connection}/{resource}": {
|
|
4531
|
+
parameters: [{
|
|
4532
|
+
name: "connection",
|
|
4533
|
+
in: "path",
|
|
4534
|
+
required: true,
|
|
4535
|
+
schema: { type: "string" }
|
|
4536
|
+
}, {
|
|
4537
|
+
name: "resource",
|
|
4538
|
+
in: "path",
|
|
4539
|
+
required: true,
|
|
4540
|
+
description: "The SCIM path below the connection",
|
|
4541
|
+
schema: { type: "string" }
|
|
4542
|
+
}],
|
|
4543
|
+
get: scim("SCIM read"),
|
|
4544
|
+
post: scim("SCIM create"),
|
|
4545
|
+
put: scim("SCIM replace"),
|
|
4546
|
+
patch: scim("SCIM patch"),
|
|
4547
|
+
delete: scim("SCIM delete")
|
|
4548
|
+
} };
|
|
4549
|
+
function withEnv(paths) {
|
|
4550
|
+
return Object.fromEntries(Object.entries(paths).map(([path, item]) => {
|
|
4551
|
+
if (!path.startsWith(ENV)) return [path, item];
|
|
4552
|
+
const parameters = Array.isArray(item["parameters"]) ? item["parameters"] : [];
|
|
4553
|
+
return [path, {
|
|
4554
|
+
...item,
|
|
4555
|
+
parameters: [{ $ref: "#/components/parameters/env" }, ...parameters]
|
|
4556
|
+
}];
|
|
4557
|
+
}));
|
|
4558
|
+
}
|
|
4559
|
+
const bearer = (description) => ({
|
|
4560
|
+
type: "http",
|
|
4561
|
+
scheme: "bearer",
|
|
4562
|
+
description
|
|
4563
|
+
});
|
|
4564
|
+
/**
|
|
4565
|
+
* `schemas/cloud-api-v1.json`: the PermDock Cloud HTTP API as an OpenAPI 3.1
|
|
4566
|
+
* document. The route tables in `adapters/cloud.mdx` and `docs:drift` keep it whole.
|
|
4567
|
+
*/
|
|
4568
|
+
const CLOUD_API_DOCUMENT = freezeDeep({
|
|
4569
|
+
openapi: "3.1.1",
|
|
4570
|
+
info: {
|
|
4571
|
+
title: "PermDock Cloud API",
|
|
4572
|
+
version: "1.0.0",
|
|
4573
|
+
description: "The platform issuer and project read API the permdock CLI calls, the routes permdock/cloud calls, and the authoring, identity, ADS, SSF and SCIM routes a Cloud implementation serves. Errors are RFC 9457 Problem Details unless the route's own standard says otherwise."
|
|
4574
|
+
},
|
|
4575
|
+
jsonSchemaDialect: "https://spec.openapis.org/oas/3.1/dialect/base",
|
|
4576
|
+
servers: [{ url: "https://permdock.com/api" }],
|
|
4577
|
+
paths: withEnv({
|
|
4578
|
+
...PLATFORM_PATHS,
|
|
4579
|
+
...APPROVALS,
|
|
4580
|
+
...RUNTIME,
|
|
4581
|
+
...AUTHORING,
|
|
4582
|
+
...IDENTITY,
|
|
4583
|
+
...ADS,
|
|
4584
|
+
...SSF,
|
|
4585
|
+
...SCIM
|
|
4586
|
+
}),
|
|
4587
|
+
components: {
|
|
4588
|
+
parameters: {
|
|
4589
|
+
env: {
|
|
4590
|
+
name: "env",
|
|
4591
|
+
in: "path",
|
|
4592
|
+
required: true,
|
|
4593
|
+
schema: {
|
|
4594
|
+
type: "string",
|
|
4595
|
+
minLength: 1
|
|
4596
|
+
}
|
|
4597
|
+
},
|
|
4598
|
+
id: {
|
|
4599
|
+
name: "id",
|
|
4600
|
+
in: "path",
|
|
4601
|
+
required: true,
|
|
4602
|
+
schema: {
|
|
4603
|
+
type: "string",
|
|
4604
|
+
minLength: 1
|
|
4605
|
+
}
|
|
4606
|
+
}
|
|
4607
|
+
},
|
|
4608
|
+
schemas: {
|
|
4609
|
+
...SCHEMAS,
|
|
4610
|
+
...PLATFORM_SCHEMAS
|
|
4611
|
+
},
|
|
4612
|
+
securitySchemes: {
|
|
4613
|
+
client: bearer("The client key (PERMDOCK_CLOUD_KEY)"),
|
|
4614
|
+
admin: bearer("The admin key (PERMDOCK_CLOUD_ADMIN_KEY)"),
|
|
4615
|
+
export: bearer("An export key"),
|
|
4616
|
+
oauthClient: {
|
|
4617
|
+
type: "http",
|
|
4618
|
+
scheme: "basic",
|
|
4619
|
+
description: "A confidential OAuth client, client_secret_basic"
|
|
4620
|
+
},
|
|
4621
|
+
publicClient: {
|
|
4622
|
+
type: "apiKey",
|
|
4623
|
+
in: "query",
|
|
4624
|
+
name: "client_id",
|
|
4625
|
+
description: "A public OAuth client; token exchange refuses it"
|
|
4626
|
+
},
|
|
4627
|
+
adsCaller: bearer("A Vercel OIDC or client-credentials token for the environment"),
|
|
4628
|
+
ssfReceiver: bearer("A receiver's token for its stream"),
|
|
4629
|
+
connectorStream: bearer("The caep-transmitter connector's stream credential"),
|
|
4630
|
+
scimBearer: bearer("The SCIM connection's bearer"),
|
|
4631
|
+
...PLATFORM_SECURITY_SCHEMES
|
|
4632
|
+
}
|
|
4633
|
+
}
|
|
4634
|
+
});
|
|
4635
|
+
//#endregion
|
|
4636
|
+
//#region src/testing/cloud-api/validate.ts
|
|
4637
|
+
const DOCUMENT_ID = "https://permdock.com/schemas/cloud-api-v1.json";
|
|
4638
|
+
const METHODS = [
|
|
4639
|
+
"get",
|
|
4640
|
+
"post",
|
|
4641
|
+
"put",
|
|
4642
|
+
"patch",
|
|
4643
|
+
"delete"
|
|
4644
|
+
];
|
|
4645
|
+
function patternOf(template) {
|
|
4646
|
+
const source = template.split(/(\{[^}]+\})/u).map((part) => {
|
|
4647
|
+
if (part === "{resource}") return ".+";
|
|
4648
|
+
if (part.startsWith("{")) return "[^/]+";
|
|
4649
|
+
return part.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
|
|
4650
|
+
}).join("");
|
|
4651
|
+
return new RegExp(`^${source}$`, "u");
|
|
4652
|
+
}
|
|
4653
|
+
function routesOf(paths) {
|
|
4654
|
+
const routes = [];
|
|
4655
|
+
for (const [template, item] of Object.entries(paths)) for (const method of METHODS) {
|
|
4656
|
+
const responses = item[method]?.responses;
|
|
4657
|
+
if (isRecord(responses)) routes.push({
|
|
4658
|
+
method: method.toUpperCase(),
|
|
4659
|
+
template,
|
|
4660
|
+
pattern: patternOf(template),
|
|
4661
|
+
responses
|
|
4662
|
+
});
|
|
4663
|
+
}
|
|
4664
|
+
return routes.toSorted((a, b) => a.template.split("{").length - b.template.split("{").length);
|
|
4665
|
+
}
|
|
4666
|
+
function pointer(parts) {
|
|
4667
|
+
return parts.map((part) => part.replaceAll("~", "~0").replaceAll("/", "~1")).join("/");
|
|
4668
|
+
}
|
|
4669
|
+
/** A checker for responses of the PermDock Cloud API, compiled once from `CLOUD_API_DOCUMENT`. */
|
|
4670
|
+
function cloudApiValidator() {
|
|
4671
|
+
const ajv = new import__2020.Ajv2020({
|
|
4672
|
+
strict: false,
|
|
4673
|
+
allErrors: false,
|
|
4674
|
+
validateFormats: false
|
|
4675
|
+
});
|
|
4676
|
+
ajv.addSchema(APPROVAL_REQUEST_SCHEMA);
|
|
4677
|
+
ajv.addSchema(CONNECTOR_SCHEMA);
|
|
4678
|
+
ajv.addSchema(catalogSchema);
|
|
4679
|
+
ajv.addSchema(PUSHED_POLICY_SCHEMA);
|
|
4680
|
+
ajv.addSchema(supabaseManifestSchema);
|
|
4681
|
+
ajv.addSchema({
|
|
4682
|
+
...CLOUD_API_DOCUMENT,
|
|
4683
|
+
$id: DOCUMENT_ID
|
|
4684
|
+
});
|
|
4685
|
+
const routes = routesOf(CLOUD_API_DOCUMENT["paths"]);
|
|
4686
|
+
const validators = /* @__PURE__ */ new Map();
|
|
4687
|
+
const validatorFor = (parts) => {
|
|
4688
|
+
const key = pointer(parts);
|
|
4689
|
+
if (!validators.has(key)) validators.set(key, ajv.getSchema(`${DOCUMENT_ID}#/${key}`));
|
|
4690
|
+
return validators.get(key);
|
|
4691
|
+
};
|
|
4692
|
+
return async (method, path, response) => {
|
|
4693
|
+
const name = `${method} ${path} ${String(response.status)}`;
|
|
4694
|
+
const route = routes.find((item) => item.method === method && item.pattern.test(path));
|
|
4695
|
+
if (route === void 0) return [`${method} ${path} is not in cloud-api-v1.json`];
|
|
4696
|
+
const spec = route.responses[String(response.status)];
|
|
4697
|
+
if (!isRecord(spec)) return [`${name}: cloud-api-v1.json documents no ${String(response.status)} for ${route.template}`];
|
|
4698
|
+
const content = spec["content"];
|
|
4699
|
+
if (!isRecord(content)) return [];
|
|
4700
|
+
const [type = ""] = (response.headers.get("content-type") ?? "").toLowerCase().split(";").map((part) => part.trim());
|
|
4701
|
+
if (!(type in content)) return [`${name}: content type "${type}", documented ${Object.keys(content).join(", ")}`];
|
|
4702
|
+
const text = await response.text();
|
|
4703
|
+
let body = text;
|
|
4704
|
+
if (type.endsWith("json")) try {
|
|
4705
|
+
body = JSON.parse(text);
|
|
4706
|
+
} catch {
|
|
4707
|
+
return [`${name}: the ${type} body is not JSON`];
|
|
4708
|
+
}
|
|
4709
|
+
const validate = validatorFor([
|
|
4710
|
+
"paths",
|
|
4711
|
+
route.template,
|
|
4712
|
+
method.toLowerCase(),
|
|
4713
|
+
"responses",
|
|
4714
|
+
String(response.status),
|
|
4715
|
+
"content",
|
|
4716
|
+
type,
|
|
4717
|
+
"schema"
|
|
4718
|
+
]);
|
|
4719
|
+
if (validate === void 0 || validate(body)) return [];
|
|
4720
|
+
return [`${name}: ${ajv.errorsText(validate.errors)}`];
|
|
4721
|
+
};
|
|
4722
|
+
}
|
|
4723
|
+
//#endregion
|
|
4724
|
+
//#region src/testing/cloud-api/index.ts
|
|
4725
|
+
const REFUSED_TYPES = [
|
|
4726
|
+
"vnd.permdock.policy+jwt",
|
|
4727
|
+
"vnd.permdock.snapshot+jwt",
|
|
4728
|
+
"vnd.permdock.evidence+jwt",
|
|
4729
|
+
"at+jwt"
|
|
4730
|
+
];
|
|
4731
|
+
const admin = {
|
|
4732
|
+
principal: {
|
|
4733
|
+
id: "u_9",
|
|
4734
|
+
roles: ["admin"]
|
|
4735
|
+
},
|
|
4736
|
+
context: {}
|
|
4737
|
+
};
|
|
4738
|
+
const member = {
|
|
4739
|
+
principal: {
|
|
4740
|
+
id: "u_2",
|
|
4741
|
+
roles: ["member"]
|
|
4742
|
+
},
|
|
4743
|
+
context: {}
|
|
4744
|
+
};
|
|
4745
|
+
function sample(token, overrides = {}) {
|
|
4746
|
+
return {
|
|
4747
|
+
v: 1,
|
|
4748
|
+
id: `apr_${token}`,
|
|
4749
|
+
token,
|
|
4750
|
+
permission: "post.delete",
|
|
4751
|
+
scope: "post:delete",
|
|
4752
|
+
resource: {
|
|
4753
|
+
type: "post",
|
|
4754
|
+
id: "42"
|
|
4755
|
+
},
|
|
4756
|
+
subject: { principal: {
|
|
4757
|
+
id: "u_1",
|
|
4758
|
+
roles: ["member"]
|
|
4759
|
+
} },
|
|
4760
|
+
detail: "post.delete requires human approval.",
|
|
4761
|
+
createdAt: isoAt(nowMillis()),
|
|
4762
|
+
expiresAt: isoAt(nowMillis() + 36e5),
|
|
4763
|
+
status: "pending",
|
|
4764
|
+
...overrides
|
|
4765
|
+
};
|
|
4766
|
+
}
|
|
4767
|
+
const METADATA = "/.well-known/oauth-authorization-server";
|
|
4768
|
+
function trimSlash(value) {
|
|
4769
|
+
return value.endsWith("/") ? value.slice(0, -1) : value;
|
|
4770
|
+
}
|
|
4771
|
+
async function problemCode(response) {
|
|
4772
|
+
const body = await response.json();
|
|
4773
|
+
return isRecord(body) ? body["code"] : void 0;
|
|
4774
|
+
}
|
|
4775
|
+
/**
|
|
4776
|
+
* Runs the conformance runners and the HTTP contract checks against one
|
|
4777
|
+
* PermDock Cloud environment, and validates every response body against
|
|
4778
|
+
* `cloud-api-v1.json`. Point it at a test environment: it creates approvals,
|
|
4779
|
+
* sends decision events and deliveries to `.invalid` addresses, and pushes
|
|
4780
|
+
* the catalog the environment already holds.
|
|
4781
|
+
*/
|
|
4782
|
+
function testCloudApi(baseUrl, keys, options) {
|
|
4783
|
+
const fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
4784
|
+
const url = trimSlash(baseUrl);
|
|
4785
|
+
const prefix = trimSlash(new URL(url).pathname);
|
|
4786
|
+
const { issuer } = cloudEndpoints({
|
|
4787
|
+
url,
|
|
4788
|
+
...options.environment === void 0 ? {} : { environment: options.environment }
|
|
4789
|
+
});
|
|
4790
|
+
const check = cloudApiValidator();
|
|
4791
|
+
const problems = [];
|
|
4792
|
+
const checks = [];
|
|
4793
|
+
const recording = async (input, init) => {
|
|
4794
|
+
const request = new Request(input, init);
|
|
4795
|
+
const response = await fetchFn(request);
|
|
4796
|
+
const { pathname } = new URL(request.url);
|
|
4797
|
+
const path = pathname === `${METADATA}${prefix}` ? METADATA : pathname.slice(prefix.length);
|
|
4798
|
+
checks.push(check(request.method, path, response.clone()).then((found) => {
|
|
4799
|
+
problems.push(...found);
|
|
4800
|
+
}));
|
|
4801
|
+
return response;
|
|
4802
|
+
};
|
|
4803
|
+
const client = cloud({
|
|
4804
|
+
url,
|
|
4805
|
+
key: keys.client,
|
|
4806
|
+
fetch: recording,
|
|
4807
|
+
approver: options.approver,
|
|
4808
|
+
...options.environment === void 0 ? {} : { environment: options.environment },
|
|
4809
|
+
...options.verifier === void 0 ? {} : { verifier: options.verifier }
|
|
4810
|
+
});
|
|
4811
|
+
const call = (path, key, init = {}) => {
|
|
4812
|
+
const headers = new Headers(init.headers);
|
|
4813
|
+
if (key !== null) headers.set("authorization", `Bearer ${key}`);
|
|
4814
|
+
if (init.body !== void 0 && !headers.has("content-type")) headers.set("content-type", "application/json");
|
|
4815
|
+
return recording(`${issuer}${path}`, {
|
|
4816
|
+
...init,
|
|
4817
|
+
headers
|
|
4818
|
+
});
|
|
4819
|
+
};
|
|
4820
|
+
const tag = () => `cloud-api-${globalThis.crypto.randomUUID()}`;
|
|
4821
|
+
const resolveRaw = async (token, verdict, target) => {
|
|
4822
|
+
const approver = await approverHeaders(verdict, options.approver, issuer, target);
|
|
4823
|
+
return call("/approvals/by-token/resolve", keys.client, {
|
|
4824
|
+
method: "POST",
|
|
4825
|
+
headers: {
|
|
4826
|
+
[APPROVAL_HEADER]: token,
|
|
4827
|
+
...approver
|
|
4828
|
+
},
|
|
4829
|
+
body: JSON.stringify(verdict)
|
|
4830
|
+
});
|
|
4831
|
+
};
|
|
4832
|
+
const current = async (token) => {
|
|
4833
|
+
const response = await call("/approvals/by-token", keys.client, { headers: { [APPROVAL_HEADER]: token } });
|
|
4834
|
+
expect(response.status).toBe(200);
|
|
4835
|
+
const request = await response.json();
|
|
4836
|
+
const etag = response.headers.get("etag");
|
|
4837
|
+
return etag === null ? { request } : {
|
|
4838
|
+
request,
|
|
4839
|
+
etag
|
|
4840
|
+
};
|
|
4841
|
+
};
|
|
4842
|
+
testApprovalStore(client.approvals);
|
|
4843
|
+
testDecisionSink(client.sink);
|
|
4844
|
+
testPolicySource(client.policies);
|
|
4845
|
+
if (options.subject !== void 0) {
|
|
4846
|
+
const subject = options.subject;
|
|
4847
|
+
testSnapshotSource({ get: async () => client.snapshotsFor(await subject()).get() });
|
|
4848
|
+
}
|
|
4849
|
+
if (options.authzen !== void 0) testAuthzen(async (request) => recording(request), {
|
|
4850
|
+
vectors: options.authzen.vectors,
|
|
4851
|
+
origin: issuer,
|
|
4852
|
+
...options.authzen.headers === void 0 ? {} : { headers: options.authzen.headers }
|
|
4853
|
+
});
|
|
4854
|
+
if (options.platform !== void 0) testCloudPlatform(url, options.environment ?? "production", options.platform, recording);
|
|
4855
|
+
it("answers 401 Problem Details without a key", async () => {
|
|
4856
|
+
const response = await call("/approvals", null);
|
|
4857
|
+
expect(response.status).toBe(401);
|
|
4858
|
+
expect(response.headers.get("content-type")).toMatch(/^application\/problem\+json/u);
|
|
4859
|
+
});
|
|
4860
|
+
it("answers 403 to a key of another kind", async () => {
|
|
4861
|
+
expect((await call("/hosted-grants", keys.client)).status).toBe(403);
|
|
4862
|
+
if (keys.export !== void 0) expect((await call("/approvals", keys.export)).status).toBe(403);
|
|
4863
|
+
});
|
|
4864
|
+
it("sends an ETag on the approval read and refuses an approver token with a stale one", async () => {
|
|
4865
|
+
const token = tag();
|
|
4866
|
+
await client.approvals.create(sample(token));
|
|
4867
|
+
const { request, etag } = await current(token);
|
|
4868
|
+
expect(etag).toEqual(expect.any(String));
|
|
4869
|
+
const stale = await resolveRaw(token, {
|
|
4870
|
+
status: "approved",
|
|
4871
|
+
by: admin
|
|
4872
|
+
}, async () => ({
|
|
4873
|
+
request,
|
|
4874
|
+
etag: "\"stale\""
|
|
4875
|
+
}));
|
|
4876
|
+
expect(stale.status).toBe(409);
|
|
4877
|
+
expect((await client.approvals.get(token))?.status).toBe("pending");
|
|
4878
|
+
});
|
|
4879
|
+
it("answers the documented resolve statuses", async () => {
|
|
4880
|
+
const verdict = {
|
|
4881
|
+
status: "approved",
|
|
4882
|
+
by: admin
|
|
4883
|
+
};
|
|
4884
|
+
const unsigned = tag();
|
|
4885
|
+
await client.approvals.create(sample(unsigned));
|
|
4886
|
+
const missingApprover = await call("/approvals/by-token/resolve", keys.client, {
|
|
4887
|
+
method: "POST",
|
|
4888
|
+
headers: { [APPROVAL_HEADER]: unsigned },
|
|
4889
|
+
body: JSON.stringify(verdict)
|
|
4890
|
+
});
|
|
4891
|
+
expect(missingApprover.status).toBe(401);
|
|
4892
|
+
expect(await problemCode(missingApprover)).toBe("approver-unauthenticated");
|
|
4893
|
+
const gated = tag();
|
|
4894
|
+
await client.approvals.create(sample(gated, { approvers: { by: {
|
|
4895
|
+
kind: "role",
|
|
4896
|
+
role: "admin",
|
|
4897
|
+
scope: "global"
|
|
4898
|
+
} } }));
|
|
4899
|
+
const ineligible = await resolveRaw(gated, {
|
|
4900
|
+
status: "approved",
|
|
4901
|
+
by: member
|
|
4902
|
+
}, async () => current(gated));
|
|
4903
|
+
expect(ineligible.status).toBe(403);
|
|
4904
|
+
const once = tag();
|
|
4905
|
+
await client.approvals.create(sample(once));
|
|
4906
|
+
expect((await resolveRaw(once, verdict, async () => current(once))).status).toBe(200);
|
|
4907
|
+
expect((await resolveRaw(once, verdict, async () => current(once))).status).toBe(409);
|
|
4908
|
+
const expired = tag();
|
|
4909
|
+
await client.approvals.create(sample(expired, {
|
|
4910
|
+
createdAt: isoAt(nowMillis() - 72e5),
|
|
4911
|
+
expiresAt: isoAt(nowMillis() - 6e4)
|
|
4912
|
+
}));
|
|
4913
|
+
expect((await resolveRaw(expired, verdict, async () => current(expired))).status).toBe(410);
|
|
4914
|
+
const missing = tag();
|
|
4915
|
+
const notFound = await resolveRaw(missing, verdict, async () => ({ request: sample(missing) }));
|
|
4916
|
+
expect(notFound.status).toBe(404);
|
|
4917
|
+
});
|
|
4918
|
+
it("refuses to sign the Cloud's own token types and approver tokens", async () => {
|
|
4919
|
+
for (const typ of REFUSED_TYPES) {
|
|
4920
|
+
const response = await call("/sign", keys.client, {
|
|
4921
|
+
method: "POST",
|
|
4922
|
+
headers: { accept: "application/jwt" },
|
|
4923
|
+
body: JSON.stringify({
|
|
4924
|
+
typ,
|
|
4925
|
+
claims: { sub: "u_1" },
|
|
4926
|
+
aud: issuer
|
|
4927
|
+
})
|
|
4928
|
+
});
|
|
4929
|
+
expect([typ, response.status]).toEqual([typ, 422]);
|
|
4930
|
+
}
|
|
4931
|
+
});
|
|
4932
|
+
it("repeats a delivery for a repeated Idempotency-Key and requires one", async () => {
|
|
4933
|
+
const delivery = tag();
|
|
4934
|
+
const message = {
|
|
4935
|
+
channel: "email",
|
|
4936
|
+
delivery,
|
|
4937
|
+
target: {
|
|
4938
|
+
address: `${delivery}@example.invalid`,
|
|
4939
|
+
principal: "u_1",
|
|
4940
|
+
permission: "post.delete"
|
|
4941
|
+
},
|
|
4942
|
+
message: {
|
|
4943
|
+
subject: "PermDock contract test",
|
|
4944
|
+
text: "Ignore."
|
|
4945
|
+
}
|
|
4946
|
+
};
|
|
4947
|
+
const send = (headers) => call("/deliveries", keys.client, {
|
|
4948
|
+
method: "POST",
|
|
4949
|
+
headers,
|
|
4950
|
+
body: JSON.stringify(message)
|
|
4951
|
+
});
|
|
4952
|
+
const first = await send({ "idempotency-key": delivery });
|
|
4953
|
+
const second = await send({ "idempotency-key": delivery });
|
|
4954
|
+
expect(first.ok && second.ok).toBe(true);
|
|
4955
|
+
const [a, b] = await Promise.all([first.json(), second.json()]);
|
|
4956
|
+
expect(isRecord(a) && isRecord(b) && a["id"] === b["id"]).toBe(true);
|
|
4957
|
+
expect((await send({})).status).toBe(400);
|
|
4958
|
+
});
|
|
4959
|
+
it("answers unchanged for the catalog it already holds", async (context) => {
|
|
4960
|
+
const response = await call("/catalog", keys.client);
|
|
4961
|
+
if (response.status === 404) context.skip();
|
|
4962
|
+
expect(response.status).toBe(200);
|
|
4963
|
+
const catalog = await response.json();
|
|
4964
|
+
expect(isRecord(catalog)).toBe(true);
|
|
4965
|
+
const pushed = await client.catalog.push(catalog);
|
|
4966
|
+
expect(pushed).toMatchObject({ unchanged: true });
|
|
4967
|
+
});
|
|
4968
|
+
it("answers 422 with a reason for a hosted grant the engine would drop", async (context) => {
|
|
4969
|
+
if (keys.admin === void 0) context.skip();
|
|
4970
|
+
const response = await call("/hosted-grants", keys.admin ?? "", {
|
|
4971
|
+
method: "POST",
|
|
4972
|
+
body: JSON.stringify({
|
|
4973
|
+
permission: "permdock.cloud-api-test.missing",
|
|
4974
|
+
to: { kind: "authenticated" }
|
|
4975
|
+
})
|
|
4976
|
+
});
|
|
4977
|
+
expect(response.status).toBe(422);
|
|
4978
|
+
const body = await response.json();
|
|
4979
|
+
expect(isRecord(body) && body["reason"]).toBe("unknown-permission");
|
|
4980
|
+
});
|
|
4981
|
+
it("serves the snapshot as application/jwt only to a fresh subject token", async (context) => {
|
|
4982
|
+
if (options.subject === void 0) {
|
|
4983
|
+
context.skip();
|
|
4984
|
+
return;
|
|
4985
|
+
}
|
|
4986
|
+
const token = await options.subject();
|
|
4987
|
+
const get = (headers) => call("/snapshot", keys.client, { headers: {
|
|
4988
|
+
accept: "application/jwt",
|
|
4989
|
+
...headers
|
|
4990
|
+
} });
|
|
4991
|
+
const first = await get({ "permdock-subject": token });
|
|
4992
|
+
expect(first.status).toBe(200);
|
|
4993
|
+
expect(first.headers.get("content-type")).toMatch(/^application\/jwt/u);
|
|
4994
|
+
expect((await get({ "permdock-subject": token })).status).toBe(401);
|
|
4995
|
+
expect((await get({})).status).toBe(401);
|
|
4996
|
+
});
|
|
4997
|
+
it("answers every request in the shapes cloud-api-v1.json documents", async () => {
|
|
4998
|
+
await Promise.all(checks);
|
|
4999
|
+
expect(problems).toEqual([]);
|
|
5000
|
+
});
|
|
5001
|
+
}
|
|
5002
|
+
//#endregion
|
|
5003
|
+
//#region src/testing/conformance/where.ts
|
|
5004
|
+
const WHERE_ROWS = [
|
|
5005
|
+
{
|
|
5006
|
+
id: "r1",
|
|
5007
|
+
authorId: "u1",
|
|
5008
|
+
score: 5,
|
|
5009
|
+
title: "Plan 100%",
|
|
5010
|
+
archived: false,
|
|
5011
|
+
note: null
|
|
5012
|
+
},
|
|
5013
|
+
{
|
|
5014
|
+
id: "r2",
|
|
5015
|
+
authorId: "u2",
|
|
5016
|
+
score: 10,
|
|
5017
|
+
title: "plan_b",
|
|
5018
|
+
archived: true,
|
|
5019
|
+
note: "kept"
|
|
5020
|
+
},
|
|
5021
|
+
{
|
|
5022
|
+
id: "r3",
|
|
5023
|
+
authorId: null,
|
|
5024
|
+
score: null,
|
|
5025
|
+
title: null,
|
|
5026
|
+
archived: null,
|
|
5027
|
+
note: null
|
|
5028
|
+
}
|
|
5029
|
+
];
|
|
5030
|
+
const WHERE_CASES = [
|
|
5031
|
+
["eq", {
|
|
5032
|
+
op: "eq",
|
|
5033
|
+
field: "authorId",
|
|
5034
|
+
value: "u1"
|
|
5035
|
+
}],
|
|
5036
|
+
["ne", {
|
|
5037
|
+
op: "ne",
|
|
5038
|
+
field: "authorId",
|
|
5039
|
+
value: "u1"
|
|
5040
|
+
}],
|
|
5041
|
+
["eq on a boolean", {
|
|
5042
|
+
op: "eq",
|
|
5043
|
+
field: "archived",
|
|
5044
|
+
value: false
|
|
2252
5045
|
}],
|
|
2253
5046
|
["gt", {
|
|
2254
5047
|
op: "gt",
|
|
@@ -2419,9 +5212,9 @@ const jwtFixtureTokens = {
|
|
|
2419
5212
|
wrongIss: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoiMjAyNi0wOSIsInR5cCI6ImF0K2p3dCJ9.eyJzdWIiOiJ1XzEiLCJpc3MiOiJodHRwczovL2V2aWwuZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2FwaS5leGFtcGxlLmNvbSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoyMDAwMDAwMDAwfQ.tIcLHLa9JLtKz1gqjRGdr5DFbUzRDs5KsoPsJCxspvbBcsFfRFG2G0XJnd1h4QHyZ_5B4OhY3W_jIIsnOPD9Bg",
|
|
2420
5213
|
unknownKid: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoibm9wZSIsInR5cCI6ImF0K2p3dCJ9.eyJzdWIiOiJ1XzEiLCJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUuY29tIiwiYXVkIjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20iLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6MjAwMDAwMDAwMH0.walgG5GvpCVBnkMtkdw-epi5c93JqfvAM_N4EWR9Tqhiw_cnzITnlQyoVfihECmV1fqtFrhwM1RCIRJvd38zAQ",
|
|
2421
5214
|
none: "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1XzEiLCJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUuY29tIiwiYXVkIjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20iLCJleHAiOjIwMDAwMDAwMDAsImlhdCI6MTcwMDAwMDAwMH0.",
|
|
2422
|
-
snapshot: "
|
|
2423
|
-
approval: "
|
|
2424
|
-
decisions: "
|
|
5215
|
+
snapshot: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoiMjAyNi0wOSIsInR5cCI6InZuZC5wZXJtZG9jay5zbmFwc2hvdCtqd3QifQ.eyJzbmFwc2hvdCI6eyJ2IjoxLCJzdWJqZWN0Ijp7InByaW5jaXBhbCI6eyJpZCI6InVfMSIsInJvbGVzIjpbIm1lbWJlciJdfSwiY29udGV4dCI6e319LCJyb2xlcyI6WyJtZW1iZXIiXSwiZ3JhbnRzIjpbXSwidGVuYW50cyI6W119LCJzdWIiOiJ1XzEiLCJpYXQiOjE3MDAwMDAwMDAsImp0aSI6InNuYXBfZml4dHVyZSIsImlzcyI6Imh0dHBzOi8vYXBwLmV4YW1wbGUuY29tIiwiYXVkIjoiaHR0cHM6Ly9hcHAuZXhhbXBsZS5jb20iLCJleHAiOjIwMDAwMDAwMDB9.41KijBuOn1Ehodp60-Pn6qx_WrHQ-BiVUmYy2vjcHjO3o1dopXxjnHJP7P1c-6ghYgrvucPu7hZrDjTtPhxWBw",
|
|
5216
|
+
approval: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoiMjAyNi0wOSIsInR5cCI6InZuZC5wZXJtZG9jay5hcHByb3ZhbCtqd3QifQ.eyJzdWIiOiJ1XzEiLCJ0b2tlbiI6InNoYS0yNTY6NDdERVFwajhIQlNhLV9USW1XLTVKQ2V1UWVSa201Tk1wSldaRzNoU3VGVSIsInJlc29sdmVkX2J5IjoidV8yIiwiYXBwcm92YWxzIjpbeyJieSI6InVfMiIsImF0IjoiMjAyMy0xMS0xNFQyMjoxMzoyMFoifV0sImF1dGhvcml6YXRpb25fZGV0YWlscyI6W3sidHlwZSI6InBvc3QiLCJhY3Rpb25zIjpbImRlbGV0ZSJdLCJpZGVudGlmaWVyIjoiNDIifV0sImlhdCI6MTcwMDAwMDAwMCwianRpIjoiYXByX2ZpeHR1cmUiLCJpc3MiOiJodHRwczovL2FwcC5leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHBzOi8vYXBwLmV4YW1wbGUuY29tIiwiZXhwIjoyMDAwMDAwMDAwfQ.Q76F3tDT2FrEnxD9TjdLfEayJEyyGUWHu6bTCCl5Flegcl9-o370agNVhcV03Khfidm-lK7gPg05ZiYcpCfyBQ",
|
|
5217
|
+
decisions: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoiMjAyNi0wOSIsInR5cCI6InZuZC5wZXJtZG9jay5ldmlkZW5jZStqd3QifQ.eyJldmVudHMiOltdLCJpYXQiOjE3MDAwMDAwMDAsImp0aSI6ImRlY19maXh0dXJlIiwiaXNzIjoiaHR0cHM6Ly9hcHAuZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2FwcC5leGFtcGxlLmNvbSIsImV4cCI6MjAwMDAwMDAwMH0.oGiFmCEFWUAEBtLO3F_rnGMSKBhb9rIxng4YLwRbBTA3fudrbJMcJD2BhNH99cjcBa1xCmADfdOnTc-Ltq-kAA"
|
|
2425
5218
|
};
|
|
2426
5219
|
//#endregion
|
|
2427
5220
|
//#region src/testing/conformance/credentials.ts
|
|
@@ -2434,7 +5227,7 @@ function testReplayStore(store) {
|
|
|
2434
5227
|
expect(await store.seen("jti-2")).toBe(false);
|
|
2435
5228
|
});
|
|
2436
5229
|
it("forgets a jti whose expiresAt has passed", async () => {
|
|
2437
|
-
await store.remember("ttl-jti",
|
|
5230
|
+
await store.remember("ttl-jti", nowSeconds() - 1);
|
|
2438
5231
|
expect(await store.seen("ttl-jti")).toBe(false);
|
|
2439
5232
|
});
|
|
2440
5233
|
it("claims a key once under concurrency and releases it for a retry", async (context) => {
|
|
@@ -2442,7 +5235,7 @@ function testReplayStore(store) {
|
|
|
2442
5235
|
context.skip();
|
|
2443
5236
|
return;
|
|
2444
5237
|
}
|
|
2445
|
-
const expiresAt =
|
|
5238
|
+
const expiresAt = nowSeconds() + 60;
|
|
2446
5239
|
const results = await Promise.all([store.claim("claim-key", expiresAt), store.claim("claim-key", expiresAt)]);
|
|
2447
5240
|
expect(results.toSorted()).toEqual([false, true]);
|
|
2448
5241
|
expect(await store.seen("claim-key")).toBe(true);
|
|
@@ -2450,6 +5243,43 @@ function testReplayStore(store) {
|
|
|
2450
5243
|
expect(await store.claim("claim-key", expiresAt)).toBe(true);
|
|
2451
5244
|
});
|
|
2452
5245
|
}
|
|
5246
|
+
function testSetQueue(queue) {
|
|
5247
|
+
it("returns pending SETs oldest first, at most max, per stream", async () => {
|
|
5248
|
+
await queue.enqueue("stream-a", "a1", "set-a1");
|
|
5249
|
+
await queue.enqueue("stream-a", "a2", "set-a2");
|
|
5250
|
+
await queue.enqueue("stream-b", "b1", "set-b1");
|
|
5251
|
+
expect(await queue.pending("stream-a", 10)).toEqual([{
|
|
5252
|
+
jti: "a1",
|
|
5253
|
+
set: "set-a1"
|
|
5254
|
+
}, {
|
|
5255
|
+
jti: "a2",
|
|
5256
|
+
set: "set-a2"
|
|
5257
|
+
}]);
|
|
5258
|
+
expect(await queue.pending("stream-a", 1)).toEqual([{
|
|
5259
|
+
jti: "a1",
|
|
5260
|
+
set: "set-a1"
|
|
5261
|
+
}]);
|
|
5262
|
+
expect(await queue.pending("stream-b", 10)).toEqual([{
|
|
5263
|
+
jti: "b1",
|
|
5264
|
+
set: "set-b1"
|
|
5265
|
+
}]);
|
|
5266
|
+
});
|
|
5267
|
+
it("keeps a SET pending until it is acknowledged or reported failed", async () => {
|
|
5268
|
+
await queue.enqueue("stream-c", "c1", "set-c1");
|
|
5269
|
+
await queue.enqueue("stream-c", "c2", "set-c2");
|
|
5270
|
+
await queue.enqueue("stream-c", "c3", "set-c3");
|
|
5271
|
+
await queue.ack("stream-c", "c1");
|
|
5272
|
+
await queue.fail("stream-c", "c2", {
|
|
5273
|
+
err: "invalid_key",
|
|
5274
|
+
description: "unknown kid"
|
|
5275
|
+
});
|
|
5276
|
+
await queue.ack("stream-other", "c3");
|
|
5277
|
+
expect(await queue.pending("stream-c", 10)).toEqual([{
|
|
5278
|
+
jti: "c3",
|
|
5279
|
+
set: "set-c3"
|
|
5280
|
+
}]);
|
|
5281
|
+
});
|
|
5282
|
+
}
|
|
2453
5283
|
function testRevocationFeed(feed) {
|
|
2454
5284
|
it("delivers each event to every subscriber until it unsubscribes", async () => {
|
|
2455
5285
|
const first = [];
|
|
@@ -2636,8 +5466,8 @@ function testCredentialVerifier(verifier, options) {
|
|
|
2636
5466
|
const key = await live();
|
|
2637
5467
|
const before = parseCredential(await verifier.verify(key));
|
|
2638
5468
|
const id = parseApiKey(key)?.id ?? "";
|
|
2639
|
-
await verifier.touch?.(id,
|
|
2640
|
-
await verifier.touch?.("unknown-id",
|
|
5469
|
+
await verifier.touch?.(id, { epochMilliseconds: nowMillis() });
|
|
5470
|
+
await verifier.touch?.("unknown-id", { epochMilliseconds: nowMillis() });
|
|
2641
5471
|
expect(parseCredential(await verifier.verify(key))).toEqual(before);
|
|
2642
5472
|
});
|
|
2643
5473
|
if (options.revoke !== void 0) {
|
|
@@ -2655,7 +5485,7 @@ function testTokenSigner(signer, options) {
|
|
|
2655
5485
|
snapshot: { v: 1 },
|
|
2656
5486
|
sub: "u_1"
|
|
2657
5487
|
}, {
|
|
2658
|
-
typ: "permdock
|
|
5488
|
+
typ: "vnd.permdock.snapshot+jwt",
|
|
2659
5489
|
audience: "https://app.example.com"
|
|
2660
5490
|
});
|
|
2661
5491
|
const header = decodeHeader(token);
|
|
@@ -2664,10 +5494,10 @@ function testTokenSigner(signer, options) {
|
|
|
2664
5494
|
"kid",
|
|
2665
5495
|
"typ"
|
|
2666
5496
|
]);
|
|
2667
|
-
expect(header["typ"]).toBe("permdock
|
|
5497
|
+
expect(header["typ"]).toBe("vnd.permdock.snapshot+jwt");
|
|
2668
5498
|
expect(header["alg"]).not.toBe("none");
|
|
2669
5499
|
const verified = await options.verifier.verify(token, {
|
|
2670
|
-
typ: "permdock
|
|
5500
|
+
typ: "vnd.permdock.snapshot+jwt",
|
|
2671
5501
|
audience: "https://app.example.com"
|
|
2672
5502
|
});
|
|
2673
5503
|
expect(verified.ok).toBe(true);
|
|
@@ -2679,80 +5509,82 @@ function testTokenSigner(signer, options) {
|
|
|
2679
5509
|
});
|
|
2680
5510
|
}
|
|
2681
5511
|
//#endregion
|
|
2682
|
-
//#region src/testing/conformance/
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
await store.consume(input);
|
|
2700
|
-
const exhausted = await store.consume(input);
|
|
2701
|
-
expect(exhausted.remaining).toBeLessThan(0);
|
|
2702
|
-
});
|
|
5512
|
+
//#region src/testing/conformance/providers.ts
|
|
5513
|
+
const OUTCOMES = [
|
|
5514
|
+
"granted",
|
|
5515
|
+
"denied",
|
|
5516
|
+
"approval-required"
|
|
5517
|
+
];
|
|
5518
|
+
const LOCAL = Object.freeze({
|
|
5519
|
+
outcome: "denied",
|
|
5520
|
+
denials: Object.freeze([Object.freeze({
|
|
5521
|
+
role: null,
|
|
5522
|
+
reason: "no-grant"
|
|
5523
|
+
})]),
|
|
5524
|
+
alternatives: Object.freeze([])
|
|
5525
|
+
});
|
|
5526
|
+
function expectDecision(decision) {
|
|
5527
|
+
expect(OUTCOMES).toContain(decision.outcome);
|
|
5528
|
+
if (decision.outcome === "denied") expect(decision.denials.length).toBeGreaterThan(0);
|
|
2703
5529
|
}
|
|
2704
|
-
function
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
operation: "changed",
|
|
2711
|
-
principal: { id: "u_1" },
|
|
2712
|
-
roles: {
|
|
2713
|
-
added: ["admin"],
|
|
2714
|
-
removed: ["member"]
|
|
2715
|
-
}
|
|
2716
|
-
}]))).resolves.toBeUndefined();
|
|
2717
|
-
await expect(Promise.resolve(sink.write([]))).resolves.toBeUndefined();
|
|
2718
|
-
if (sink.flush !== void 0) {
|
|
2719
|
-
await expect(Promise.resolve(sink.flush())).resolves.toBeUndefined();
|
|
2720
|
-
await expect(Promise.resolve(sink.flush())).resolves.toBeUndefined();
|
|
2721
|
-
}
|
|
5530
|
+
function testDecisionProvider(provider, options) {
|
|
5531
|
+
const request = Object.freeze({
|
|
5532
|
+
permission: options.permission,
|
|
5533
|
+
data: options.data,
|
|
5534
|
+
subject: options.subject,
|
|
5535
|
+
local: LOCAL
|
|
2722
5536
|
});
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
expect(snapshot === null || snapshot === void 0).toBe(false);
|
|
2728
|
-
if (typeof snapshot === "object" && snapshot !== null && "v" in snapshot) expect(snapshot.v).toBe(1);
|
|
2729
|
-
if (source.subscribe !== void 0) source.subscribe(() => void 0)();
|
|
5537
|
+
it("names itself and claims the permission synchronously", () => {
|
|
5538
|
+
expect(typeof provider.name).toBe("string");
|
|
5539
|
+
expect(provider.name.length).toBeGreaterThan(0);
|
|
5540
|
+
expect(provider.handles(options.permission)).toBe(true);
|
|
2730
5541
|
});
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
const readCurrent = () => {
|
|
2734
|
-
const document = source.current();
|
|
2735
|
-
expect(document !== null && typeof document === "object" && "then" in document && typeof document.then === "function").toBe(false);
|
|
2736
|
-
return document;
|
|
2737
|
-
};
|
|
2738
|
-
it("returns null or a v1 policy document synchronously", () => {
|
|
2739
|
-
const document = readCurrent();
|
|
2740
|
-
if (document !== null) expect(parsePolicyDocument(document)).toEqual(document);
|
|
5542
|
+
it("answers decide with one of the three outcomes", async () => {
|
|
5543
|
+
expectDecision(await provider.decide(request));
|
|
2741
5544
|
});
|
|
2742
|
-
it("
|
|
2743
|
-
await
|
|
2744
|
-
|
|
2745
|
-
|
|
5545
|
+
it("lists permitted ids as non-empty strings, or null or undefined", async () => {
|
|
5546
|
+
const ids = provider.permitted === void 0 ? void 0 : await provider.permitted({
|
|
5547
|
+
permission: options.permission,
|
|
5548
|
+
subject: options.subject
|
|
5549
|
+
});
|
|
5550
|
+
expect(ids === null || ids === void 0 || ids.every((id) => typeof id === "string" && id.length > 0)).toBe(true);
|
|
2746
5551
|
});
|
|
2747
|
-
|
|
2748
|
-
const
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
5552
|
+
it("denies without throwing when its transport fails", async () => {
|
|
5553
|
+
const unavailable = options.unavailable;
|
|
5554
|
+
const decision = unavailable === void 0 ? void 0 : await unavailable.decide(request).catch(() => void 0);
|
|
5555
|
+
expect(unavailable === void 0 || decision?.outcome === "denied").toBe(true);
|
|
5556
|
+
});
|
|
5557
|
+
}
|
|
5558
|
+
function testSnapshotStorage(storage) {
|
|
5559
|
+
const key = `permdock.conformance.${globalThis.crypto.randomUUID()}`;
|
|
5560
|
+
const other = `${key}.other`;
|
|
5561
|
+
it("reads back what it stores, verbatim", async () => {
|
|
5562
|
+
const value = JSON.stringify({
|
|
5563
|
+
v: 1,
|
|
5564
|
+
note: "ünïcødé \u2028 line"
|
|
2754
5565
|
});
|
|
2755
|
-
|
|
5566
|
+
await storage.setItem(key, value);
|
|
5567
|
+
expect(await storage.getItem(key)).toBe(value);
|
|
5568
|
+
await storage.setItem(key, "replaced");
|
|
5569
|
+
expect(await storage.getItem(key)).toBe("replaced");
|
|
5570
|
+
await storage.removeItem(key);
|
|
5571
|
+
});
|
|
5572
|
+
it("answers null for a key it never stored or removed", async () => {
|
|
5573
|
+
expect(await storage.getItem(other)).toBeNull();
|
|
5574
|
+
await storage.setItem(key, "kept");
|
|
5575
|
+
await storage.removeItem(key);
|
|
5576
|
+
expect(await storage.getItem(key)).toBeNull();
|
|
5577
|
+
await storage.removeItem(other);
|
|
5578
|
+
});
|
|
5579
|
+
it("keeps keys apart", async () => {
|
|
5580
|
+
await storage.setItem(key, "a");
|
|
5581
|
+
await storage.setItem(other, "b");
|
|
5582
|
+
expect(await storage.getItem(key)).toBe("a");
|
|
5583
|
+
expect(await storage.getItem(other)).toBe("b");
|
|
5584
|
+
await storage.removeItem(key);
|
|
5585
|
+
expect(await storage.getItem(other)).toBe("b");
|
|
5586
|
+
await storage.removeItem(other);
|
|
5587
|
+
});
|
|
2756
5588
|
}
|
|
2757
5589
|
//#endregion
|
|
2758
5590
|
//#region src/testing/conformance/relations.ts
|
|
@@ -2878,6 +5710,302 @@ function testRelationSource(source, options) {
|
|
|
2878
5710
|
});
|
|
2879
5711
|
}
|
|
2880
5712
|
//#endregion
|
|
5713
|
+
//#region src/testing/conformance/principals.ts
|
|
5714
|
+
function isFrozenDeep(value) {
|
|
5715
|
+
if (value === null || typeof value !== "object") return true;
|
|
5716
|
+
return Object.isFrozen(value) && Object.values(value).every((item) => isFrozenDeep(item));
|
|
5717
|
+
}
|
|
5718
|
+
/**
|
|
5719
|
+
* Checks a `PrincipalSource`: `principal` answers each listed id with the
|
|
5720
|
+
* expected principal or `null`, never another id; `principalByIdentity`
|
|
5721
|
+
* resolves each listed identity to the expected principal id or `null`, and
|
|
5722
|
+
* an email matches trimmed and in any case.
|
|
5723
|
+
*/
|
|
5724
|
+
function testPrincipalSource(source, options) {
|
|
5725
|
+
it("returns the principal with the asked id, or null", async () => {
|
|
5726
|
+
for (const entry of options.principals) {
|
|
5727
|
+
let found;
|
|
5728
|
+
try {
|
|
5729
|
+
found = await source.principal(entry.id, entry.tenant === void 0 ? {} : { tenant: entry.tenant });
|
|
5730
|
+
} catch {
|
|
5731
|
+
found = null;
|
|
5732
|
+
}
|
|
5733
|
+
expect(found === null || typeof found === "object").toBe(true);
|
|
5734
|
+
if (found !== null) expect(await loadPrincipal(source, entry.id, entry.tenant)).toEqual(found);
|
|
5735
|
+
if (entry.expect !== void 0) expect(found).toEqual(entry.expect);
|
|
5736
|
+
}
|
|
5737
|
+
});
|
|
5738
|
+
it("returns principals a caller cannot change", async () => {
|
|
5739
|
+
for (const entry of options.principals) {
|
|
5740
|
+
const found = await loadPrincipal(source, entry.id, entry.tenant);
|
|
5741
|
+
if (found !== null) expect(isFrozenDeep(found)).toBe(true);
|
|
5742
|
+
}
|
|
5743
|
+
});
|
|
5744
|
+
it("answers an empty or unknown id with null", async () => {
|
|
5745
|
+
expect(await loadPrincipal(source, "")).toBeNull();
|
|
5746
|
+
expect(await loadPrincipal(source, "permdock-conformance-unknown-principal")).toBeNull();
|
|
5747
|
+
});
|
|
5748
|
+
if (options.identities !== void 0 && options.identities.length > 0) {
|
|
5749
|
+
const identities = options.identities;
|
|
5750
|
+
it("resolves linked identities and nothing else", async () => {
|
|
5751
|
+
for (const entry of identities) {
|
|
5752
|
+
const found = await loadPrincipalByIdentity(source, entry.identity);
|
|
5753
|
+
expect(found?.id ?? null).toBe(entry.expect);
|
|
5754
|
+
}
|
|
5755
|
+
});
|
|
5756
|
+
it("matches an email trimmed and in any case", async () => {
|
|
5757
|
+
for (const entry of identities) {
|
|
5758
|
+
if (entry.identity.kind !== "email" || entry.expect === null) continue;
|
|
5759
|
+
const shouted = await loadPrincipalByIdentity(source, {
|
|
5760
|
+
kind: "email",
|
|
5761
|
+
value: ` ${entry.identity.value.toUpperCase()} `
|
|
5762
|
+
});
|
|
5763
|
+
expect(shouted?.id ?? null).toBe(entry.expect);
|
|
5764
|
+
}
|
|
5765
|
+
});
|
|
5766
|
+
}
|
|
5767
|
+
}
|
|
5768
|
+
//#endregion
|
|
5769
|
+
//#region src/testing/conformance/automations.ts
|
|
5770
|
+
async function automationOf(source, id, tenant) {
|
|
5771
|
+
try {
|
|
5772
|
+
return await source.automation(id, tenant === void 0 ? {} : { tenant });
|
|
5773
|
+
} catch {
|
|
5774
|
+
return null;
|
|
5775
|
+
}
|
|
5776
|
+
}
|
|
5777
|
+
/**
|
|
5778
|
+
* Checks an `AutomationSource`: `automation(id)` answers each listed id
|
|
5779
|
+
* with a valid Automation v1 record of that id or `null`, never another id;
|
|
5780
|
+
* a record held to one tenant is `null` when asked for another; an empty or
|
|
5781
|
+
* unknown id is `null`.
|
|
5782
|
+
*/
|
|
5783
|
+
function testAutomationSource(source, options) {
|
|
5784
|
+
it("returns a valid v1 record with the asked id, or null", async () => {
|
|
5785
|
+
for (const entry of options.automations) {
|
|
5786
|
+
const found = await automationOf(source, entry.id, entry.tenant);
|
|
5787
|
+
if (found !== null) {
|
|
5788
|
+
const parsed = parseAutomation(found);
|
|
5789
|
+
expect(parsed).toEqual(found);
|
|
5790
|
+
expect(parsed?.id).toBe(entry.id);
|
|
5791
|
+
}
|
|
5792
|
+
if (entry.expect !== void 0) expect(found).toEqual(entry.expect);
|
|
5793
|
+
}
|
|
5794
|
+
});
|
|
5795
|
+
it("refuses a record held to another tenant", async () => {
|
|
5796
|
+
for (const entry of options.automations) {
|
|
5797
|
+
const found = parseAutomation(await automationOf(source, entry.id));
|
|
5798
|
+
if (found?.tenant === void 0) continue;
|
|
5799
|
+
expect(await automationOf(source, entry.id, `${found.tenant}-permdock-conformance-other`)).toBeNull();
|
|
5800
|
+
}
|
|
5801
|
+
});
|
|
5802
|
+
it("answers an empty or unknown id with null", async () => {
|
|
5803
|
+
expect(await automationOf(source, "")).toBeNull();
|
|
5804
|
+
expect(await automationOf(source, "permdock-conformance-unknown-automation")).toBeNull();
|
|
5805
|
+
});
|
|
5806
|
+
}
|
|
5807
|
+
async function sessionOf(source, id) {
|
|
5808
|
+
try {
|
|
5809
|
+
return await source.session(id);
|
|
5810
|
+
} catch {
|
|
5811
|
+
return null;
|
|
5812
|
+
}
|
|
5813
|
+
}
|
|
5814
|
+
/**
|
|
5815
|
+
* Checks a `SupportSessionSource`: `session(id)` answers each listed id
|
|
5816
|
+
* with a record of that id or `null`, never another id; `ended` is a
|
|
5817
|
+
* boolean when present; an empty or unknown id is `null`.
|
|
5818
|
+
*/
|
|
5819
|
+
function testSupportSessionSource(source, options) {
|
|
5820
|
+
it("returns the session with the asked id, or null", async () => {
|
|
5821
|
+
for (const entry of options.sessions) {
|
|
5822
|
+
const found = await sessionOf(source, entry.id);
|
|
5823
|
+
expect(found === null || typeof found === "object").toBe(true);
|
|
5824
|
+
if (found !== null && typeof found === "object") {
|
|
5825
|
+
expect(Reflect.get(found, "id")).toBe(entry.id);
|
|
5826
|
+
const ended = Reflect.get(found, "ended");
|
|
5827
|
+
expect(ended === void 0 || typeof ended === "boolean").toBe(true);
|
|
5828
|
+
}
|
|
5829
|
+
if (entry.ended === null) expect(found).toBeNull();
|
|
5830
|
+
else if (entry.ended !== void 0) {
|
|
5831
|
+
expect(found).not.toBeNull();
|
|
5832
|
+
expect(found !== null && typeof found === "object" && Reflect.get(found, "ended") === true).toBe(entry.ended);
|
|
5833
|
+
}
|
|
5834
|
+
}
|
|
5835
|
+
});
|
|
5836
|
+
it("answers an empty or unknown id with null", async () => {
|
|
5837
|
+
expect(await sessionOf(source, "")).toBeNull();
|
|
5838
|
+
expect(await sessionOf(source, "permdock-conformance-unknown-session")).toBeNull();
|
|
5839
|
+
});
|
|
5840
|
+
}
|
|
5841
|
+
const SETTLED = Symbol("settled");
|
|
5842
|
+
/** Whether `promise` settles within `ms` milliseconds; a rejection counts as settled. */
|
|
5843
|
+
async function settlesWithin(promise, ms) {
|
|
5844
|
+
let timer;
|
|
5845
|
+
const timeout = new Promise((resolve) => {
|
|
5846
|
+
timer = setTimeout(() => {
|
|
5847
|
+
resolve(false);
|
|
5848
|
+
}, ms);
|
|
5849
|
+
});
|
|
5850
|
+
try {
|
|
5851
|
+
return await Promise.race([promise.then(() => SETTLED, () => SETTLED), timeout]).then((result) => result === SETTLED);
|
|
5852
|
+
} finally {
|
|
5853
|
+
clearTimeout(timer);
|
|
5854
|
+
}
|
|
5855
|
+
}
|
|
5856
|
+
/**
|
|
5857
|
+
* Checks an `ApprovalWaiter`: a wait ends when its token is woken and not
|
|
5858
|
+
* when another token is; waking a token nobody waits on resolves; a wait
|
|
5859
|
+
* whose `expiresAt` has passed ends; an aborted `signal` rejects the wait with its reason.
|
|
5860
|
+
* Pass `expiry: false` or `abort: false` for a waiter whose runner offers no
|
|
5861
|
+
* timer or no cancellation, `pending` when the waiter reads a store, and
|
|
5862
|
+
* `parked` when a wait parks asynchronously.
|
|
5863
|
+
*/
|
|
5864
|
+
function testApprovalWaiter(waiter, options = {}) {
|
|
5865
|
+
const timeout = options.timeout ?? 1e3;
|
|
5866
|
+
const pending = async (label) => {
|
|
5867
|
+
return options.pending === void 0 ? `permdock-conformance-${label}` : await options.pending(label);
|
|
5868
|
+
};
|
|
5869
|
+
const parked = options.parked ?? (() => new Promise((resolve) => {
|
|
5870
|
+
setTimeout(resolve, 0);
|
|
5871
|
+
}));
|
|
5872
|
+
it("ends a wait when its token is woken, and only then", async () => {
|
|
5873
|
+
const woken = await pending("woken");
|
|
5874
|
+
const still = await pending("other");
|
|
5875
|
+
const waiting = waiter.wait(woken);
|
|
5876
|
+
const other = waiter.wait(still);
|
|
5877
|
+
await parked(woken);
|
|
5878
|
+
await parked(still);
|
|
5879
|
+
await waiter.wake(woken);
|
|
5880
|
+
expect(await settlesWithin(waiting, timeout)).toBe(true);
|
|
5881
|
+
await expect(waiting).resolves.toBeUndefined();
|
|
5882
|
+
expect(await settlesWithin(other, 20)).toBe(false);
|
|
5883
|
+
await waiter.wake(still);
|
|
5884
|
+
await expect(other).resolves.toBeUndefined();
|
|
5885
|
+
});
|
|
5886
|
+
it("resolves a wake for a token nobody waits on", async () => {
|
|
5887
|
+
await expect(Promise.resolve(waiter.wake("permdock-conformance-nobody"))).resolves.toBeUndefined();
|
|
5888
|
+
});
|
|
5889
|
+
if (options.expiry !== false) it("ends a wait whose expiresAt has passed", async () => {
|
|
5890
|
+
const waiting = waiter.wait(await pending("expired"), { expiresAt: { epochMilliseconds: nowMillis() - 1e3 } });
|
|
5891
|
+
expect(await settlesWithin(waiting, timeout)).toBe(true);
|
|
5892
|
+
await expect(waiting).resolves.toBeUndefined();
|
|
5893
|
+
});
|
|
5894
|
+
if (options.abort !== false) it("rejects a wait when its signal aborts", async () => {
|
|
5895
|
+
const token = await pending("aborted");
|
|
5896
|
+
const controller = new AbortController();
|
|
5897
|
+
const waiting = waiter.wait(token, { signal: controller.signal });
|
|
5898
|
+
await parked(token);
|
|
5899
|
+
const reason = /* @__PURE__ */ new Error("permdock-conformance-abort");
|
|
5900
|
+
controller.abort(reason);
|
|
5901
|
+
await expect(waiting).rejects.toBe(reason);
|
|
5902
|
+
});
|
|
5903
|
+
}
|
|
5904
|
+
//#endregion
|
|
5905
|
+
//#region src/testing/delivery-transport.ts
|
|
5906
|
+
const permissions = definePermissions({ notice: resource({ actions: ["read"] }) });
|
|
5907
|
+
const policy = definePolicy(permissions, {
|
|
5908
|
+
grants: [allow(permissions.notice.read, { to: authenticated() })],
|
|
5909
|
+
subject: (user) => user
|
|
5910
|
+
});
|
|
5911
|
+
async function grantedDecision() {
|
|
5912
|
+
const decision = (await getPermDock(policy, { id: "u_runner" })).decide(permissions.notice.read, { id: "n_runner" }, { trusted: true });
|
|
5913
|
+
if (decision.outcome !== "granted") throw new Error("testDeliveryTransport: the runner policy did not grant");
|
|
5914
|
+
return decision;
|
|
5915
|
+
}
|
|
5916
|
+
function key(suffix) {
|
|
5917
|
+
return {
|
|
5918
|
+
idempotencyKey: `pdd_runner_${suffix}`,
|
|
5919
|
+
delivery: "dlv_runner"
|
|
5920
|
+
};
|
|
5921
|
+
}
|
|
5922
|
+
/**
|
|
5923
|
+
* Conformance for a `DeliveryTransport`: it sends a checked target with its
|
|
5924
|
+
* idempotency key, keeps the key on a retry, refuses a target without a
|
|
5925
|
+
* granted decision before calling the provider, and maps provider errors to
|
|
5926
|
+
* retryable or final failures without rejecting.
|
|
5927
|
+
*/
|
|
5928
|
+
function testDeliveryTransport(name, options) {
|
|
5929
|
+
const targetWith = async (decision) => ({
|
|
5930
|
+
channel: options.channel,
|
|
5931
|
+
address: options.address,
|
|
5932
|
+
principal: "u_runner",
|
|
5933
|
+
decision
|
|
5934
|
+
});
|
|
5935
|
+
const send = async (harness, target, suffix) => harness.transport.deliver(target, options.message, key(suffix));
|
|
5936
|
+
describe(`${name} (delivery transport)`, () => {
|
|
5937
|
+
it("names its channel and provider", () => {
|
|
5938
|
+
const { transport } = options.create();
|
|
5939
|
+
expect(transport.channel).toBe(options.channel);
|
|
5940
|
+
expect(typeof transport.name).toBe("string");
|
|
5941
|
+
expect(transport.name).not.toBe("");
|
|
5942
|
+
});
|
|
5943
|
+
it("sends a checked target with its idempotency key", async () => {
|
|
5944
|
+
const harness = options.create();
|
|
5945
|
+
const result = await send(harness, await targetWith(await grantedDecision()), "a");
|
|
5946
|
+
expect(result.status).toBe("sent");
|
|
5947
|
+
expect(await harness.received()).toEqual([{ idempotencyKey: "pdd_runner_a" }]);
|
|
5948
|
+
});
|
|
5949
|
+
it("passes the same key on a retry of the same delivery", async () => {
|
|
5950
|
+
const harness = options.create();
|
|
5951
|
+
const target = await targetWith(await grantedDecision());
|
|
5952
|
+
await send(harness, target, "b");
|
|
5953
|
+
await send(harness, target, "b");
|
|
5954
|
+
const received = await harness.received();
|
|
5955
|
+
expect(received.length).toBeGreaterThan(0);
|
|
5956
|
+
expect(received.every((entry) => entry.idempotencyKey === "pdd_runner_b")).toBe(true);
|
|
5957
|
+
});
|
|
5958
|
+
it("refuses a target without a granted decision and never calls the provider", async () => {
|
|
5959
|
+
const harness = options.create();
|
|
5960
|
+
for (const decision of [
|
|
5961
|
+
void 0,
|
|
5962
|
+
{
|
|
5963
|
+
outcome: "denied",
|
|
5964
|
+
denials: [],
|
|
5965
|
+
alternatives: []
|
|
5966
|
+
},
|
|
5967
|
+
{ outcome: "approval-required" }
|
|
5968
|
+
]) expect(await send(harness, await targetWith(decision), "c")).toMatchObject({
|
|
5969
|
+
status: "failed",
|
|
5970
|
+
cause: "invalid-recipient",
|
|
5971
|
+
retryable: false
|
|
5972
|
+
});
|
|
5973
|
+
expect(await harness.received()).toEqual([]);
|
|
5974
|
+
});
|
|
5975
|
+
it("maps a retryable provider error to a retryable failure", async () => {
|
|
5976
|
+
const harness = options.create();
|
|
5977
|
+
harness.fail("retryable");
|
|
5978
|
+
expect(await send(harness, await targetWith(await grantedDecision()), "d")).toMatchObject({
|
|
5979
|
+
status: "failed",
|
|
5980
|
+
retryable: true
|
|
5981
|
+
});
|
|
5982
|
+
});
|
|
5983
|
+
it("maps a final provider error to a final failure", async () => {
|
|
5984
|
+
const harness = options.create();
|
|
5985
|
+
harness.fail("final");
|
|
5986
|
+
expect(await send(harness, await targetWith(await grantedDecision()), "e")).toMatchObject({
|
|
5987
|
+
status: "failed",
|
|
5988
|
+
retryable: false
|
|
5989
|
+
});
|
|
5990
|
+
});
|
|
5991
|
+
it("resolves a provider throw as a retryable failure or rejects for the router to catch", async () => {
|
|
5992
|
+
const harness = options.create();
|
|
5993
|
+
harness.fail("throw");
|
|
5994
|
+
let result;
|
|
5995
|
+
try {
|
|
5996
|
+
result = await send(harness, await targetWith(await grantedDecision()), "f");
|
|
5997
|
+
} catch {
|
|
5998
|
+
result = void 0;
|
|
5999
|
+
}
|
|
6000
|
+
if (result !== void 0) expect(result).toMatchObject({
|
|
6001
|
+
status: "failed",
|
|
6002
|
+
retryable: true
|
|
6003
|
+
});
|
|
6004
|
+
expect(await harness.received()).toEqual([]);
|
|
6005
|
+
});
|
|
6006
|
+
});
|
|
6007
|
+
}
|
|
6008
|
+
//#endregion
|
|
2881
6009
|
//#region src/testing/conformance/identity.ts
|
|
2882
6010
|
function testSubjectResolver(resolver, options) {
|
|
2883
6011
|
it("never throws and fails closed to anonymous", async () => {
|
|
@@ -2948,7 +6076,7 @@ function testEntitlementSource(source, options) {
|
|
|
2948
6076
|
expect(await source.entitlementsFor(options.principal, {})).toEqual([]);
|
|
2949
6077
|
});
|
|
2950
6078
|
}
|
|
2951
|
-
function
|
|
6079
|
+
function testCustomRoleSource(source, options) {
|
|
2952
6080
|
it("only resolves declared role names", async () => {
|
|
2953
6081
|
const declared = options.declared.map((item) => typeof item === "string" ? item : item.key);
|
|
2954
6082
|
const roles = await source.rolesFor(options.tenant);
|
|
@@ -3046,7 +6174,7 @@ function testDirectoryStore(store, options) {
|
|
|
3046
6174
|
const kinds = [];
|
|
3047
6175
|
const token = "conformance-scim-token";
|
|
3048
6176
|
const handle = scimHandler({
|
|
3049
|
-
store,
|
|
6177
|
+
directory: store,
|
|
3050
6178
|
tenant: home,
|
|
3051
6179
|
token: {
|
|
3052
6180
|
hash: "sha256",
|
|
@@ -3100,7 +6228,7 @@ function testSettingsSource(source, options) {
|
|
|
3100
6228
|
const credentials = settings.credentials;
|
|
3101
6229
|
if (credentials !== void 0) {
|
|
3102
6230
|
expect(credentials.maxTtl === void 0 || Number.isFinite(credentials.maxTtl) && credentials.maxTtl >= 0).toBe(true);
|
|
3103
|
-
expect(credentials.kinds === void 0 || credentials.kinds.every((kind) => kind === "user" || kind === "service")).toBe(true);
|
|
6231
|
+
expect(credentials.kinds === void 0 || credentials.kinds.every((kind) => kind === "user" || kind === "service" || kind === "feed")).toBe(true);
|
|
3104
6232
|
}
|
|
3105
6233
|
expect(JSON.parse(JSON.stringify(settings))).toEqual(settings);
|
|
3106
6234
|
}
|
|
@@ -3137,6 +6265,17 @@ const ownerMembership = {
|
|
|
3137
6265
|
managedBy: "idp",
|
|
3138
6266
|
entitlements: ["seat:pro"]
|
|
3139
6267
|
};
|
|
6268
|
+
const ownerMembershipClaim = {
|
|
6269
|
+
scope: ownerMembership.scope,
|
|
6270
|
+
id: ownerMembership.id,
|
|
6271
|
+
roles: ownerMembership.roles,
|
|
6272
|
+
via: ownerMembership.via,
|
|
6273
|
+
expires_at: ownerMembership.expiresAt,
|
|
6274
|
+
granted_by: ownerMembership.grantedBy,
|
|
6275
|
+
reason: ownerMembership.reason,
|
|
6276
|
+
managed_by: ownerMembership.managedBy,
|
|
6277
|
+
entitlements: ownerMembership.entitlements
|
|
6278
|
+
};
|
|
3140
6279
|
const projectMembership = {
|
|
3141
6280
|
scope: "project",
|
|
3142
6281
|
id: project,
|
|
@@ -3290,7 +6429,7 @@ const supabaseClaimFixtures = {
|
|
|
3290
6429
|
},
|
|
3291
6430
|
authz_ver: 3
|
|
3292
6431
|
},
|
|
3293
|
-
options: { plans: "features" },
|
|
6432
|
+
options: { claims: { plans: "features" } },
|
|
3294
6433
|
expect: {
|
|
3295
6434
|
id,
|
|
3296
6435
|
roles: [],
|
|
@@ -3315,7 +6454,7 @@ const supabaseClaimFixtures = {
|
|
|
3315
6454
|
tenant_id: org,
|
|
3316
6455
|
memberships: [
|
|
3317
6456
|
{
|
|
3318
|
-
...
|
|
6457
|
+
...ownerMembershipClaim,
|
|
3319
6458
|
grants: { analyst: ["post.read", "-post.delete"] }
|
|
3320
6459
|
},
|
|
3321
6460
|
projectMembership,
|
|
@@ -3683,4 +6822,139 @@ const supabaseHookManifestFixture = {
|
|
|
3683
6822
|
}
|
|
3684
6823
|
};
|
|
3685
6824
|
//#endregion
|
|
3686
|
-
|
|
6825
|
+
//#region src/testing/problem-examples.ts
|
|
6826
|
+
/**
|
|
6827
|
+
* One RFC 9457 body per PermDock problem type, exactly as the HTTP adapters
|
|
6828
|
+
* write it for a `post.delete` request by subject `u_123`. A client tests its
|
|
6829
|
+
* error handling against these; `tests/standards/problem-details.test.ts`
|
|
6830
|
+
* rebuilds each from the code that emits it, and the docs show the same JSON.
|
|
6831
|
+
*/
|
|
6832
|
+
const PROBLEM_EXAMPLES = Object.freeze({
|
|
6833
|
+
denied: {
|
|
6834
|
+
type: "https://permdock.com/problems/denied",
|
|
6835
|
+
title: "Permission denied",
|
|
6836
|
+
status: 403,
|
|
6837
|
+
detail: "post.delete denied for subject u_123: member (condition). Alternatives: post.read, post.update.",
|
|
6838
|
+
instance: "/posts/p_42",
|
|
6839
|
+
permission: "post.delete",
|
|
6840
|
+
scope: "post:delete",
|
|
6841
|
+
resource: { type: "post" },
|
|
6842
|
+
denials: [{
|
|
6843
|
+
role: "member",
|
|
6844
|
+
reason: "condition"
|
|
6845
|
+
}],
|
|
6846
|
+
alternatives: ["post.read", "post.update"]
|
|
6847
|
+
},
|
|
6848
|
+
"approval-required": {
|
|
6849
|
+
type: "https://permdock.com/problems/approval-required",
|
|
6850
|
+
title: "Approval required",
|
|
6851
|
+
status: 403,
|
|
6852
|
+
detail: "post.delete requires human approval (human). Token: pd1.eyJhbGciOiJFZERTQSJ9.",
|
|
6853
|
+
instance: "/posts/p_42",
|
|
6854
|
+
permission: "post.delete",
|
|
6855
|
+
scope: "post:delete",
|
|
6856
|
+
resource: { type: "post" },
|
|
6857
|
+
reason: "human",
|
|
6858
|
+
token: "pd1.eyJhbGciOiJFZERTQSJ9",
|
|
6859
|
+
approval: { at: "https://app.example/approvals" }
|
|
6860
|
+
},
|
|
6861
|
+
validation: {
|
|
6862
|
+
type: "https://permdock.com/problems/validation",
|
|
6863
|
+
title: "Invalid resource data",
|
|
6864
|
+
status: 400,
|
|
6865
|
+
detail: "invalid post",
|
|
6866
|
+
permission: "post.delete",
|
|
6867
|
+
issues: [{
|
|
6868
|
+
message: "title is required",
|
|
6869
|
+
path: ["title"]
|
|
6870
|
+
}]
|
|
6871
|
+
},
|
|
6872
|
+
"step-up-required": {
|
|
6873
|
+
type: "https://permdock.com/problems/step-up-required",
|
|
6874
|
+
title: "Step-up required",
|
|
6875
|
+
status: 401,
|
|
6876
|
+
detail: "post.delete denied for subject u_123: member (insufficient-user-authentication). Alternatives: post.read, post.update.",
|
|
6877
|
+
permission: "post.delete",
|
|
6878
|
+
scope: "post:delete",
|
|
6879
|
+
resource: { type: "post" },
|
|
6880
|
+
denials: [{
|
|
6881
|
+
role: "member",
|
|
6882
|
+
reason: "insufficient-user-authentication",
|
|
6883
|
+
to: {
|
|
6884
|
+
kind: "assurance",
|
|
6885
|
+
acr: ["urn:mfa"],
|
|
6886
|
+
maxAge: 600
|
|
6887
|
+
}
|
|
6888
|
+
}],
|
|
6889
|
+
alternatives: ["post.read", "post.update"],
|
|
6890
|
+
acrValues: ["urn:mfa"],
|
|
6891
|
+
maxAge: 600
|
|
6892
|
+
},
|
|
6893
|
+
"not-entitled": {
|
|
6894
|
+
type: "https://permdock.com/problems/not-entitled",
|
|
6895
|
+
title: "Plan upgrade required",
|
|
6896
|
+
status: 403,
|
|
6897
|
+
detail: "post.delete denied for subject u_123: admin (not-entitled). Alternatives: post.read, post.update.",
|
|
6898
|
+
permission: "post.delete",
|
|
6899
|
+
scope: "post:delete",
|
|
6900
|
+
resource: { type: "post" },
|
|
6901
|
+
denials: [{
|
|
6902
|
+
role: "admin",
|
|
6903
|
+
reason: "not-entitled",
|
|
6904
|
+
to: {
|
|
6905
|
+
kind: "plan",
|
|
6906
|
+
plan: "pro"
|
|
6907
|
+
}
|
|
6908
|
+
}],
|
|
6909
|
+
alternatives: ["post.read", "post.update"],
|
|
6910
|
+
plans: ["pro"]
|
|
6911
|
+
},
|
|
6912
|
+
"rate-limited": {
|
|
6913
|
+
type: "https://permdock.com/problems/rate-limited",
|
|
6914
|
+
title: "Rate limit exceeded",
|
|
6915
|
+
status: 429,
|
|
6916
|
+
detail: "post.delete denied for subject u_123: member (limit). Alternatives: post.read, post.update.",
|
|
6917
|
+
permission: "post.delete",
|
|
6918
|
+
scope: "post:delete",
|
|
6919
|
+
resource: { type: "post" },
|
|
6920
|
+
denials: [{
|
|
6921
|
+
role: "member",
|
|
6922
|
+
reason: "limit",
|
|
6923
|
+
detail: {
|
|
6924
|
+
count: 10,
|
|
6925
|
+
per: 60,
|
|
6926
|
+
resetsAt: "2030-03-17T17:46:40.000Z"
|
|
6927
|
+
}
|
|
6928
|
+
}],
|
|
6929
|
+
alternatives: ["post.read", "post.update"]
|
|
6930
|
+
},
|
|
6931
|
+
"limit-unavailable": {
|
|
6932
|
+
type: "https://permdock.com/problems/limit-unavailable",
|
|
6933
|
+
title: "Rate limit unavailable",
|
|
6934
|
+
status: 503,
|
|
6935
|
+
detail: "post.delete denied for subject u_123: member (limit-unavailable). Alternatives: post.read, post.update.",
|
|
6936
|
+
permission: "post.delete",
|
|
6937
|
+
scope: "post:delete",
|
|
6938
|
+
resource: { type: "post" },
|
|
6939
|
+
denials: [{
|
|
6940
|
+
role: "member",
|
|
6941
|
+
reason: "limit-unavailable"
|
|
6942
|
+
}],
|
|
6943
|
+
alternatives: ["post.read", "post.update"]
|
|
6944
|
+
},
|
|
6945
|
+
"invalid-signature": {
|
|
6946
|
+
type: "https://permdock.com/problems/invalid-signature",
|
|
6947
|
+
title: "Invalid signature",
|
|
6948
|
+
status: 403,
|
|
6949
|
+
detail: "HTTP Message Signature did not verify"
|
|
6950
|
+
},
|
|
6951
|
+
"approver-not-eligible": {
|
|
6952
|
+
type: "https://permdock.com/problems/approver-not-eligible",
|
|
6953
|
+
title: "Approver not eligible",
|
|
6954
|
+
status: 403,
|
|
6955
|
+
detail: "approver is the principal of this request",
|
|
6956
|
+
reason: "approver-is-principal"
|
|
6957
|
+
}
|
|
6958
|
+
});
|
|
6959
|
+
//#endregion
|
|
6960
|
+
export { AGENT_THROWING_ROW, AGENT_THROWING_USER, PROBLEM_EXAMPLES, authzenTodoData, authzenTodoPermissions, authzenTodoPolicy, authzenTodoUsers, authzenTodoVectors, describePolicy, expectTypeOf, jwtFixtureAudience, jwtFixtureIssuer, jwtFixtureJwks, jwtFixtureTokens, snapshotFixture, supabaseClaimFixtures, supabaseClaimVectors, supabaseHookManifestFixture, supabaseMembershipsBudget, testAccessReviewStore, testAgentAdapter, testAgentRuntime, testApprovalDelegationSource, testApprovalPolicySource, testApprovalStore, testApprovalWaiter, testAuthzen, testAutomationSource, testClientParity, testClientStore, testCloudApi, testCredentialVerifier, testCustomRoleSource, testDecisionProvider, testDecisionSink, testDeliveryTransport, testDirectoryStore, testEntitlementSource, testHttpAdapter, testIdempotencyStore, testLegacyParity, testLimitStore, testMembershipSource, testOrmParity, testPolicySource, testPrincipalSource, testRelationSource, testReplayStore, testRevocationFeed, testRlsParity, testSetQueue, testSettingsSource, testSnapshotSource, testSnapshotStorage, testSubjectResolver, testSupportSessionSource, testTokenSigner, testTokenVerifier, testWhereCompiler };
|