Compare commits

..
9 Commits
Author SHA1 Message Date
Oleg Lukasonok 0570e2d551 README: coffee button at the top 2026-11-08 22:06:56 +02:00
Oleg Lukasonok 4d0c0382f1 README: buy me a coffee 2026-11-08 22:04:44 +02:00
Oleg Lukasonok 7dd28ee646 README: sign-in through 127.0.0.1, SSO without setup or passwords, the Argo CD template 2026-11-08 21:53:26 +02:00
Oleg Lukasonok 253635ffef Sign-in ends on a page with a button back to the editor
Browsers open an app link only after a click on the page. With a live
identity provider session the whole sign-in ran without one, so the
redirect to vscode:// was dropped and the editor never got its code. The
page opens the editor at once and keeps a button for when that is held
back. Server 0.1.4, chart 0.1.5.
2026-11-08 19:56:53 +02:00
Oleg Lukasonok 50d3d81b78 Sign-in goes straight to the identity provider
With single sign-on there is nothing to choose, so /authorize redirects to
it; the page shows only when something went wrong there.
Server 0.1.3, chart 0.1.4.
2026-11-08 19:43:07 +02:00
Oleg Lukasonok 1ab3d0b26a Single sign-on is the only way in when it is configured
The sign-in page shows only the identity provider; password sign-in and
new local accounts are refused. Invites go through the identity provider.
Server 0.1.2, chart 0.1.3.
2026-11-08 19:39:02 +02:00
Oleg Lukasonok 01a35f7ba6 Single sign-on servers need no setup step
With single sign-on there is no first-start code and no /setup form: the
identity provider says who may sign in and who is admin, and the first
allowed sign-in makes the organisation, named after ARCHIFY_SYNC_NAME.
Server 0.1.1, chart 0.1.2.
2026-11-08 19:34:48 +02:00
Oleg Lukasonok d8d46266dc Chart: postgresql.existingClaim for a database volume made outside the chart 2026-11-06 21:23:49 +02:00
Oleg Lukasonok 99778d7489 Archify Sync server, Dockerfile and Helm chart
Rust server (axum, yrs, sqlx/PostgreSQL): live shared models over the y-websocket protocol, versions, browser sign-in with PKCE, invites, single sign-on through OIDC, metrics. Docker image on distroless, compose file, and a Helm chart with one pod, optional bundled PostgreSQL and secrets from existing Secrets.
2026-11-06 12:06:59 +02:00
9 changed files with 114 additions and 38 deletions

No files matched your search

