Remove pre-release HTTP compatibility surface

This commit is contained in:
ms2k 2026-07-19 17:49:03 +09:00
commit f28810ea33
8 changed files with 104 additions and 329 deletions

View file

@ -32,8 +32,7 @@ framework route and decoding failures retain Servant's response format.
| Surface | Contract |
| --- | --- |
| Runtime | `GET /healthz` reports process liveness. `GET /readyz` reports declared traffic safety. |
| Browser | Public `GET /nuthatch.js`, `GET /login`, and `GET /oidc/callback`; authenticated `GET /app` and `/assets/*`; and CSRF-protected authenticated `POST /logout` implement the browser session boundary. |
| Legacy product | Authenticated `GET /`, `GET /download`, `POST /upload`, `POST /delete`, and `POST /delete-prefix` remain supported. |
| Browser | Public `GET /login` and `GET /oidc/callback`; authenticated `GET /app` and `/assets/*`; and CSRF-protected authenticated `POST /logout` implement the browser session boundary. |
| Programmatic product | Authenticated `/api/session`, `/api/browse`, `/api/download`, `/api/upload`, `/api/delete`, `/api/delete-plan`, and `/api/delete-batch` expose the JSON product surface. |
Product paths are valid UTF-8, NFC-normalized, relative, and canonical. The

View file

@ -113,7 +113,6 @@ library
Nuthatch.Session
Nuthatch.Streaming
Nuthatch.Types
Nuthatch.Ui
executable nuthatch
import: shared

View file

