Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fddf3ed571 | ||
|
|
fae1389657 | ||
|
|
2fbd3a024d | ||
|
|
dd6c454d23 | ||
|
|
7da2be887d | ||
|
|
994df0d675 | ||
|
|
7c86198002 | ||
|
|
6114a994db | ||
|
|
6baa46bc28 |
No files matched your search
Generated
+3
-3
@@ -43,7 +43,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archify-sync-protocol"
|
name = "archify-sync-protocol"
|
||||||
version = "0.1.0"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -53,7 +53,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archify-sync-server"
|
name = "archify-sync-server"
|
||||||
version = "0.1.0"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"archify-sync-protocol",
|
"archify-sync-protocol",
|
||||||
@@ -83,7 +83,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archify-sync-store"
|
name = "archify-sync-store"
|
||||||
version = "0.1.0"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "3"
|
|||||||
members = ["crates/protocol", "crates/store", "crates/server"]
|
members = ["crates/protocol", "crates/store", "crates/server"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext-sync"
|
repository = "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext-sync"
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
# Archify Sync
|
# Archify Sync
|
||||||
|
|
||||||
|
[](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.
|
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 sharing, sign-in and setup fit together, as a walkthrough: [articles on Medium](https://medium.com/@root.at.skic).
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
@@ -14,9 +18,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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.** A server without accounts prints a first-start code in its log. The first person to use it at `/setup` becomes the admin.
|
- **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.
|
- **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
|
## Layout
|
||||||
|
|
||||||
@@ -36,7 +40,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
|
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:
|
Or the server and its database in containers:
|
||||||
|
|
||||||
@@ -47,7 +51,7 @@ docker compose logs server | grep "with code"
|
|||||||
|
|
||||||
## Kubernetes
|
## 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.
|
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 +61,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"
|
--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 .`:
|
On a local cluster (OrbStack, kind, k3d) with the image from `docker build -t archify-sync:local .`:
|
||||||
|
|
||||||
@@ -69,7 +73,7 @@ The server is then at `http://localhost:18081`.
|
|||||||
|
|
||||||
## Single sign-on (OIDC)
|
## 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:
|
Who gets in:
|
||||||
|
|
||||||
@@ -77,7 +81,7 @@ Who gets in:
|
|||||||
- **No allowed groups**: anyone the provider signs in, but a new person needs an invite link the first time.
|
- **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.
|
- **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
|
### The client at the provider
|
||||||
|
|
||||||
@@ -169,3 +173,9 @@ All settings come from the environment.
|
|||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Archify Sync is built and maintained in my spare time. If it helps you, a coffee keeps the releases coming:
|
||||||
|
|
||||||
|
[](https://buymeacoffee.com/root.at.skic)
|
||||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
|||||||
name: archify-sync
|
name: archify-sync
|
||||||
description: Archify Sync, live and shared Archify models for VS Code
|
description: Archify Sync, live and shared Archify models for VS Code
|
||||||
type: application
|
type: application
|
||||||
version: 0.1.0
|
version: 0.1.5
|
||||||
appVersion: "0.1.0"
|
appVersion: "0.1.4"
|
||||||
annotations:
|
annotations:
|
||||||
artifacthub.io/license: MIT
|
artifacthub.io/license: MIT
|
||||||
sources:
|
sources:
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ spec:
|
|||||||
volumes:
|
volumes:
|
||||||
- name: run
|
- name: run
|
||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
|
{{- with .Values.postgresql.existingClaim }}
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: {{ . }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if not .Values.postgresql.existingClaim }}
|
||||||
volumeClaimTemplates:
|
volumeClaimTemplates:
|
||||||
- metadata:
|
- metadata:
|
||||||
name: data
|
name: data
|
||||||
@@ -108,4 +114,5 @@ spec:
|
|||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
storage: {{ .Values.postgresql.storage }}
|
storage: {{ .Values.postgresql.storage }}
|
||||||
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
@@ -31,6 +31,9 @@ postgresql:
|
|||||||
existingSecret: ""
|
existingSecret: ""
|
||||||
storage: 5Gi
|
storage: 5Gi
|
||||||
storageClass: ""
|
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: {}
|
resources: {}
|
||||||
|
|
||||||
config:
|
config:
|
||||||
|
|||||||
+47
-19
@@ -190,6 +190,11 @@ pub async fn authorize_page(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(q): Query<AuthorizeQuery>,
|
Query(q): Query<AuthorizeQuery>,
|
||||||
) -> Response {
|
) -> 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
|
authorize_form(&state, &q, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,24 +220,29 @@ async fn authorize_form(state: &AppState, q: &AuthorizeQuery, error: Option<&str
|
|||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
]
|
]
|
||||||
.concat();
|
.concat();
|
||||||
// Single sign-on first when the server has it; the password form stays for local accounts.
|
// With single sign-on the identity provider is the only way in: no
|
||||||
let mut body = state
|
// password form, no new local accounts. An invite goes through it too.
|
||||||
.oidc
|
if let Some(o) = &state.oidc {
|
||||||
.as_ref()
|
let invited = match &q.invite {
|
||||||
.map(|o| {
|
Some(invite) => match state.store.invite_organisation(&auth::sha256(invite)).await {
|
||||||
format!(
|
Ok(Some((_, space_name))) => format!(
|
||||||
r#"<div class="card"><h2>Sign in for VS Code</h2>{}<a class="button" href="/oidc/start?{}">Sign in with {}</a></div>"#,
|
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),
|
error_line(error),
|
||||||
escape(&q.to_query_string()),
|
escape(&q.to_query_string()),
|
||||||
escape(&o.config.label),
|
escape(&o.config.label),
|
||||||
)
|
);
|
||||||
})
|
return page(state, "Sign in", &body);
|
||||||
.unwrap_or_default();
|
}
|
||||||
let (title, error) = if body.is_empty() {
|
let mut body = String::new();
|
||||||
("Sign in for VS Code", error)
|
let title = "Sign in for VS Code";
|
||||||
} else {
|
|
||||||
("Or with a password", None)
|
|
||||||
};
|
|
||||||
body.push_str(&format!(
|
body.push_str(&format!(
|
||||||
r#"<div class="card"><h2>{title}</h2>{}
|
r#"<div class="card"><h2>{title}</h2>{}
|
||||||
<form method="post" action="/authorize">{fields}{}
|
<form method="post" action="/authorize">{fields}{}
|
||||||
@@ -269,6 +279,10 @@ pub async fn authorize_submit(
|
|||||||
if check_authorize(&state, &q).is_err() {
|
if check_authorize(&state, &q).is_err() {
|
||||||
return authorize_form(&state, &q, None).await;
|
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() {
|
let account = match form.action.as_str() {
|
||||||
"signup" => sign_up(&state, &form).await,
|
"signup" => sign_up(&state, &form).await,
|
||||||
_ => sign_in(&state, &form).await,
|
_ => sign_in(&state, &form).await,
|
||||||
@@ -300,7 +314,18 @@ async fn complete(state: &AppState, q: &AuthorizeQuery, account: &AccountRow) ->
|
|||||||
.query_pairs_mut()
|
.query_pairs_mut()
|
||||||
.append_pair("code", &code)
|
.append_pair("code", &code)
|
||||||
.append_pair("state", &q.state);
|
.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> {
|
async fn sign_in(state: &AppState, form: &AuthorizeForm) -> Result<AccountRow, ApiErr> {
|
||||||
@@ -531,9 +556,12 @@ async fn sso_account(
|
|||||||
.invite_organisation(&auth::sha256(invite))
|
.invite_organisation(&auth::sha256(invite))
|
||||||
.await?
|
.await?
|
||||||
.map(|(org, _)| org),
|
.map(|(org, _)| org),
|
||||||
None if !config.allowed_groups.is_empty() || admin => {
|
None if !config.allowed_groups.is_empty() || admin => Some(
|
||||||
state.store.first_organisation().await?
|
state
|
||||||
}
|
.store
|
||||||
|
.organisation_or_create(&state.config.server_name)
|
||||||
|
.await?,
|
||||||
|
),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
let Some(organisation) = organisation else {
|
let Some(organisation) = organisation else {
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ impl AppState {
|
|||||||
let secret = STANDARD.decode(stored.as_str().unwrap_or_default())?;
|
let secret = STANDARD.decode(stored.as_str().unwrap_or_default())?;
|
||||||
anyhow::ensure!(secret.len() >= 32, "the stored signing key is too short");
|
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();
|
let code = auth::first_start_code();
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"first start: no accounts yet. Set up the first admin at {}/setup with code {code}",
|
"first start: no accounts yet. Set up the first admin at {}/setup with code {code}",
|
||||||
|
|||||||
@@ -313,6 +313,34 @@ impl Store {
|
|||||||
Ok(())
|
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.
|
/// The server's organisation: the one made at setup.
|
||||||
pub async fn first_organisation(&self) -> Result<Option<Uuid>> {
|
pub async fn first_organisation(&self) -> Result<Option<Uuid>> {
|
||||||
Ok(
|
Ok(
|
||||||
|
|||||||
Reference in new issue
Block a user