syntropy 0.30.0 → 0.32.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.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/CHANGELOG.md +30 -0
  4. data/TODO.md +46 -1
  5. data/bin/syntropy +8 -86
  6. data/cmd/_banner.rb +16 -0
  7. data/cmd/console.rb +77 -0
  8. data/cmd/help.rb +12 -0
  9. data/cmd/serve.rb +95 -0
  10. data/cmd/test.rb +40 -0
  11. data/examples/{counter.rb → basic/counter.rb} +1 -1
  12. data/examples/{templates.rb → basic/templates.rb} +1 -1
  13. data/examples/blog/app/_layout/default.rb +11 -0
  14. data/examples/blog/app/_lib/post_store.rb +47 -0
  15. data/examples/blog/app/_schema/2026-01-01-initial.rb +9 -0
  16. data/examples/blog/app/_setup.rb +4 -0
  17. data/examples/blog/app/index.rb +7 -0
  18. data/examples/blog/app/posts/[id]/edit.rb +33 -0
  19. data/examples/blog/app/posts/[id]/index.rb +58 -0
  20. data/examples/blog/app/posts/index.rb +38 -0
  21. data/examples/blog/app/posts/new.rb +29 -0
  22. data/examples/mcp-oauth/.ruby-version +1 -0
  23. data/examples/mcp-oauth/Gemfile +8 -0
  24. data/examples/mcp-oauth/README.md +128 -0
  25. data/examples/mcp-oauth/app/.well-known/oauth-authorization-server.rb +18 -0
  26. data/examples/mcp-oauth/app/.well-known/oauth-protected-resource.rb +10 -0
  27. data/examples/mcp-oauth/app/_lib/auth_store.rb +23 -0
  28. data/examples/mcp-oauth/app/index.md +1 -0
  29. data/examples/mcp-oauth/app/mcp.rb +85 -0
  30. data/examples/mcp-oauth/app/oauth/authorize.rb +18 -0
  31. data/examples/mcp-oauth/app/oauth/consent.rb +86 -0
  32. data/examples/mcp-oauth/app/oauth/register.rb +14 -0
  33. data/examples/mcp-oauth/app/oauth/token.rb +79 -0
  34. data/examples/mcp-oauth/app/signin.rb +85 -0
  35. data/examples/mcp-oauth/test/helper.rb +9 -0
  36. data/examples/mcp-oauth/test/test_app.rb +27 -0
  37. data/examples/mcp-oauth/test/test_oauth.rb +628 -0
  38. data/lib/syntropy/app.rb +34 -9
  39. data/lib/syntropy/applets/builtin/default_error_handler.rb +3 -3
  40. data/lib/syntropy/applets/builtin/req.rb +1 -1
  41. data/lib/syntropy/db/connection_pool.rb +71 -0
  42. data/lib/syntropy/db/schema.rb +92 -0
  43. data/lib/syntropy/db/store.rb +31 -0
  44. data/lib/syntropy/dev_mode.rb +1 -1
  45. data/lib/syntropy/errors.rb +6 -0
  46. data/lib/syntropy/http/client.rb +43 -0
  47. data/lib/syntropy/http/client_connection.rb +36 -0
  48. data/lib/syntropy/http/io_extensions.rb +176 -0
  49. data/lib/syntropy/http/server.rb +5 -5
  50. data/lib/syntropy/http/{connection.rb → server_connection.rb} +15 -91
  51. data/lib/syntropy/http.rb +3 -1
  52. data/lib/syntropy/logger.rb +5 -1
  53. data/lib/syntropy/{module.rb → module_loader.rb} +47 -8
  54. data/lib/syntropy/papercraft_extensions.rb +1 -1
  55. data/lib/syntropy/request/mock_adapter.rb +2 -0
  56. data/lib/syntropy/request/request_info.rb +22 -4
  57. data/lib/syntropy/request/response.rb +2 -2
  58. data/lib/syntropy/request/validation.rb +11 -5
  59. data/lib/syntropy/routing_tree.rb +2 -1
  60. data/lib/syntropy/test.rb +77 -0
  61. data/lib/syntropy/version.rb +1 -1
  62. data/lib/syntropy.rb +5 -23
  63. data/syntropy.gemspec +3 -3
  64. data/test/app/.well-known/foo.rb +3 -0
  65. data/test/app/_hook.rb +1 -1
  66. data/test/app/by_method.rb +9 -0
  67. data/test/app_setup/_setup.rb +7 -0
  68. data/test/app_setup/index.rb +1 -0
  69. data/test/app_with_schema/_schema/2026-01-02-foo.rb +12 -0
  70. data/test/app_with_schema/_schema/2026-05-30-bar.rb +7 -0
  71. data/test/helper.rb +1 -25
  72. data/test/schema/2026-01-02-foo.rb +12 -0
  73. data/test/schema/2026-05-30-bar.rb +7 -0
  74. data/test/test_app.rb +110 -70
  75. data/test/test_caching.rb +1 -1
  76. data/test/{test_connection_pool.rb → test_db_connection_pool.rb} +7 -2
  77. data/test/test_db_schema.rb +96 -0
  78. data/test/test_db_store.rb +24 -0
  79. data/test/test_http_client.rb +52 -0
  80. data/test/test_http_client_connection.rb +43 -0
  81. data/test/test_http_protocol.rb +250 -0
  82. data/test/{test_connection.rb → test_http_server_connection.rb} +39 -48
  83. data/test/test_json_api.rb +5 -5
  84. data/test/{test_module.rb → test_module_loader.rb} +31 -0
  85. data/test/{test_request_extensions.rb → test_request.rb} +153 -18
  86. data/test/test_routing_tree.rb +15 -3
  87. data/test/test_server.rb +9 -13
  88. metadata +84 -36
  89. data/lib/syntropy/connection_pool.rb +0 -61
  90. data/test/test_request_info.rb +0 -90
  91. /data/examples/{bad.rb → basic/bad.rb} +0 -0
  92. /data/examples/{card.rb → basic/card.rb} +0 -0
  93. /data/examples/{counter.js → basic/counter.js} +0 -0
  94. /data/examples/{counter_api.rb → basic/counter_api.rb} +0 -0
  95. /data/examples/{favicon.ico → basic/favicon.ico} +0 -0
  96. /data/examples/{index.md → basic/index.md} +0 -0