@ -2,7 +2,6 @@ module Nuthatch.Api
( Api
, HealthEndpoint
, ReadyEndpoint
, BrowserScriptEndpoint
, AppEndpoint
, StaticAssetsEndpoint
, ProductApi
@ -12,17 +11,10 @@ module Nuthatch.Api
, LoginSuccess
, LogoutEndpoint
, LogoutSuccess
, BrowseEndpoint
, DownloadEndpoint
, DownloadSuccess
, UploadEndpoint
, DeleteEndpoint
, DeletePrefixEndpoint
, api
, HTML
, JavaScript
, HtmlPage (..)
, BrowserScript (..)
, SessionContext (..)
, SessionAuth
, BrowsePrefix (..)
@ -32,10 +24,7 @@ module Nuthatch.Api
, HealthResponse (..)
, ReadyResponse (..)
, BrowseResponse (..)
, BrowseView (..)
, DeleteRequest (..)
, DeletePrefixRequest (..)
, DeletePrefixResponse (..)
, SessionResponse (..)
, DeletePlanResponse (..)
, DeleteBatchItem (..)
@ -81,26 +70,15 @@ import Servant.Server (HasServer (..))
import Servant.Server.Experimental.Auth (AuthServerData)
data HTML
data JavaScript
instance Accept HTML where
contentType _ = "text" // "html" /: ("charset", "utf-8")
instance Accept JavaScript where
contentType _ = "text" // "javascript" /: ("charset", "utf-8")
newtype HtmlPage = HtmlPage { unHtmlPage :: Text }
deriving stock (Eq, Show)
instance MimeRender HTML HtmlPage where
mimeRender _ = BL.fromStrict . TE.encodeUtf8 . unHtmlPage
newtype BrowserScript = BrowserScript { unBrowserScript :: Text }
deriving stock (Eq, Show)
instance MimeRender JavaScript BrowserScript where
mimeRender _ = BL.fromStrict . TE.encodeUtf8 . unBrowserScript
data SessionContext = SessionContext
{ sessionUser :: SessionUser
, sessionCsrf :: Maybe CsrfToken
@ -152,18 +130,6 @@ instance ToJSON BrowseResponse where
, "partial" .= browsePartial response
]
data BrowseView = BrowseView
{ browseViewHtml :: HtmlPage
, browseViewJson :: BrowseResponse
}
deriving stock (Eq, Show)
instance MimeRender HTML BrowseView where
mimeRender proxy = mimeRender proxy . browseViewHtml
instance ToJSON BrowseView where
toJSON = toJSON . browseViewJson
data DeleteRequest = DeleteRequest
{ deleteRoot :: Root
, deletePath :: RelativePath
@ -180,28 +146,6 @@ instance FromJSON DeleteRequest where
<*> o .: "operation_id"
<*> o .: "csrf"
data DeletePrefixRequest = DeletePrefixRequest
{ deletePrefixRoot :: Root
, deletePrefixPath :: RelativePath
, deletePrefixCsrf :: CsrfToken
}
deriving stock (Eq)
instance FromJSON DeletePrefixRequest where
parseJSON = withObject "DeletePrefixRequest" $ \o ->
DeletePrefixRequest
<$> o .: "root"
<*> o .: "path"
<*> o .: "csrf"
newtype DeletePrefixResponse = DeletePrefixResponse
{ deletedObjectCount :: Int
}
deriving stock (Eq, Show)
instance ToJSON DeletePrefixResponse where
toJSON response = object ["deleted" .= deletedObjectCount response]
data UploadResponse = UploadResponse
{ uploadResponseOperationId :: OperationId
, uploadResponseResult :: MutationResult
@ -462,9 +406,6 @@ type HealthEndpoint =
type ReadyEndpoint =
"readyz" :> Throws ReadyError :> Get '[JSON] ReadyResponse
type BrowserScriptEndpoint =
"nuthatch.js" :> Get '[JavaScript] BrowserScript
type AppEndpoint =
"app"
:> AuthProtect SessionAuth
@ -524,16 +465,6 @@ type LogoutEndpoint =
:> Header' '[Required, Strict] "X-CSRF-Token" CsrfToken
:> Post '[JSON] LogoutSuccess
type BrowseEndpoint =
AuthProtect SessionAuth
:> Throws AuthenticationError
:> Throws BrowseError
:> QueryParam "root" Root
:> QueryParam "prefix" BrowsePrefix
:> QueryParam "q" SearchQuery
:> QueryParam "continuation" PaginationToken
:> Get '[HTML, JSON] BrowseView
type DownloadSuccess =
Headers
'[ Header "Content-Disposition" Text
@ -543,46 +474,6 @@ type DownloadSuccess =
]
(SourceIO BS.ByteString)
type DownloadEndpoint =
"download"
:> AuthProtect SessionAuth
:> Throws AuthenticationError
:> Throws DownloadError
:> QueryParam' '[Required, Strict] "root" Root
:> QueryParam' '[Required, Strict] "path" RelativePath
:> Header "Range" Text
:> StreamGet NoFraming OctetStream DownloadSuccess
type UploadEndpoint =
"upload"
:> AuthProtect SessionAuth
:> Throws AuthenticationError
:> Throws UploadError
:> QueryParam' '[Required, Strict] "root" Root
:> QueryParam' '[Required, Strict] "path" RelativePath
:> QueryParam' '[Required, Strict] "operation_id" OperationId
:> Header' '[Required, Strict] "X-Declared-Size" DeclaredSize
:> Header' '[Required, Strict] "X-Content-SHA256" ContentSha256
:> Header' '[Required, Strict] "X-CSRF-Token" CsrfToken
:> StreamBody NoFraming OctetStream (SourceIO BS.ByteString)
:> Post '[JSON] UploadResponse
type DeleteEndpoint =
"delete"
:> AuthProtect SessionAuth
:> Throws AuthenticationError
:> Throws DeleteError
:> ReqBody '[JSON] DeleteRequest
:> Post '[JSON] DeleteResponse
type DeletePrefixEndpoint =
"delete-prefix"
:> AuthProtect SessionAuth
:> Throws AuthenticationError
:> Throws DeletePrefixError
:> ReqBody '[JSON] DeletePrefixRequest
:> Post '[JSON] DeletePrefixResponse
type ProductApi =
"api" :>
( "session"
@ -597,7 +488,7 @@ type ProductApi =
:> QueryParam "prefix" BrowsePrefix
:> QueryParam "q" SearchQuery
:> QueryParam "continuation" PaginationToken
:> Get '[JSON] BrowseView
:> Get '[JSON] BrowseResponse
:<|> "download"
:> AuthProtect SessionAuth
:> Throws AuthenticationError
@ -643,17 +534,11 @@ type ProductApi =
type Api =
HealthEndpoint
:<|> ReadyEndpoint
:<|> BrowserScriptEndpoint
:<|> AppEndpoint
:<|> StaticAssetsEndpoint
:<|> LoginEndpoint
:<|> OidcCallbackEndpoint
:<|> LogoutEndpoint
:<|> BrowseEndpoint
:<|> DownloadEndpoint
:<|> UploadEndpoint
:<|> DeleteEndpoint
:<|> DeletePrefixEndpoint
:<|> ProductApi
api :: Proxy Api

View file

@ -94,7 +94,6 @@ import Nuthatch.Streaming
, UploadStreamError (..)
)
import Nuthatch.Types
import Nuthatch.Ui (browserScript, productPage)
import Servant
import Servant.Server.Experimental.Auth
( AuthHandler
@ -157,17 +156,11 @@ server :: Env -> Server Api
server env =
pure (HealthResponse True)
:<|> handleReady env
:<|> pure (BrowserScript browserScript)
:<|> handleAppRequest
:<|> staticAssetsServer
:<|> handleLoginRequest env
:<|> handleOidcCallback env
:<|> handleLogoutRequest env
:<|> handleBrowseRequest env
:<|> handleDownloadRequest env
:<|> handleUploadRequest env
:<|> handleDeleteRequest env
:<|> handleDeletePrefixRequest env
:<|>
( handleSessionRequest
:<|> handleBrowseRequest env
@ -396,7 +389,7 @@ handleBrowseRequest
-> Maybe BrowsePrefix
-> Maybe SearchQuery
-> Maybe PaginationToken
-> Handler BrowseView
-> Handler BrowseResponse
handleBrowseRequest env session selectedRoot selectedPrefix search continuation = do
let user = sessionUser session
root = fromMaybe Personal selectedRoot
@ -414,14 +407,10 @@ handleBrowseRequest env session selectedRoot selectedPrefix search continuation
(throwOperationError status502Error . BrowseObjectStoreFailed)
pure
result
let response = BrowseResponse
{ browseEntries = entries
, browseContinuation = PaginationToken <$> nextContinuation
, browsePartial = partial
}
pure BrowseView
{ browseViewHtml = HtmlPage (productPage entries)
, browseViewJson = response
pure BrowseResponse
{ browseEntries = entries
, browseContinuation = PaginationToken <$> nextContinuation
, browsePartial = partial
}
handleDownloadRequest
@ -624,22 +613,6 @@ handleDeleteRequest env session request = do
cfgIdempotencyWindowSec (envConfig env)
}
handleDeletePrefixRequest
:: Env
-> SessionContext
-> DeletePrefixRequest
-> Handler DeletePrefixResponse
handleDeletePrefixRequest env session request = do
requireCsrf "delete_prefix" DeletePrefixCsrfRejected session (deletePrefixCsrf request)
liftIO $ audit "delete_prefix" "rejected"
(Just (unUserId (suUserId (sessionUser session))))
([ ("root", A.toJSON (rootLabel (deletePrefixRoot request)))
, ("reason", A.String "paged_delete_required")
] <> objectKeyAudit (envConfig env) (deletePrefixPath request))
throwOperationError err409
(DeletePrefixMutationRejected
"use delete-plan and delete-batch with client operation ids")
handleDeletePlanRequest
:: Env
-> SessionContext

View file

@ -1,55 +0,0 @@
module Nuthatch.Ui
( browserScript
, productPage
, htmlResponse
, escapeHtml
) where
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Lazy (fromStrict)
import qualified Data.Text.Lazy.Encoding as TLE
import Network.HTTP.Types (Status, hContentType)
import Network.Wai (Response, responseLBS)
import Nuthatch.Types
htmlResponse :: Status -> Text -> Response
htmlResponse status body =
responseLBS status
[(hContentType, "text/html; charset=utf-8")]
(TLE.encodeUtf8 (fromStrict body))
escapeHtml :: Text -> Text
escapeHtml =
T.concatMap $ \case
'&' -> "&amp;"
'<' -> "&lt;"
'>' -> "&gt;"
'"' -> "&quot;"
'\'' -> "&#39;"
c -> T.singleton c
productPage :: [ListEntry] -> Text
productPage entries =
T.unlines
[ "<!doctype html>"
, "<html lang=\"en\"><head>"
, "<meta charset=\"utf-8\">"
, "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
, "<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'self'; object-src 'none'; base-uri 'none'\">"
, "<title>Nuthatch</title>"
, "</head><body><main>"
, "<h1>Nuthatch</h1>"
, "<p>The browser application has moved to <a href=\"/app\">/app</a>.</p>"
, "<ul>"
, T.concat
[ "<li>" <> escapeHtml (leRelativePath entry) <> "</li>"
| entry <- entries
]
, "</ul>"
, "</main></body></html>"
]
browserScript :: Text
browserScript =
"const nuthatchApp = '/app'; if (window.location.pathname === '/') window.location.assign(nuthatchApp);"

View file

@ -13,6 +13,7 @@ import Control.Exception
, throwIO
, try
)
import Control.Monad (forM_)
import Control.Monad.IO.Class (liftIO)
import Control.Retry (simulatePolicy)
import Control.Lens ((&), (?~))
@ -129,7 +130,6 @@ import Nuthatch.Streaming
, maximumMultipartBytes
, multipartPartBytes
)
import Nuthatch.Ui (browserScript, productPage)
import Servant
import Servant.Server.Experimental.Auth (AuthHandler, mkAuthHandler)
import Servant.Types.SourceT
@ -623,8 +623,8 @@ apiTests = testGroup "typed HTTP boundary"
("prefix_mutation_rejected" `BS.isInfixOf` BL.toStrict prefixError)
, testCase "production authentication validates cookies and callback emits secure cookies"
testAuthenticationBoundary
, testCase "legacy browser entry directs safely to the static application"
testBrowserBridge
, testCase "only canonical product routes are exposed"
testCanonicalProductRoutes
, testCase "links derive from the type-level API" $
show (linkURI (safeLink api (Proxy :: Proxy HealthEndpoint)))
@?= "healthz"
@ -648,7 +648,7 @@ testValidDecoding :: IO ()
testValidDecoding = do
calls <- newIORef 0
response <- apiRequest calls methodGet
"/?root=personal&prefix=docs%2Freports&q=quarterly&continuation=next"
"/api/browse?root=personal&prefix=docs%2Freports&q=quarterly&continuation=next"
[]
""
simpleStatus response @?= status200
@ -657,16 +657,17 @@ testValidDecoding = do
testInvalidDomains :: IO ()
testInvalidDomains = do
calls <- newIORef 0
invalidRoot <- apiRequest calls methodGet "/?root=PERSONAL" [] ""
invalidRoot <- apiRequest calls methodGet "/api/browse?root=PERSONAL" [] ""
simpleStatus invalidRoot @?= status400
invalidPath <- apiRequest calls methodGet "/?prefix=docs%2F..%2Fprivate" [] ""
invalidPath <- apiRequest calls methodGet
"/api/browse?prefix=docs%2F..%2Fprivate" [] ""
simpleStatus invalidPath @?= status400
readIORef calls >>= (@?= 0)
testMalformedUtf8 :: IO ()
testMalformedUtf8 = do
calls <- newIORef 0
response <- apiRequest calls methodGet "/?q=%FF" [] ""
response <- apiRequest calls methodGet "/api/browse?q=%FF" [] ""
simpleStatus response @?= status400
readIORef calls >>= (@?= 0)
@ -681,16 +682,17 @@ testUploadDecoding = do
, ("X-CSRF-Token", csrf)
]
response <- apiRequest calls methodPost
"/upload?root=personal&path=docs%2Ffile.txt&operation_id=bad%20id"
"/api/upload?root=personal&path=docs%2Ffile.txt&operation_id=bad%20id"
headers
"body"
simpleStatus response @?= status400
invalidSize <- apiRequest calls methodPost
"/upload?root=personal&path=docs%2Ffile.txt&operation_id=upload-1"
"/api/upload?root=personal&path=docs%2Ffile.txt&operation_id=upload-1"
(replaceHeader "X-Declared-Size" "zero" headers)
"body"
simpleStatus invalidSize @?= status400
invalidPagination <- apiRequest calls methodGet "/?continuation=" [] ""
invalidPagination <- apiRequest calls methodGet
"/api/browse?continuation=" [] ""
simpleStatus invalidPagination @?= status400
readIORef calls >>= (@?= 0)
@ -705,7 +707,7 @@ testStreamingEndpoints = do
, ("X-CSRF-Token", csrf)
]
upload <- apiRequest calls methodPost
"/upload?root=personal&path=docs%2Ffile.txt&operation_id=upload-1"
"/api/upload?root=personal&path=docs%2Ffile.txt&operation_id=upload-1"
headers
"body"
simpleStatus upload @?= status200
@ -713,7 +715,7 @@ testStreamingEndpoints = do
("\"idempotency_window_seconds\":3600"
`BS.isInfixOf` BL.toStrict (simpleBody upload))
download <- apiRequest calls methodGet
"/download?root=shared&path=docs%2Ffile.txt"
"/api/download?root=shared&path=docs%2Ffile.txt"
[]
""
simpleStatus download @?= status200
@ -735,10 +737,10 @@ testOperationRepresentations = do
[("Cookie", "nuthatch_oidc=transaction")]
""
simpleStatus callback @?= status303
legacyLogin <- apiRequest calls methodPost "/login" [] ""
simpleStatus legacyLogin @?= status405
wrongLoginMethod <- apiRequest calls methodPost "/login" [] ""
simpleStatus wrongLoginMethod @?= status405
deletion <- apiRequest calls methodPost
"/delete"
"/api/delete"
[("Content-Type", "application/json")]
"{\"root\":\"shared\",\"path\":\"docs/file.txt\",\"operation_id\":\"delete-1\",\"csrf\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}"
simpleStatus deletion @?= status200
@ -748,7 +750,7 @@ testOperationRepresentations = do
("\"idempotency_window_seconds\":3600"
`BS.isInfixOf` BL.toStrict (simpleBody deletion))
malformed <- apiRequest calls methodPost
"/delete"
"/api/delete"
[("Content-Type", "application/json")]
"{\"root\":\"shared\",\"path\":\"../escape\"}"
simpleStatus malformed @?= status400
@ -815,7 +817,6 @@ typedTestServer :: IORef Int -> Server Api
typedTestServer calls =
counted (HealthResponse True)
:<|> counted (ReadyResponse True)
:<|> counted (BrowserScript "script")
:<|> (\_ -> counted (addHeader "default-src 'none'" (HtmlPage "app")))
:<|> (\_ -> Tagged $ \_ sendResponse ->
sendResponse (responseLBS status404 [] ""))
@ -831,29 +832,6 @@ typedTestServer calls =
(addHeader "session=expired"
(addHeader "csrf=expired"
(addHeader "transaction=expired" NoContent))))
:<|> (\_ _ _ _ _ -> counted
(BrowseView
(HtmlPage "browse")
BrowseResponse
{ browseEntries = []
, browseContinuation = Nothing
, browsePartial = False
}))
:<|> (\_ _ _ _ -> counted
(addHeader "attachment; filename=\"file.txt\""
(addHeader "nosniff"
(addHeader "sandbox" (addHeader 4 (source ["body"]))))))
:<|> (\_ _ _ operation _ _ _ _ -> counted UploadResponse
{ uploadResponseOperationId = operation
, uploadResponseResult = MutationSucceeded
, uploadResponseIdempotencyWindowSeconds = 3600
})
:<|> (\_ request -> counted DeleteResponse
{ deleteResponseOperationId = deleteOperationId request
, deleteResponseResult = MutationSucceeded
, deleteResponseIdempotencyWindowSeconds = 3600
})
:<|> (\_ _ -> counted (DeletePrefixResponse 1))
:<|>
( (\session -> counted SessionResponse
{ sessionResponseUserId = suUserId (sessionUser session)
@ -883,13 +861,11 @@ typedTestServer calls =
counted :: a -> Handler a
counted value = liftIO (modifyIORef' calls (+ 1)) >> pure value
emptyBrowse =
BrowseView
(HtmlPage "browse")
BrowseResponse
{ browseEntries = []
, browseContinuation = Nothing
, browsePartial = False
}
BrowseResponse
{ browseEntries = []
, browseContinuation = Nothing
, browsePartial = False
}
downloadResponse =
addHeader "attachment; filename=\"file.txt\"" $
addHeader "nosniff" $
@ -932,7 +908,7 @@ testAuthenticationBoundary = do
"nuthatch_session=" <> TE.encodeUtf8 (unSessionToken token)
<> "; nuthatch_csrf=" <> TE.encodeUtf8 (unCsrfToken csrf)
authenticated <- apiRequestWith app methodGet
"/?root=personal"
"/api/browse?root=personal"
[("Cookie", cookie)]
""
simpleStatus authenticated @?= status200
@ -940,7 +916,7 @@ testAuthenticationBoundary = do
writeIORef calls 0
malformed <- apiRequestWith app methodGet
"/?root=personal"
"/api/browse?root=personal"
[("Cookie", "nuthatch_session=" <> BS.singleton 255
<> "; nuthatch_csrf=" <> TE.encodeUtf8 (unCsrfToken csrf))]
""
@ -948,7 +924,7 @@ testAuthenticationBoundary = do
readIORef calls >>= (@?= 0)
malformedGrammar <- apiRequestWith app methodGet
"/?root=personal"
"/api/browse?root=personal"
[("Cookie", "nuthatch_session")]
""
simpleStatus malformedGrammar @?= status400
@ -961,33 +937,33 @@ testAuthenticationBoundary = do
| otherwise -> prefix <> "a"
Nothing -> "invalid"
tampered <- apiRequestWith app methodGet
"/?root=personal"
"/api/browse?root=personal"
[("Cookie", "nuthatch_session=" <> TE.encodeUtf8 tamperedToken)]
""
simpleStatus tampered @?= status303
readIORef calls >>= (@?= 0)
missingCsrf <- apiRequestWith app methodGet
"/?root=personal"
"/api/browse?root=personal"
[("Cookie", "nuthatch_session=" <> TE.encodeUtf8 (unSessionToken token))]
""
simpleStatus missingCsrf @?= status200
readIORef calls >>= (@?= 1)
testBrowserBridge :: IO ()
testBrowserBridge = do
let entry = ListEntry
{ leName = "folder"
, leIsPrefix = True
, leSize = Nothing
, leRelativePath = "folder<script>"
}
browse = productPage [entry]
assertBool "legacy page directs browsers to the static application"
("/app" `T.isInfixOf` browse && "/app" `T.isInfixOf` browserScript)
assertBool "legacy page escapes object paths"
("folder&lt;script&gt;" `T.isInfixOf` browse
&& not ("folder<script>" `T.isInfixOf` browse))
testCanonicalProductRoutes :: IO ()
testCanonicalProductRoutes = do
calls <- newIORef 0
forM_
[ (methodGet, "/")
, (methodGet, "/nuthatch.js")
, (methodGet, "/download?root=personal&path=file")
, (methodPost, "/upload")
, (methodPost, "/delete")
, (methodPost, "/delete-prefix")
] $ \(method, path) -> do
response <- apiRequest calls method path [] ""
simpleStatus response @?= status404
readIORef calls >>= (@?= 0)
productionAuthTestApplication :: Config -> IORef Int -> Application
productionAuthTestApplication cfg calls =

View file

@ -278,17 +278,6 @@ spec:
grep -F '"ok":true'
curl --insecure --fail --silent --show-error "$base/readyz" |
grep -F '"ready":true'
curl --insecure --fail --silent --show-error "$base/nuthatch.js" |
grep -F 'nuthatch'
{
curl --insecure --fail --silent --show-error "$base/nuthatch.js"
curl --insecure --silent --show-error "$base/login"
} > /tmp/browser-visible
if grep -Ei \
'NUTHATCH_S3|AWS_ACCESS_KEY|AWS_SECRET_ACCESS_KEY|client_secret' \
/tmp/browser-visible; then
exit 1
fi
test "$(
curl --insecure --silent --output /dev/null \
--write-out '%{http_code}' \
@ -299,11 +288,11 @@ spec:
curl --insecure --silent --output /dev/null \
--write-out '%{http_code}' \
"$base/"
)" = 303
)" = 404
test "$(
curl --insecure --silent --output /dev/null \
--write-out '%{http_code}' \
"$base/download?root=personal&path=unauthenticated"
"$base/api/download?root=personal&path=unauthenticated"
)" = 303
unauthenticated_digest="$(
printf x | sha256sum | cut -d' ' -f1
@ -315,14 +304,14 @@ spec:
--header "X-Content-SHA256: $unauthenticated_digest" \
--header 'X-CSRF-Token: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \
--data x \
"$base/upload?root=personal&path=unauthenticated&operation_id=40000000-0000-4000-8000-000000000001"
"$base/api/upload?root=personal&path=unauthenticated&operation_id=40000000-0000-4000-8000-000000000001"
)" = 303
test "$(
curl --insecure --silent --output /dev/null \
--write-out '%{http_code}' \
--header 'Content-Type: application/json' \
--data '{"root":"personal","path":"unauthenticated","operation_id":"40000000-0000-4000-8000-000000000002","csrf":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' \
"$base/delete"
"$base/api/delete"
)" = 303
test "$(
curl --insecure --silent --output /dev/null \
@ -330,7 +319,7 @@ spec:
--header 'Content-Type: application/json' \
--data '{"root":"personal","path":"unauthenticated","csrf":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' \
"$base/delete-prefix"
)" = 303
)" = 404
test "$(
curl --insecure --silent --output /dev/null \
--write-out '%{http_code}' \
@ -348,14 +337,14 @@ spec:
curl --insecure --silent --output /tmp/authenticated.body \
--write-out '%{http_code}' \
--cookie "$cookies" \
"$base/"
"$base/api/browse"
)"
printf 'Authenticated browse status=%s\n' "$authenticated_status"
test "$authenticated_status" = 200
curl --insecure --fail --silent --show-error \
--cookie "$cookies" \
--header 'Accept: application/json' \
"$base/" >/tmp/authenticated.json
"$base/api/browse" >/tmp/authenticated.json
if grep -Ei \
'NUTHATCH_S3|AWS_ACCESS_KEY|AWS_SECRET_ACCESS_KEY|client_secret' \
/tmp/authenticated.body /tmp/authenticated.json; then
@ -514,6 +503,11 @@ spec:
/tmp/product-asset.headers >/dev/null
grep -i '^x-content-type-options: *nosniff' \
/tmp/product-asset.headers >/dev/null
if grep -Ei \
'NUTHATCH_S3|AWS_ACCESS_KEY|AWS_SECRET_ACCESS_KEY|client_secret' \
/tmp/product-app.html /tmp/product-asset.js; then
exit 1
fi
for marker in \
'Drop a file here' \
'Shared root' \
@ -768,7 +762,7 @@ spec:
first_size="$(wc -c </tmp/epoch-one | tr -d ' ')"
first_digest="$(sha256sum /tmp/epoch-one | cut -d' ' -f1)"
operation=73000000-0000-4000-8000-000000000001
mutation_url="$base/upload?root=personal&path=idempotency.txt&operation_id=$operation"
mutation_url="$base/api/upload?root=personal&path=idempotency.txt&operation_id=$operation"
pod_curl "$NUTHATCH_IDEMPOTENCY_IP_A" \
--fail --silent --show-error \
--cookie "$cookies" \
@ -880,7 +874,7 @@ spec:
pod_curl "$NUTHATCH_IDEMPOTENCY_IP_A" \
--fail --silent --show-error \
--cookie "$cookies" \
"$base/download?root=personal&path=idempotency.txt" \
"$base/api/download?root=personal&path=idempotency.txt" \
>/tmp/epoch-download
cmp "$epoch_two_body" /tmp/epoch-download
@ -890,7 +884,7 @@ spec:
sha256sum /tmp/active-body | cut -d' ' -f1
)"
active_operation=73000000-0000-4000-8000-000000000002
active_url="$base/upload?root=personal&path=active-window.bin&operation_id=$active_operation"
active_url="$base/api/upload?root=personal&path=active-window.bin&operation_id=$active_operation"
(
dd if=/tmp/active-body bs=65536 count=1 status=none
sleep 5
@ -929,7 +923,7 @@ spec:
--header "X-Content-SHA256: $first_digest" \
--header "X-CSRF-Token: $csrf" \
--data-binary @/tmp/epoch-one \
"$base/upload?root=personal&path=active-window.bin&operation_id=73000000-0000-4000-8000-000000000003"
"$base/api/upload?root=personal&path=active-window.bin&operation_id=73000000-0000-4000-8000-000000000003"
)"
printf 'Active key lock status=%s body=' "$lock_status"
cat /tmp/active-lock.json
@ -983,7 +977,7 @@ spec:
--header "X-CSRF-Token: $csrf" \
--request POST \
--upload-file - \
"$base/upload?root=personal&path=streaming-large.bin&operation_id=70000000-0000-4000-8000-000000000001"
"$base/api/upload?root=personal&path=streaming-large.bin&operation_id=70000000-0000-4000-8000-000000000001"
)"
printf 'Large upload status=%s\n' "$status"
test "$status" = 200
@ -992,7 +986,7 @@ spec:
download_digest="$(
curl --fail --silent --show-error \
--cookie "$cookies" \
"$base/download?root=personal&path=streaming-large.bin" |
"$base/api/download?root=personal&path=streaming-large.bin" |
sha256sum |
cut -d' ' -f1
)"
@ -1013,7 +1007,7 @@ spec:
--header "X-Content-SHA256: $large_digest" \
--header "X-CSRF-Token: $csrf" \
--data-binary @/tmp/tiny \
"$base/upload?root=personal&path=streaming-large.bin&operation_id=70000000-0000-4000-8000-000000000007"
"$base/api/upload?root=personal&path=streaming-large.bin&operation_id=70000000-0000-4000-8000-000000000007"
)"
test "$status" = 200
grep -F '"result":"succeeded"' \
@ -1029,7 +1023,7 @@ spec:
--header "X-Content-SHA256: $tiny_digest" \
--header "X-CSRF-Token: $csrf" \
--data-binary @/tmp/tiny \
"$base/upload?root=personal&path=size-mismatch.bin&operation_id=70000000-0000-4000-8000-000000000002"
"$base/api/upload?root=personal&path=size-mismatch.bin&operation_id=70000000-0000-4000-8000-000000000002"
)"
printf 'Size mismatch status=%s body=' "$status"
cat /tmp/size-mismatch.json
@ -1046,7 +1040,7 @@ spec:
--header "X-Content-SHA256: $(printf '%064d' 0)" \
--header "X-CSRF-Token: $csrf" \
--data-binary @/tmp/tiny \
"$base/upload?root=personal&path=identity-mismatch.bin&operation_id=70000000-0000-4000-8000-000000000003"
"$base/api/upload?root=personal&path=identity-mismatch.bin&operation_id=70000000-0000-4000-8000-000000000003"
)"
printf 'Identity mismatch status=%s body=' "$status"
cat /tmp/identity-mismatch.json
@ -1069,7 +1063,7 @@ spec:
--header "X-CSRF-Token: $csrf" \
--request POST \
--upload-file - \
"$base/upload?root=personal&path=too-large.bin&operation_id=70000000-0000-4000-8000-000000000004"
"$base/api/upload?root=personal&path=too-large.bin&operation_id=70000000-0000-4000-8000-000000000004"
)"
printf 'Over-limit status=%s body=' "$status"
cat /tmp/too-large.json
@ -1089,7 +1083,7 @@ spec:
--header "X-CSRF-Token: $csrf" \
--request POST \
--upload-file - \
"$base/upload?root=personal&path=aborted-upload.bin&operation_id=70000000-0000-4000-8000-000000000005" \
"$base/api/upload?root=personal&path=aborted-upload.bin&operation_id=70000000-0000-4000-8000-000000000005" \
>/tmp/aborted-upload.json; then
exit 1
fi
@ -1097,7 +1091,7 @@ spec:
curl --silent --output /dev/null \
--write-out '%{http_code}' \
--cookie "$cookies" \
"$base/download?root=personal&path=aborted-upload.bin"
"$base/api/download?root=personal&path=aborted-upload.bin"
)"
printf 'Aborted upload lookup status=%s\n' \
"$aborted_upload_status"
@ -1112,7 +1106,7 @@ spec:
--header "X-Content-SHA256: $large_digest" \
--header "X-CSRF-Token: $csrf" \
--data-binary @/tmp/tiny \
"$base/upload?root=personal&path=aborted-upload.bin&operation_id=70000000-0000-4000-8000-000000000005"
"$base/api/upload?root=personal&path=aborted-upload.bin&operation_id=70000000-0000-4000-8000-000000000005"
)"
printf 'Aborted upload replay status=%s body=' "$status"
cat /tmp/aborted-upload-replay.json
@ -1132,14 +1126,14 @@ spec:
--header "X-CSRF-Token: $csrf" \
--request POST \
--upload-file - \
"$base/upload?root=personal&path=streaming-large.bin&operation_id=70000000-0000-4000-8000-000000000006" \
"$base/api/upload?root=personal&path=streaming-large.bin&operation_id=70000000-0000-4000-8000-000000000006" \
>/tmp/aborted-overwrite.json; then
exit 1
fi
preserved_digest="$(
curl --fail --silent --show-error \
--cookie "$cookies" \
"$base/download?root=personal&path=streaming-large.bin" |
"$base/api/download?root=personal&path=streaming-large.bin" |
sha256sum |
cut -d' ' -f1
)"
@ -1148,7 +1142,7 @@ spec:
--max-time 1 \
--limit-rate 64k \
--cookie "$cookies" \
"$base/download?root=personal&path=streaming-large.bin" \
"$base/api/download?root=personal&path=streaming-large.bin" \
>/tmp/aborted-download; then
exit 1
fi
@ -1178,7 +1172,7 @@ spec:
--header "X-Content-SHA256: $joint_digest" \
--header "X-CSRF-Token: $csrf" \
--data-binary @/tmp/joint-upload \
"$base/upload?root=personal&path=joint-throttle.bin&operation_id=70000000-0000-4000-8000-000000000013"
"$base/api/upload?root=personal&path=joint-throttle.bin&operation_id=70000000-0000-4000-8000-000000000013"
)"
test "$status" = 200
grep -F '"result":"succeeded"' /tmp/joint-upload.json
@ -1197,7 +1191,7 @@ spec:
--header "X-Content-SHA256: $upload_digest" \
--header "X-CSRF-Token: $csrf_b" \
--data-binary @/tmp/throttle-upload \
"$base/upload?root=personal&path=throttled-upload.bin&operation_id=71000000-0000-4000-8000-000000000001"
"$base/api/upload?root=personal&path=throttled-upload.bin&operation_id=71000000-0000-4000-8000-000000000001"
)"
test "$status" = 429
grep -F '"error":"bandwidth_exceeded"' /tmp/throttle-upload.json
@ -1211,15 +1205,15 @@ spec:
--header "X-Content-SHA256: $upload_digest" \
--header "X-CSRF-Token: $csrf_b" \
--data-binary @/tmp/tiny \
"$base/upload?root=personal&path=prestart.bin&operation_id=71000000-0000-4000-8000-000000000002"
"$base/api/upload?root=personal&path=prestart.bin&operation_id=71000000-0000-4000-8000-000000000002"
)" = 429
curl --silent --show-error --cookie "$cookies" \
"$base/download?root=personal&path=streaming-large.bin" \
"$base/api/download?root=personal&path=streaming-large.bin" \
>/tmp/parallel-a &
first_pid=$!
curl --silent --show-error --cookie "$cookies" \
"$base/download?root=personal&path=streaming-large.bin" \
"$base/api/download?root=personal&path=streaming-large.bin" \
>/tmp/parallel-b &
second_pid=$!
first_ok=true
@ -1244,7 +1238,7 @@ spec:
curl --silent --output /tmp/prestart-download.json \
--write-out '%{http_code}' \
--cookie "$cookies" \
"$base/download?root=personal&path=streaming-large.bin"
"$base/api/download?root=personal&path=streaming-large.bin"
)"
printf 'Post-exhaustion download status=%s\n' "$prestart_status"
test "$prestart_status" = 429
@ -1275,7 +1269,7 @@ spec:
--header "X-Content-SHA256: $refill_digest" \
--header "X-CSRF-Token: $csrf" \
--data-binary @/tmp/refill-upload \
"$base/upload?root=personal&path=refill-$suffix.bin&operation_id=$refill_operation"
"$base/api/upload?root=personal&path=refill-$suffix.bin&operation_id=$refill_operation"
)"
test "$status" = 200
grep -F '"result":"succeeded"' "/tmp/refill-$suffix.json"
@ -1289,7 +1283,7 @@ spec:
--header "X-Content-SHA256: $refill_digest" \
--header "X-CSRF-Token: $csrf" \
--data-binary @/tmp/refill-upload \
"$base/upload?root=personal&path=refill-exhausted.bin&operation_id=72000000-0000-4000-8000-000000000003"
"$base/api/upload?root=personal&path=refill-exhausted.bin&operation_id=72000000-0000-4000-8000-000000000003"
)" = 429
fi
done
@ -1329,7 +1323,7 @@ spec:
--header "X-Content-SHA256: $tiny_digest" \
--header "X-CSRF-Token: $csrf" \
--data-binary @/tmp/tiny \
"$base/upload?root=personal&path=unavailable.bin&operation_id=72000000-0000-4000-8000-000000000001"
"$base/api/upload?root=personal&path=unavailable.bin&operation_id=72000000-0000-4000-8000-000000000001"
)" = 503
grep -F '"error":"throttle_unavailable"' \
/tmp/unavailable-upload.json
@ -1337,7 +1331,7 @@ spec:
curl --silent --output /tmp/unavailable-download.json \
--write-out '%{http_code}' \
--cookie "$cookies" \
"$base/download?root=personal&path=$unavailable_path"
"$base/api/download?root=personal&path=$unavailable_path"
)" = 503
grep -F '"error":"throttle_unavailable"' \
/tmp/unavailable-download.json
@ -1363,7 +1357,7 @@ spec:
--header "X-Content-SHA256: $digest" \
--header "X-CSRF-Token: $csrf" \
--data-binary @/tmp/body \
"$base/upload?root=$root&path=smoke-$suffix.txt&operation_id=00000000-0000-4000-8000-0000000000${suffix}"
"$base/api/upload?root=$root&path=smoke-$suffix.txt&operation_id=00000000-0000-4000-8000-0000000000${suffix}"
)"
printf 'Upload %s status=%s\n' "$root" "$upload_status"
test "$upload_status" = 200
@ -1371,11 +1365,11 @@ spec:
curl --fail --silent --show-error \
--cookie "$cookies" \
--header 'Accept: application/json' \
"$base/?root=$root" |
"$base/api/browse?root=$root" |
grep -F "smoke-$suffix.txt"
curl --fail --silent --show-error \
--cookie "$cookies" \
"$base/download?root=$root&path=smoke-$suffix.txt" >/tmp/download
"$base/api/download?root=$root&path=smoke-$suffix.txt" >/tmp/download
test "$(cat /tmp/download)" = "$first"
printf '%s' "$second" >/tmp/body
@ -1388,22 +1382,22 @@ spec:
--header "X-Content-SHA256: $digest" \
--header "X-CSRF-Token: $csrf" \
--data-binary @/tmp/body \
"$base/upload?root=$root&path=smoke-$suffix.txt&operation_id=10000000-0000-4000-8000-0000000000${suffix}" |
"$base/api/upload?root=$root&path=smoke-$suffix.txt&operation_id=10000000-0000-4000-8000-0000000000${suffix}" |
grep -F '"result":"succeeded"'
curl --fail --silent --show-error \
--cookie "$cookies" \
"$base/download?root=$root&path=smoke-$suffix.txt" >/tmp/download
"$base/api/download?root=$root&path=smoke-$suffix.txt" >/tmp/download
test "$(cat /tmp/download)" = "$second"
curl --fail --silent --show-error \
--cookie "$cookies" \
--header 'Content-Type: application/json' \
--data "{\"root\":\"$root\",\"path\":\"smoke-$suffix.txt\",\"operation_id\":\"20000000-0000-4000-8000-0000000000${suffix}\",\"csrf\":\"$csrf\"}" \
"$base/delete" |
"$base/api/delete" |
grep -F '"result":"succeeded"'
if curl --fail --silent \
--cookie "$cookies" \
"$base/download?root=$root&path=smoke-$suffix.txt" >/dev/null; then
"$base/api/download?root=$root&path=smoke-$suffix.txt" >/dev/null; then
exit 1
fi
}
@ -1420,7 +1414,7 @@ spec:
--header "X-Content-SHA256: $digest" \
--header "X-CSRF-Token: $csrf" \
--data-binary @/tmp/body \
"$base/upload?root=personal&path=cross-user.txt&operation_id=30000000-0000-4000-8000-000000000001" |
"$base/api/upload?root=personal&path=cross-user.txt&operation_id=30000000-0000-4000-8000-000000000001" |
grep -F '"result":"succeeded"'
curl --insecure --fail --silent --show-error \
--cookie "$cookies" \
@ -1429,7 +1423,7 @@ spec:
--header "X-Content-SHA256: $digest" \
--header "X-CSRF-Token: $csrf" \
--data-binary @/tmp/body \
"$base/upload?root=shared&path=cross-user.txt&operation_id=30000000-0000-4000-8000-000000000002" |
"$base/api/upload?root=shared&path=cross-user.txt&operation_id=30000000-0000-4000-8000-000000000002" |
grep -F '"result":"succeeded"'
csrf_b="$(login "$NUTHATCH_OIDC_USER_B" b)"
@ -1472,14 +1466,14 @@ spec:
if curl --insecure --fail --silent --show-error \
--cookie /tmp/cookies-b \
--header 'Accept: application/json' \
"$base/?root=personal" |
"$base/api/browse?root=personal" |
grep -F cross-user.txt; then
exit 1
fi
curl --insecure --fail --silent --show-error \
--cookie /tmp/cookies-b \
--header 'Accept: application/json' \
"$base/?root=shared" |
"$base/api/browse?root=shared" |
grep -F cross-user.txt
if curl --insecure --fail --silent \
@ -1507,7 +1501,7 @@ spec:
curl --insecure --silent --output /dev/null \
--write-out '%{http_code}' \
--cookie "$cookies" \
"$base/"
"$base/api/browse"
)" = 303
printf 'OIDC app smoke passed\n'
env:

View file

@ -51,7 +51,11 @@ if grep -Eiq '\b(TODO|TBD|unresolved|open question)\b' "$document"; then
fi
grep -F 'e0240ab33521a1882fb647a79f7fd3c74c9b6348b29bdb14e572204517882859' "$document" >/dev/null
grep -F 'Public `GET /nuthatch.js`' "$document" >/dev/null
grep -F 'Authenticated `/api/session`, `/api/browse`, `/api/download`' "$document" >/dev/null
if grep -Fq '/nuthatch.js' "$document"; then
printf '%s\n' "Durable contracts contain a removed browser bridge" >&2
exit 1
fi
grep -F '`nuthatch-sha256`' "$document" >/dev/null
grep -F '`x-amz-meta-nuthatch-sha256`' "$document" >/dev/null
grep -F 'always streams the full body and computes its digest' "$document" >/dev/null