Skip to content

Commit c981fbe

Browse files
committed
Merge remote-tracking branch 'origin/feat/oauth-provider' into feat/scim-provisioning
2 parents d4aaf58 + 751550d commit c981fbe

112 files changed

Lines changed: 14040 additions & 2027 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/docs/content/docs/cli/authentication.mdx‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -237,9 +237,13 @@ Save it to avoid repeating the flag:
237237
sim configure --set-endpoint http://localhost:3000 --profile local
238238
```
239239

240-
A self-hosted deployment offers OAuth sign-in when
241-
`OAUTH_PROVIDER_ENABLED=true` and authentication is enabled. Leave it unset to
242-
use the pairing-code handoff; `DISABLE_AUTH=true` also forces OAuth off.
240+
A deployment offers OAuth sign-in when its global `oauth-provider` feature flag
241+
is enabled. With AppConfig, enable it in the existing `feature-flags` document
242+
using `"oauth-provider": { "enabled": true }`. When AppConfig is disabled or no
243+
AppConfig document has been loaded, `OAUTH_PROVIDER_ENABLED=true` supplies the fallback.
244+
With the provider off, the CLI uses the pairing-code handoff; `DISABLE_AUTH=true`
245+
always forces OAuth off. Operators must apply the database migration and drain
246+
older app instances before enabling it. See [Sign in with Sim](/platform/self-hosting/authentication#sign-in-with-sim).
243247

244248
## Where the login is stored
245249

‎apps/docs/content/docs/platform/enterprise/self-hosted.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ Persist that value as `CRON_SECRET` on the app **and** on whatever calls these e
9090
<Callout type="warn">
9191
Both shipped deployments schedule the data-drain dispatcher and OAuth token cleanup, but **not** the three configurable data-retention endpoints. Setting `DATA_RETENTION_ENABLED=true` alone deletes no retained product data — those windows are evaluated only when one of the three endpoints is called. Add them to `cronjobs.jobs` yourself, or drive them from an external scheduler.
9292

93-
OAuth token cleanup continues after `OAUTH_PROVIDER_ENABLED=false` so rows created while the provider was enabled do not become permanent.
93+
OAuth token cleanup continues when the global `oauth-provider` feature flag is off, so rows created while the provider was enabled do not become permanent. See [Sign in with Sim](/platform/self-hosting/authentication#sign-in-with-sim) for AppConfig and fallback configuration.
9494
</Callout>
9595

9696
```bash

‎apps/docs/content/docs/platform/self-hosting/authentication.mdx‎

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,23 +85,46 @@ Your deployment can act as an OAuth 2.0 authorization server using authorization
8585
code with PKCE and current OAuth security guidance. The Sim CLI uses it when
8686
enabled; see [CLI authentication](/cli/authentication).
8787

88-
Use a two-phase rollout: apply the database migration while this flag is unset,
89-
deploy and drain every older app instance, then enable it in a separate config
90-
rollout:
88+
The global `oauth-provider` feature flag controls availability. Keep it off while
89+
applying the database migration, then deploy and drain every older app instance
90+
before enabling it.
91+
92+
If your deployment uses AWS AppConfig, add this entry to the existing
93+
`feature-flags` document and deploy that configuration:
94+
95+
```json
96+
{
97+
"oauth-provider": { "enabled": true }
98+
}
99+
```
100+
101+
Preserve the document's other entries. This flag is global: use `enabled`, not
102+
workspace, organization, user, or admin targeting. Set `enabled` to `false` to
103+
turn it off; changes take effect as instances refresh their AppConfig cache.
104+
105+
When AppConfig is disabled or no AppConfig document has been loaded, the
106+
fallback is:
91107

92108
```bash
93109
OAUTH_PROVIDER_ENABLED=true
94110
```
95111

96-
With it unset or false, the discovery document at `/.well-known/oauth-authorization-server`
97-
returns 404 and the CLI falls back to the pairing-code handoff on its own.
112+
In that fallback mode, unset or false keeps the provider off. An available
113+
AppConfig document takes precedence over this variable, including when the
114+
`oauth-provider` entry is missing or disabled. AppConfig fetch failures retain
115+
the last successfully loaded document.
116+
117+
When the provider is off, discovery at `/.well-known/oauth-authorization-server`
118+
returns 404 and the CLI falls back to the pairing-code handoff.
98119
`DISABLE_AUTH=true` also forces the provider off because the authorization flow
99120
requires a real Better Auth user session.
100121