@@ -0,0 +1,38 @@
1
+ @post_store = import '_lib/post_store'
2
+ @layout = import '_layout/default'
3
+
4
+ export http_methods
5
+
6
+ def get(req)
7
+ posts = @post_store.get_all
8
+ req.respond_html(
9
+ @template.render(posts:)
10
+ )
11
+ end
12
+
13
+ def post(req)
14
+ data = req.get_form_data
15
+ title = req.validate(data['title'], String, /.+/)
16
+ body = req.validate(data['body'], String, /.+/)
17
+ id = @post_store.create(title, body)
18
+
19
+ req.redirect("posts/#{id}")
20
+ end
21
+
22
+ @template = @layout.apply { |**props|
23
+ h1 "My blog"
24
+ props[:posts].each { |post|
25
+ div {
26
+ h2 {
27
+ a post[:title], href: "/posts/#{post[:id]}"
28
+ }
29
+ p post[:body]
30
+ }
31
+ }
32
+
33
+ div {
34
+ p {
35
+ a "New post", href: '/posts/new'
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,29 @@
1
+ @post_store = import '/_lib/post_store'
2
+ @layout = import '/_layout/default'
3
+
4
+ export http_methods
5
+
6
+ def get(req)
7
+ req.respond_html(
8
+ @template.render
9
+ )
10
+ end
11
+
12
+ @template = @layout.apply { |**props|
13
+ h1 "Create blog post"
14
+ div {
15
+ form(action: "/posts", method: 'post') {
16
+ div {
17
+ label 'Title', for: 'title'
18
+ input name: 'title', type: 'text'
19
+ }
20
+ div {
21
+ label 'Body', for: 'body'
22
+ textarea '', name: 'body', rows: 5
23
+ }
24
+ div {
25
+ button 'Submit', type: 'submit'
26
+ }
27
+ }
28
+ }
29
+ }
@@ -0,0 +1 @@
1
+ 4.0.3
@@ -0,0 +1,8 @@
1
+ source 'https://gem.coop'
2
+
3
+ gem 'syntropy', path: '../..'
4
+ gem 'jwt'
5
+
6
+ group :development do
7
+ gem 'minitest'
8
+ end
@@ -0,0 +1,128 @@
1
+ # Syntropy OAuth 2.1 Example App
2
+
3
+ This app implements a site that includes an MCP server with OAuth 2.1 authorization.
4
+
5
+ ## Authorization workflow:
6
+
7
+ ### Phase 1: Discovery
8
+
9
+ - MCP client accesses the mcp endpoint:
10
+
11
+ `GET /mcp`
12
+
13
+ Response headers:
14
+
15
+ ```
16
+ HTTP/1.1 401 Unauthorized
17
+ WWW-Authenticate: Bearer realm="mcp", resource_metadata="http://localhost:1234/.well-known/oauth-protected-resource"
18
+ ```
19
+
20
+ - MCP client makes a request to the protected resource endpoint in order to the
21
+ protected resource metadata:
22
+
23
+ `GET /.well-known/oauth-protected-resource`
24
+
25
+ Response JSON:
26
+
27
+ ```
28
+ {
29
+ resource: "http://localhost:1234/",
30
+ authorization_servers: ["http://localhost:1234/"],
31
+ scopes_supported: ["mcp:read", "mcp:write"]
32
+ }
33
+ ```
34
+
35
+ - MCP client makes a request to the authorization server:
36
+
37
+ `GET /.well-known/oauth-authorization-server`
38
+
39
+ Response JSON:
40
+
41
+ ```
42
+ {
43
+ issuer: "http://localhost:1234/",
44
+ registration_endpoint: "http://localhost:1234/oauth/register",
45
+ authorization_endpoint: "http://localhost:1234/oauth/authorize",
46
+ token_endpoint: "http://localhost:1234/oauth/token",
47
+ scopes_supported: ["mcp:read", "mcp:write"],
48
+ response_types_supported: ["code"]
49
+ }
50
+ ```
51
+
52
+ ### Phase 2: Dynamic Client Registration (DCR)
53
+
54
+ - MCP client makes a request to the register endpoint
55
+
56
+ ```
57
+ POST /oauth/register
58
+ Content-Type: application/json
59
+
60
+ {
61
+ "client_name": "Cursor AI Agent",
62
+ "redirect_uris": ["http://localhost:8400/callback"],
63
+ "grant_types": ["authorization_code", "refresh_token"]
64
+ }
65
+ ```
66
+
67
+ Response:
68
+
69
+ ```
70
+ HTTP/1.1 201 Created
71
+ Content-Type: application/json
72
+
73
+ {
74
+ "client_id": "mcp_client_xyz789",
75
+ "client_name": "Cursor AI Agent",
76
+ "redirect_uris": ["http://localhost:8400/callback"],
77
+ "grant_types": ["authorization_code", "refresh_token"]
78
+ }
79
+ ```
80
+
81
+ ### Phase 3: Authorization Request with PKCE
82
+
83
+ - MCP client opens a browser with a URL pointing to the authorization endpoint:
84
+
85
+ `GET /oauth/authorize?response_type=code&client_id=mcp_client_xyz789&redirect_uri=http%3A%2F%2Flocalhost%3A8400%2Fcallback&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&state=random_state_string HTTP/1.1`
86
+
87
+ the server leads the user through signin and consent workflow. Finally it
88
+ generates a temporary auth code and redirects to the client's callback URL:
89
+
90
+ Response:
91
+
92
+ ```
93
+ HTTP/1.1 302 Found
94
+ Location: http://localhost:8400/callback?code=splat-auth-code-123&state=random_state_string
95
+ ```
96
+
97
+ ### Phase 4: Token Exchange
98
+
99
+ - MCP client grabs the code and exchanges it with the token endpoint:
100
+
101
+ ```
102
+ POST /oauth/token HTTP/1.1
103
+ Host: auth.example.com
104
+ Content-Type: application/x-www-form-urlencoded
105
+
106
+ grant_type=authorization_code&code=splat-auth-code-123&redirect_uri=http%3A%2F%2Flocalhost%3A8400%2Fcallback&client_id=mcp_client_xyz789&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
107
+ ```
108
+
109
+ The authorization server hashes the code_verifier and verifies it matches the
110
+ challenge submitted in Phase 3. If it does, it returns an access token:
111
+
112
+ ```
113
+ HTTP/1.1 200 OK
114
+ Content-Type: application/json
115
+
116
+ {
117
+ "access_token": "mcp_access_token_abc123",
118
+ "token_type": "Bearer",
119
+ "expires_in": 3600,
120
+ "refresh_token": "mcp_refresh_token_def456"
121
+ }
122
+ ```
123
+
124
+ ### And we're done
125
+
126
+ The client can now seamlessly access the MCP resources by attaching the header:
127
+
128
+ `Authorization: Bearer mcp_access_token_abc123`
@@ -0,0 +1,18 @@
1
+ # https://datatracker.ietf.org/doc/html/rfc8414#section-2
2
+ export ->(req) {
3
+ req.respond_json(
4
+ {
5
+ issuer: "http://localhost:1234/",
6
+ registration_endpoint: "http://localhost:1234/oauth/register",
7
+ authorization_endpoint: "http://localhost:1234/oauth/authorize",
8
+ token_endpoint: "http://localhost:1234/oauth/token",
9
+ scopes_supported: ["mcp:read", "mcp:write"],
10
+ response_types_supported: ["code"]
11
+ # jwks_uri: "http://localhost:1234/.well-known/jwks.json",
12
+ # grant_types_supported: ["authorization_code", "refresh_token"],
13
+ # code_challenge_methods_supported: ["S256"],
14
+ # claims_supported: ["aud", "iss", "exp", "scope", "sub"],
15
+ # client_id_metadata_document_supported: true
16
+ }
17
+ )
18
+ }
@@ -0,0 +1,10 @@
1
+ # https://datatracker.ietf.org/doc/html/rfc9728#name-protected-resource-metadata
2
+ export ->(req) {
3
+ req.respond_json(
4
+ {
5
+ resource: "http://localhost:1234/",
6
+ authorization_servers: ["http://localhost:1234/"],
7
+ scopes_supported: ["mcp:read", "mcp:write"]
8
+ }
9
+ )
10
+ }
@@ -0,0 +1,23 @@
1
+ export self
2
+
3
+ require 'securerandom'
4
+
5
+ @store = {}
6
+
7
+ def store(params)
8
+ key = SecureRandom.hex(16)
9
+ @store[key] = params
10
+ key
11
+ end
12
+
13
+ def fetch(key)
14
+ @store[key]
15
+ end
16
+
17
+ def update(key, value)
18
+ @store[key] = value
19
+ end
20
+
21
+ def fetch_and_remove(key)
22
+ @store.delete(key)
23
+ end
@@ -0,0 +1 @@
1
+ # Syntropy OAuth 2.1 Example
@@ -0,0 +1,85 @@
1
+ AuthStore = import './_lib/auth_store'
2
+
3
+ export ->(req) {
4
+ req.validate_http_method('post')
5
+ req.validate_content_type('application/json')
6
+
7
+ if !(token_info = valid_token?(req))
8
+ respond_unauthorized(req)
9
+ return
10
+ end
11
+
12
+ handle(req, token_info)
13
+ }
14
+
15
+ # @param req [Syntropy::Request]
16
+ def valid_token?(req)
17
+ token = req.auth_bearer_token
18
+ return false if !token
19
+
20
+ token_info = AuthStore.fetch(token)
21
+ return false if !token_info
22
+
23
+ true
24
+ end
25
+
26
+ def respond_unauthorized(req)
27
+ req.respond_json(
28
+ {
29
+ error: 'unauthorized',
30
+ message: 'Authentication required'
31
+ },
32
+ ':status' => HTTP::UNAUTHORIZED,
33
+ 'WWW-Authenticate' => <<~EOF.tr("\n", ' ')
34
+ Bearer realm="mcp",
35
+ resource_metadata="http://localhost:1234/.well-known/oauth-protected-resource"
36
+ EOF
37
+ )
38
+ end
39
+
40
+ def handle(req, token_info)
41
+ req.validate_http_method('post')
42
+ req.validate_content_type('application/json')
43
+ json = JSON.parse(req.read)
44
+
45
+ req.validate(json['jsonrpc'], '2.0')
46
+ req.validate(json['id'], [Integer, String])
47
+
48
+ method = req.validate(json['method'], String)
49
+ sym = :"handle_#{method}"
50
+ raise Syntropy::ValidationError, 'METHOD_NOT_FOUND: method not found' if !respond_to?(sym)
51
+
52
+ send(sym, req, json, token_info)
53
+ rescue Syntropy::ValidationError => e
54
+ if (m = e.message.match(/(.+)\: (.+)/))
55
+ type, message = m[1], m[2]
56
+ else
57
+ type, message = 'INVALID_REQUEST', e.message
58
+ end
59
+ respond_error(req, json, type, message)
60
+ end
61
+
62
+ ERROR_CODES = {
63
+ 'INVALID_REQUEST' => -32600,
64
+ 'METHOD_NOT_FOUND' => -32601,
65
+ 'INVALID_PARAMS' => -32602,
66
+ 'INTERNAL_ERROR' => -32603,
67
+ 'PARSE_ERROR' => -32700
68
+ }
69
+
70
+ def respond_error(req, json, error_type, error_message)
71
+ error_code = ERROR_CODES[type] || ERROR_CODES['INTERNAL_ERROR']
72
+ req.respond_json(
73
+ {
74
+ jsonrpc: '2.0',
75
+ id: json['id'],
76
+ error: {
77
+ code: error_code,
78
+ message: error_message
79
+ }
80
+ }
81
+ )
82
+ end
83
+
84
+ def handle_initialize()
85
+ end
@@ -0,0 +1,18 @@
1
+ AuthStore = import '../_lib/auth_store'
2
+
3
+ # https://datatracker.ietf.org/doc/html/rfc6749#section-3.1
4
+ export ->(req) {
5
+ req.validate_http_method('get')
6
+ params = req.query
7
+ req.validate(params['response_type'], 'code')
8
+
9
+ client_info = AuthStore.fetch(params['client_id'])
10
+ req.validate(client_info, Hash)
11
+
12
+ key = AuthStore.store(req.query)
13
+ req.respond(nil, {
14
+ ':status' => Syntropy::HTTP::FOUND,
15
+ 'Location' => '/signin',
16
+ 'Set-Cookie' => "oauth_signin_id=#{key}"
17
+ })
18
+ }
@@ -0,0 +1,86 @@
1
+ AuthStore = import '../_lib/auth_store'
2
+
3
+ export ->(req) {
4
+ case req.method
5
+ when 'get'
6
+ render_consent_form(req)
7
+ when 'post'
8
+ validate_consent(req)
9
+ else
10
+ raise Syntropy::Error.method_not_allowed
11
+ end
12
+ }
13
+
14
+ def render_consent_form(req)
15
+ oauth_signin_id = req.cookies['oauth_signin_id']
16
+ auth_info = AuthStore.fetch(oauth_signin_id)
17
+ req.validate(auth_info, Hash)
18
+
19
+ client_id = auth_info['client_id']
20
+ req.validate(client_id, String)
21
+ client_info = AuthStore.fetch(client_id)
22
+
23
+ sid = auth_info['sid']
24
+ req.validate(sid, String)
25
+ session_info = AuthStore.fetch(sid)
26
+
27
+ req.respond_html(@consent_form.render(client_info, session_info))
28
+ end
29
+
30
+ def validate_consent(req)
31
+ data = req.get_form_data
32
+ decision = data['decision']
33
+ req.validate(decision, ['deny', 'allow'])
34
+
35
+ oauth_signin_id = req.cookies['oauth_signin_id']
36
+ auth_info = AuthStore.fetch(oauth_signin_id)
37
+ req.validate(auth_info, Hash)
38
+
39
+ callback_query = case decision
40
+ when 'deny'
41
+ {
42
+ error: 'access_denied',
43
+ state: auth_info['state']
44
+ }
45
+ when 'allow'
46
+ auth_code = AuthStore.store(auth_info)
47
+ {
48
+ code: auth_code,
49
+ state: auth_info['state']
50
+ }
51
+ end
52
+
53
+ uri = format(
54
+ '%s?%s', auth_info['redirect_uri'],
55
+ URI.encode_www_form(callback_query)
56
+ )
57
+ req.respond(
58
+ nil,
59
+ ':status' => Syntropy::HTTP::FOUND,
60
+ 'Location' => uri
61
+ )
62
+ end
63
+
64
+ @consent_form = template { |client_info, session_info|
65
+ html {
66
+ head {
67
+ title 'My awesome site'
68
+ }
69
+ body {
70
+ h2 client_info['client_name']
71
+ p {
72
+ span 'wants to access my awesome site on behalf of '
73
+ em session_info[:username]
74
+ span '.'
75
+ }
76
+
77
+ form(action: '') {
78
+ div {
79
+ button 'Deny', type: 'submit', name: 'decision', value: 'deny'
80
+ button 'Allow', type: 'submit', name: 'decision', value: 'allow'
81
+ }
82
+ }
83
+ }
84
+ auto_refresh!
85
+ }
86
+ }
@@ -0,0 +1,14 @@
1
+ AuthStore = import '../_lib/auth_store'
2
+
3
+ # https://datatracker.ietf.org/doc/html/rfc7591
4
+ export ->(req) {
5
+ req.validate_http_method('post')
6
+ req.validate_content_type('application/json')
7
+ client_info = JSON.parse(req.read)
8
+ client_id = AuthStore.store(client_info)
9
+
10
+ req.respond_json(
11
+ { client_id: }.merge(client_info),
12
+ ':status' => HTTP::CREATED
13
+ )
14
+ }
@@ -0,0 +1,79 @@
1
+ AuthStore = import '../_lib/auth_store'
2
+
3
+ # https://datatracker.ietf.org/doc/html/rfc6749#section-5
4
+ export ->(req) do
5
+ req.validate_http_method('post')
6
+ params = req.get_form_data
7
+ req.validate(
8
+ params['redirect_uri'], String,
9
+ message: 'invalid_request'
10
+ )
11
+
12
+ req.validate(
13
+ params['grant_type'], 'authorization_code',
14
+ message: 'unsupported_grant_type'
15
+ )
16
+
17
+ code = params['code']
18
+ auth_info = AuthStore.fetch(code)
19
+ req.validate(
20
+ auth_info, Hash,
21
+ message: 'invalid_request'
22
+ )
23
+
24
+ req.validate(
25
+ params['redirect_uri'], auth_info['redirect_uri'],
26
+ message: 'invalid_request'
27
+ )
28
+
29
+ client_id = params['client_id']
30
+ client_info = AuthStore.fetch(client_id)
31
+ req.validate(
32
+ client_info, Hash,
33
+ message: 'invalid_client'
34
+ )
35
+
36
+ code_verifier = params['code_verifier']
37
+ hashed = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
38
+ req.validate(
39
+ hashed, auth_info['code_challenge'],
40
+ message: 'invalid_grant'
41
+ )
42
+
43
+ session_info = AuthStore.fetch(auth_info['sid'])
44
+ req.validate(
45
+ session_info, Hash,
46
+ message: 'invalid_grant'
47
+ )
48
+
49
+ token_info = session_info.merge(
50
+ # some app-specific metadata
51
+ type: 'oauth',
52
+ ttl: 86400 * 30
53
+ )
54
+ token = AuthStore.store(token_info)
55
+
56
+ req.respond_json({
57
+ access_token: token,
58
+ token_type: 'Bearer',
59
+ expires_in: token_info[:ttl]
60
+ })
61
+ rescue ValidationError => e
62
+ req.respond_json(
63
+ {
64
+ error: e.message
65
+ },
66
+ ':status' => Syntropy::HTTP::BAD_REQUEST
67
+ )
68
+ rescue => e
69
+ status = Syntropy::Error.http_status(e)
70
+ raise if status == HTTP::INTERNAL_SERVER_ERROR
71
+
72
+ req.respond_json(
73
+ {
74
+ error: 'invalid_request',
75
+ error_description: e.message
76
+ },
77
+ ':status' => Syntropy::HTTP::BAD_REQUEST
78
+ )
79
+ end
@@ -0,0 +1,85 @@
1
+ AuthStore = import '../_lib/auth_store'
2
+
3
+ export ->(req) {
4
+ case req.method
5
+ when 'get'
6
+ render_signin_form(req)
7
+ when 'post'
8
+ validate_signin(req)
9
+ else
10
+ raise Syntropy::Error.method_not_allowed
11
+ end
12
+ }
13
+
14
+ def render_signin_form(req)
15
+ req.respond_html(@signin_form.render)
16
+ end
17
+
18
+ def validate_signin(req)
19
+ creds = req.get_form_data
20
+
21
+ if !valid_creds?(creds)
22
+ req.respond_html(
23
+ @signin_form.render,
24
+ ':status' => Syntropy::HTTP::UNAUTHORIZED
25
+ )
26
+ return
27
+ end
28
+
29
+ sid = AuthStore.store({
30
+ username: creds['username'],
31
+ timestamp: Time.now.to_i
32
+ })
33
+
34
+ oauth_signin_id = req.cookies['oauth_signin_id']
35
+ if oauth_signin_id
36
+ auth_info = AuthStore.fetch(oauth_signin_id)
37
+ AuthStore.update(oauth_signin_id, auth_info.merge('sid' => sid))
38
+ req.validate(auth_info, Hash)
39
+ req.respond(
40
+ nil,
41
+ ':status' => Syntropy::HTTP::SEE_OTHER,
42
+ 'Location' => '/oauth/consent',
43
+ )
44
+ return
45
+ end
46
+
47
+ req.respond(
48
+ nil,
49
+ ':status' => Syntropy::HTTP::SEE_OTHER,
50
+ 'Location' => '/',
51
+ 'Set-Cookie' => "sid=#{sid}"
52
+ )
53
+ end
54
+
55
+ def valid_creds?(creds)
56
+ (creds['username'] == 'foobar') && (creds['password'] == 'foobar')
57
+ end
58
+
59
+ @signin_form = template {
60
+ html {
61
+ head {
62
+ title 'My awesome site'
63
+ }
64
+ body {
65
+ h1 'Sign in:'
66
+
67
+ form(method: 'post') {
68
+ div {
69
+ label 'username:', for: 'username'
70
+ input type: 'text', name: 'username', required: true
71
+ }
72
+
73
+ div {
74
+ label 'password:', for: 'password'
75
+ input type: 'password', name: 'password', required: true
76
+ }
77
+
78
+ div {
79
+ input type: 'submit', value: 'Submit'
80
+ }
81
+ }
82
+ }
83
+ auto_refresh!
84
+ }
85
+ }
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # require 'bundler/setup'
4
+ # require 'syntropy'
5
+ # require 'syntropy/test'
6
+ # require 'minitest/autorun'
7
+
8
+ # STDOUT.sync = true
9
+ # STDERR.sync = true
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helper'
4
+
5
+ class AppTest < Minitest::Test
6
+ APP_ROOT = File.expand_path(File.join(__dir__, '../app'))
7
+ HTTP = Syntropy::HTTP
8
+
9
+ def setup
10
+ @machine = UM.new
11
+ @app = Syntropy::App.new(
12
+ root_dir: APP_ROOT,
13
+ mount_path: '/',
14
+ machine: @machine
15
+ )
16
+ @test_harness = Syntropy::TestHarness.new(@app)
17
+ end
18
+
19
+ def test_root
20
+ req = @test_harness.request(
21
+ ':method' => 'GET',
22
+ ':path' => '/'
23
+ )
24
+ assert_equal HTTP::OK, req.response_status
25
+ assert_match /Syntropy/, req.response_body
26
+ end
27
+ end