Generated
+3 -3
View File
@@ -43,7 +43,7 @@ dependencies = [
[[package]]
name = "archify-sync-protocol"
version = "0.1.0"
version = "0.1.4"
dependencies = [
"chrono",
"serde",
@@ -53,7 +53,7 @@ dependencies = [
[[package]]
name = "archify-sync-server"
version = "0.1.0"
version = "0.1.4"
dependencies = [
"anyhow",
"archify-sync-protocol",
@@ -83,7 +83,7 @@ dependencies = [
[[package]]
name = "archify-sync-store"
version = "0.1.0"
version = "0.1.4"
dependencies = [
"chrono",
"serde_json",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/protocol", "crates/store", "crates/server"]
[workspace.package]
version = "0.1.0"
version = "0.1.4"
edition = "2024"
license = "MIT"
repository = "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext-sync"
+17 -9
View File
@@ -1,8 +1,10 @@
# Archify Sync
[![Buy me a coffee](https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png)](https://buymeacoffee.com/root.at.skic)
The server behind live, shared models in [Archify Diagram Viewer](https://marketplace.visualstudio.com/items?itemName=root-at-skic.archify-vscode-ext). People edit one model at the same time and see each other type. Every edit is kept, versions are taken on their own, and anyone can publish one by hand.
One Rust process and PostgreSQL. The design, with its decisions, is the Archify model in `ws-storm/2026-10-30-archify-sync-backend/`.
One Rust process and PostgreSQL.
## How it works
@@ -14,9 +16,9 @@ One Rust process and PostgreSQL. The design, with its decisions, is the Archify
- **Publish (1.0, 2.0, …):** marked by hand, with a name.
- **Restore:** brings an earlier version back as a new edit and a new version; history is never rewritten.
- **Update log:** taking a version compacts it.
- **Sign-in.** The editor opens `/authorize` in the browser and gets a one-time code back through its URI handler. It exchanges the code with its PKCE verifier for an access token (JWT, 15 minutes) and a refresh token. The refresh token is single use: reusing one signs out the whole session.
- **First start.** A server without accounts prints a first-start code in its log. The first person to use it at `/setup` becomes the admin.
- **Invites.** An invite link (`/join/<token>`) opens the extension. Someone new makes an account on the sign-in page, so no mail server is needed.
- **Sign-in.** The editor opens `/authorize` in the browser and gets a one-time code back: on the desktop through a one-off listener on `127.0.0.1`, in remote and web editors through its URI handler (`vscode://…/auth`). After sign-in the server shows a page that opens the editor, with a button for when the browser holds the link back. The editor exchanges the code with its PKCE verifier for an access token (JWT, 15 minutes) and a refresh token. The refresh token is single use: reusing one signs out the whole session.
- **First start.** With single sign-on there is no setup step: the identity provider says who may sign in and who is admin, and the first sign-in makes the organisation (named after `ARCHIFY_SYNC_NAME`). Without single sign-on, a server without accounts prints a first-start code in its log; the first person to use it at `/setup` becomes the admin.
- **Invites.** An invite link (`/join/<token>`) opens the extension. Someone new makes an account on the sign-in page, so no mail server is needed. With single sign-on, the identity provider is the only way in: sign-in goes straight to it, with no password form, and an invited person signs in there.
## Layout
@@ -36,7 +38,7 @@ docker run -d --name archify-pg -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=archify
ARCHIFY_SYNC_DATABASE_URL=postgres://postgres:dev@127.0.0.1:5432/archify cargo run -p archify-sync-server
```
The log shows the first-start code. Open `http://localhost:8080/setup` and enter it.
Without single sign-on, the log shows the first-start code. Open `http://localhost:8080/setup` and enter it.
Or the server and its database in containers:
@@ -47,7 +49,7 @@ docker compose logs server | grep "with code"
## Kubernetes
The homelab deployment is in [archify-vscode-ext-sync-gitops](https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext-sync-gitops) (rendered from `archify-vscode-ext-sync-gitops-tmpl`); Argo CD takes this chart from here.
For Argo CD, [archify-vscode-ext-sync-gitops-tmpl](https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext-sync-gitops-tmpl) is a Copier template that renders an app-of-apps with this chart, its values and, optionally, its secrets through External Secrets.
The chart is in `charts/archify-sync`. It always runs one pod with the `Recreate` strategy: one server holds the live copy of each space, and the extension works offline while the pod is replaced. Probes use `/healthz` and `/readyz`; on `SIGTERM` the server stores a snapshot of every open space.
@@ -57,7 +59,7 @@ helm install sync charts/archify-sync -n archify-sync --create-namespace \
--set database.existingSecret=archify-sync-db # a Secret with the URL under "url"
```
`postgresql.enabled=true` adds a single PostgreSQL to the release, for trying out. Behind ingress-nginx, raise `proxy-read-timeout` and `proxy-send-timeout` (see `values.yaml`): edits travel over a WebSocket that stays open.
`postgresql.enabled=true` adds a single PostgreSQL to the release, for trying out. `postgresql.existingClaim` puts its data on a claim made outside the chart; PostgreSQL runs as uid 70 and needs that directory owned by 70, mode 0700, so an NFS share must not map every user to root. Behind ingress-nginx, raise `proxy-read-timeout` and `proxy-send-timeout` (see `values.yaml`): edits travel over a WebSocket that stays open.
On a local cluster (OrbStack, kind, k3d) with the image from `docker build -t archify-sync:local .`:
@@ -69,7 +71,7 @@ The server is then at `http://localhost:18081`.
## Single sign-on (OIDC)
With an OpenID Connect provider (Keycloak, Authentik, Entra ID, …) the sign-in page shows **Sign in with Keycloak** above the password form. Nothing changes in the extension: it opens the same page, and the provider sends the person back to `<public URL>/oidc/callback`. People are matched by the provider's issuer and subject, never by name; a new person gets an account named after `preferred_username` (`ana`, or `ana-2` when `ana` is taken).
With an OpenID Connect provider (Keycloak, Authentik, Entra ID, …) the provider is the only way in: `/authorize` goes straight to it, there is no password form, and no local accounts are made. Nothing changes in the extension: it opens the same page, and the provider sends the person back to `<public URL>/oidc/callback`. People are matched by the provider's issuer and subject, never by name; a new person gets an account named after `preferred_username` (`ana`, or `ana-2` when `ana` is taken).
Who gets in:
@@ -77,7 +79,7 @@ Who gets in:
- **No allowed groups**: anyone the provider signs in, but a new person needs an invite link the first time.
- **Admin groups** (`ARCHIFY_SYNC_OIDC_ADMIN_GROUPS=archify-admins`): their members are admins of the server.
The first admin is still set up with a password on `/setup`; keep that account as the way in when the provider is down.
There is no `/setup` step: the first sign-in by an allowed or admin member makes the organisation, named after `ARCHIFY_SYNC_NAME`.
### The client at the provider
@@ -169,3 +171,9 @@ All settings come from the environment.
## Licence
MIT
## Support
Archify Sync is built and maintained in my spare time. If it helps you, a coffee keeps the releases coming:
[![Buy me a coffee](https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png)](https://buymeacoffee.com/root.at.skic)
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: archify-sync
description: Archify Sync, live and shared Archify models for VS Code
type: application
version: 0.1.0
appVersion: "0.1.0"
version: 0.1.5
appVersion: "0.1.4"
annotations:
artifacthub.io/license: MIT
sources:
@@ -97,6 +97,12 @@ spec:
volumes:
- name: run
emptyDir: {}
{{- with .Values.postgresql.existingClaim }}
- name: data
persistentVolumeClaim:
claimName: {{ . }}
{{- end }}
{{- if not .Values.postgresql.existingClaim }}
volumeClaimTemplates:
- metadata:
name: data
@@ -108,4 +114,5 @@ spec:
resources:
requests:
storage: {{ .Values.postgresql.storage }}
{{- end }}
{{- end }}
+3
View File
@@ -31,6 +31,9 @@ postgresql:
existingSecret: ""
storage: 5Gi
storageClass: ""
# A PersistentVolumeClaim made outside the chart (e.g. a static NFS volume whose
# directory is owned by uid 70, mode 0700); storage and storageClass are then unused.
existingClaim: ""
resources: {}
config:
+50 -22
View File
@@ -190,6 +190,11 @@ pub async fn authorize_page(
State(state): State<AppState>,
Query(q): Query<AuthorizeQuery>,
) -> Response {
// With single sign-on there is nothing to choose: straight to the identity
// provider. The page only shows when something went wrong there.
if state.oidc.is_some() && check_authorize(&state, &q).is_ok() {
return Redirect::to(&format!("/oidc/start?{}", q.to_query_string())).into_response();
}
authorize_form(&state, &q, None).await
}
@@ -215,24 +220,29 @@ async fn authorize_form(state: &AppState, q: &AuthorizeQuery, error: Option<&str
.unwrap_or_default(),
]
.concat();
// Single sign-on first when the server has it; the password form stays for local accounts.
let mut body = state
.oidc
.as_ref()
.map(|o| {
format!(
r#"<div class="card"><h2>Sign in for VS Code</h2>{}<a class="button" href="/oidc/start?{}">Sign in with {}</a></div>"#,
error_line(error),
escape(&q.to_query_string()),
escape(&o.config.label),
)
})
.unwrap_or_default();
let (title, error) = if body.is_empty() {
("Sign in for VS Code", error)
} else {
("Or with a password", None)
};
// With single sign-on the identity provider is the only way in: no
// password form, no new local accounts. An invite goes through it too.
if let Some(o) = &state.oidc {
let invited = match &q.invite {
Some(invite) => match state.store.invite_organisation(&auth::sha256(invite)).await {
Ok(Some((_, space_name))) => format!(
r#"<p class="muted">You were invited to <b>{}</b>.</p>"#,
escape(&space_name)
),
_ => String::new(),
},
None => String::new(),
};
let body = format!(
r#"<div class="card"><h2>Sign in for VS Code</h2>{}{invited}<a class="button" href="/oidc/start?{}">Sign in with {}</a></div>"#,
error_line(error),
escape(&q.to_query_string()),
escape(&o.config.label),
);
return page(state, "Sign in", &body);
}
let mut body = String::new();
let title = "Sign in for VS Code";
body.push_str(&format!(
r#"<div class="card"><h2>{title}</h2>{}
<form method="post" action="/authorize">{fields}{}
@@ -269,6 +279,10 @@ pub async fn authorize_submit(
if check_authorize(&state, &q).is_err() {
return authorize_form(&state, &q, None).await;
}
if let Some(o) = &state.oidc {
let message = format!("This server signs in with {} only.", o.config.label);
return authorize_form(&state, &q, Some(&message)).await;
}
let account = match form.action.as_str() {
"signup" => sign_up(&state, &form).await,
_ => sign_in(&state, &form).await,
@@ -300,7 +314,18 @@ async fn complete(state: &AppState, q: &AuthorizeQuery, account: &AccountRow) ->
.query_pairs_mut()
.append_pair("code", &code)
.append_pair("state", &q.state);
Redirect::to(target.as_str()).into_response()
// A page, not a redirect: browsers open an app link only after a click on
// the page, and with a live identity provider session nobody clicked. The
// page tries at once and keeps a button for when the browser held back.
let link = escape(target.as_str());
page(
state,
"Signed in",
&format!(
r#"<meta http-equiv="refresh" content="0;url={link}"><div class="card"><h2>Signed in as {}</h2><p class="muted">Your editor finishes the sign-in. If it did not open, use the button.</p><a class="button" href="{link}">Open the editor</a></div>"#,
escape(&account.display_name),
),
)
}
async fn sign_in(state: &AppState, form: &AuthorizeForm) -> Result<AccountRow, ApiErr> {
@@ -531,9 +556,12 @@ async fn sso_account(
.invite_organisation(&auth::sha256(invite))
.await?
.map(|(org, _)| org),
None if !config.allowed_groups.is_empty() || admin => {
state.store.first_organisation().await?
}
None if !config.allowed_groups.is_empty() || admin => Some(
state
.store
.organisation_or_create(&state.config.server_name)
.await?,
),
None => None,
};
let Some(organisation) = organisation else {
+3 -1
View File
@@ -42,7 +42,9 @@ impl AppState {
let secret = STANDARD.decode(stored.as_str().unwrap_or_default())?;
anyhow::ensure!(secret.len() >= 32, "the stored signing key is too short");
let first_start = if store.account_count().await? == 0 {
// With single sign-on the identity provider says who is admin; there
// is no first-start code and no setup page.
let first_start = if config.oidc.is_none() && store.account_count().await? == 0 {
let code = auth::first_start_code();
tracing::warn!(
"first start: no accounts yet. Set up the first admin at {}/setup with code {code}",
+28
View File
@@ -313,6 +313,34 @@ impl Store {
Ok(())
}
/// The server's organisation, made with `name` when there is none yet
/// (a single sign-on server has no setup step).
pub async fn organisation_or_create(&self, name: &str) -> Result<Uuid> {
let mut tx = self.pool.begin().await?;
// Serialises the first sign-ins, so only one organisation is made.
sqlx::query("LOCK TABLE organisations IN EXCLUSIVE MODE")
.execute(&mut *tx)
.await?;
let found: Option<Uuid> =
sqlx::query_scalar("SELECT id FROM organisations ORDER BY created_at LIMIT 1")
.fetch_optional(&mut *tx)
.await?;
let id = match found {
Some(id) => id,
None => {
let id = Uuid::now_v7();
sqlx::query("INSERT INTO organisations (id, name) VALUES ($1, $2)")
.bind(id)
.bind(name)
.execute(&mut *tx)
.await?;
id
}
};
tx.commit().await?;
Ok(id)
}
/// The server's organisation: the one made at setup.
pub async fn first_organisation(&self) -> Result<Option<Uuid>> {
Ok(