101122
Access tokens are opaque and last an hour; refresh tokens rotate on every use.
102123
Each login has a fixed thirty-day lifetime that refreshing does not extend.
103-
Nothing is cached, so revoking a grant under
104-
**Settings → Authorized apps** stops the app on its very next request.
124+
Token validation checks current grants, so revoking a grant under
125+
**Settings → Authorized apps** stops the app on its very next request. These
126+
settings remain available for reviewing and revoking existing grants while the
127+
provider is off, and scheduled OAuth token cleanup continues.
105128

106129
### Registering an app
107130

‎apps/docs/content/docs/platform/self-hosting/environment-variables.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ Google, GitHub, and Microsoft sign-in, their callback URLs, and the `DISABLE_*_A
127127

128128
| Variable | Description |
129129
| --- | --- |
130-
| `OAUTH_PROVIDER_ENABLED` | Set to `true` in a second rollout after the migration is applied and every older app instance is drained. Unset/false uses the CLI pairing-code handoff. `DISABLE_AUTH=true` always forces it off. See [Authentication](/platform/self-hosting/authentication#sign-in-with-sim) |
130+
| `OAUTH_PROVIDER_ENABLED` | Fallback for the global `oauth-provider` feature flag when AppConfig is disabled or no AppConfig document has been loaded. Set to `true` only after the migration is applied and every older app instance is drained. With AppConfig, use `"oauth-provider": { "enabled": true }` in the existing `feature-flags` document instead. `DISABLE_AUTH=true` always forces it off. See [Authentication](/platform/self-hosting/authentication#sign-in-with-sim) |
131131

132132
## Integration Credentials
133133

‎apps/docs/openapi-v2-files-audit.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3285,7 +3285,7 @@
32853285
"uploadedByEmail": {
32863286
"type": "string",
32873287
"format": "email",
3288-
"pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
3288+
"pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$",
32893289
"description": "Current email address of the uploader.",
32903290
"examples": ["jane@example.com"]
32913291
},
@@ -4147,7 +4147,7 @@
41474147
"uploadedByEmail": {
41484148
"type": "string",
41494149
"format": "email",
4150-
"pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
4150+
"pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$",
41514151
"description": "Current email address of the uploader.",
41524152
"examples": ["jane@example.com"]
41534153
},
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createServer, type Server as HttpServer } from 'node:http'
5+
import type { AddressInfo } from 'node:net'
6+
import { Server } from 'socket.io'
7+
import { io as connect, type Socket } from 'socket.io-client'
8+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
9+
import { setupConnectionHandlers, waitForConnectionCleanup } from '@/handlers/connection'
10+
import type { AuthenticatedSocket } from '@/middleware/auth'
11+
import { MemoryRoomManager } from '@/rooms'
12+
13+
vi.mock('@/handlers/file-doc', () => ({ cleanupFileDocForSocket: vi.fn() }))
14+
vi.mock('@/handlers/subblocks', () => ({ cleanupPendingSubblocksForSocket: vi.fn() }))
15+
vi.mock('@/handlers/variables', () => ({ cleanupPendingVariablesForSocket: vi.fn() }))
16+
17+
describe('server shutdown connection drain', () => {
18+
let httpServer: HttpServer
19+
let io: Server
20+
let manager: MemoryRoomManager
21+
let client: Socket
22+
23+
beforeEach(async () => {
24+
httpServer = createServer()
25+
io = new Server(httpServer, { transports: ['websocket'] })
26+
manager = new MemoryRoomManager(io)
27+
await manager.initialize()
28+
io.on('connection', (socket) => setupConnectionHandlers(socket as AuthenticatedSocket, manager))
29+
await new Promise<void>((resolve) => httpServer.listen(0, '127.0.0.1', resolve))
30+
const port = (httpServer.address() as AddressInfo).port
31+
client = connect(`http://127.0.0.1:${port}`, { transports: ['websocket'], autoConnect: false })
32+
const connected = new Promise<void>((resolve) => client.once('connect', resolve))
33+
client.connect()
34+
await connected
35+
})
36+
37+
afterEach(async () => {
38+
client.disconnect()
39+
await io.close()
40+
await waitForConnectionCleanup()
41+
await manager.shutdown()
42+
vi.restoreAllMocks()
43+
})
44+
45+
it('keeps automatic reconnection active after transport shutdown', async () => {
46+
const disconnected = new Promise<string>((resolve) => client.once('disconnect', resolve))
47+
await io.close()
48+
expect(await disconnected).toBe('transport close')
49+
expect(client.active).toBe(true)
50+
await waitForConnectionCleanup()
51+
})
52+
53+
it('waits for asynchronous presence cleanup before releasing its dependencies', async () => {
54+
let finishRemoval: (() => void) | undefined
55+
vi.spyOn(manager, 'removeSocketFromAllRooms').mockImplementation(
56+
() =>
57+
new Promise((resolve) => {
58+
finishRemoval = () => resolve([])
59+
})
60+
)
61+
await io.close()
62+
let drained = false
63+
const drain = waitForConnectionCleanup().then(() => {
64+
drained = true
65+
})
66+
await Promise.resolve()
67+
expect(drained).toBe(false)
68+
expect(finishRemoval).toBeDefined()
69+
finishRemoval?.()
70+
await drain
71+
expect(drained).toBe(true)
72+
})
73+
})

‎apps/realtime/src/handlers/connection.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ const logger = createLogger('ConnectionHandlers')
1616
*/
1717
const PRESENCE_BEARING_TYPES = new Set<RoomRef['type']>([ROOM_TYPES.WORKFLOW, ROOM_TYPES.TABLE])
1818

19+
const pendingDisconnects = new Set<Promise<void>>()
20+
21+
/** Keep Redis available until disconnect listeners finish removing presence. */
22+
export async function waitForConnectionCleanup(): Promise<void> {
23+
await Promise.all(pendingDisconnects)
24+
}
25+
1926
export function setupConnectionHandlers(socket: AuthenticatedSocket, roomManager: IRoomManager) {
2027
socket.on('error', (error) => {
2128
logger.error(`Socket ${socket.id} error:`, error)
@@ -28,7 +35,7 @@ export function setupConnectionHandlers(socket: AuthenticatedSocket, roomManager
2835
// `disconnecting` (not `disconnect`): here `socket.rooms` is still populated and
2936
// authoritative, so presence is cleaned up even if the Redis room-set key was
3037
// evicted or TTL-expired (which would leave the manager's stored rooms empty).
31-
socket.on('disconnecting', async (reason) => {
38+
const handleDisconnect = async (reason: string) => {
3239
try {
3340
// Snapshot the live Socket.IO room membership SYNCHRONOUSLY, before any
3441
// await: Socket.IO clears `socket.rooms` via leaveAll() as soon as the
@@ -91,5 +98,11 @@ export function setupConnectionHandlers(socket: AuthenticatedSocket, roomManager
9198
} catch (error) {
9299
logger.error(`Error handling disconnect for socket ${socket.id}:`, error)
93100
}
101+
}
102+
103+
socket.on('disconnecting', (reason) => {
104+
const cleanup = handleDisconnect(reason)
105+
pendingDisconnects.add(cleanup)
106+
void cleanup.finally(() => pendingDisconnects.delete(cleanup))
94107
})
95108
}

‎apps/realtime/src/handlers/file-doc-app.ts‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,8 @@ function postToApp(path: string, payload: unknown, timeoutMs: number): Promise<R
1818
}
1919

2020
/**
21-
* Ask the app to build a server-authoritative seed (markdown → Yjs) for a file's collaborative
22-
* document. Returns the Yjs update to apply, or `null` for a genuinely empty/missing file (an empty
23-
* document is correct). THROWS on a transport failure (non-2xx / network / timeout / malformed body)
24-
* so the caller can tell a real empty from a failure it should be allowed to retry.
21+
* Existing empty files have named, versioned seeds; only missing files return null.
22+
* Transport and malformed-response failures throw so callers retry instead of creating empty rooms.
2523
*/
2624
export async function fetchFileDocSeed(
2725
workspaceId: string,

0 commit comments

Comments
 (0)